Merge remote-tracking branch 'origin/master' into fix/checkout-workdir-prompt

# Conflicts:
#	packages/ui/app-boot/README.i18n.yaml
#	packages/ui/app-boot/README.md
#	packages/ui/app-boot/README.zh.md
This commit is contained in:
Turtle
2026-08-01 00:02:17 +08:00
1843 changed files with 146944 additions and 15125 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: b187f691df4c6ca9c4fe1fe3bf7e631f14997b01
README.zh.md: 6f86573fbb1e60bf290d90f67e4a574c96219e0a
README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e
README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5

View File

@@ -10,24 +10,26 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
## Personal config
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
@@ -45,4 +47,3 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.

View File

@@ -10,24 +10,26 @@
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
## 个人配置
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
@@ -45,4 +47,3 @@
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。

View File

@@ -1,8 +1,8 @@
/**
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
* optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
* @module @deepseek-ai/dsh-app-boot
*/
@@ -10,13 +10,20 @@ import { pathToFileURL } from 'node:url'
import { readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context } from 'cordis'
import { Context, type FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module 'cordis' {
interface Context {
/** Harness-home path resolver available to Loader `!!js` config expressions. */
dshHomePath?: typeof dshHomePath
}
}
/**
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
@@ -60,16 +67,12 @@ export function loadEnv(
/** File inside the Harness home holding the personal loader overlay patches. */
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time. Personal
// patches are parsed with the same schema so they may reference `process.env`.
// Load-only: this schema never dumps, so no `predicate`/`represent`.
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
// The include's YAML dialect (`!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time), imported
// from the include itself so patch parsing and config dumping can never drift
// from what the include mounts. Personal patches share it so they may
// reference `process.env`.
const personalPatchesSchema = entryListSchema
/**
* Load the optional personal overlay patches (`config.yaml` under the Harness
@@ -94,25 +97,196 @@ export function loadPersonalPatches(
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'personal patches')
}
/**
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
* `--config <path>` overlay applied over the shared base. Same file format as
* {@link loadPersonalPatches}, but a missing file throws, because the caller
* named this file — its absence is a misconfiguration, not "no overlay".
* @param binName - the diagnostic prefix on the thrown error.
* @param file - absolute path of the overlay file.
* @returns the parsed patch list.
*/
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
let content: string
try {
content = readFileSync(file, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
}
return parsePatchList(binName, file, content, 'overlay')
}
/**
* Parse one loader patch list: a top-level YAML array of
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
* because a patch file that cannot be applied at all is a misconfiguration; a
* single patch whose target row is absent stays a per-entry Loader warning, so
* one overlay shared across surfaces does not have to match every tree.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - the source path, quoted in errors.
* @param content - the file's text.
* @param label - what to call this list in errors (`personal patches`, `overlay`).
* @returns the parsed patch list.
*/
function parsePatchList(
binName: string, file: string, content: string, label: string,
): PatchOptions[] {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: personalPatchesSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
}
// A present personal config that cannot apply is a misconfiguration and must
// fail loud here — the include only warns per entry at mount.
parsed.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return parsed as PatchOptions[]
}
/** One overlay patch list with the label provenance comments print for it. */
export interface ConfigDumpLayer {
/** Source name shown in provenance comments (a file basename or path). */
label: string
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
patches: PatchOptions[]
}
/**
* Compose the effective entry list exactly as `boot()` would mount it: parse
* the base config file with the include's entry-list dialect, apply every
* layer's patches as ONE flattened list through the include's own patch
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
* even patch-visibility corner cases (a later layer targeting a group child a
* plain config replacement introduced, which the single-pass id index never
* sees) compose identically — then render the result as YAML in the same
* dialect (`!!js` expressions print verbatim, unevaluated).
*
* Every run of rows with the same provenance is preceded by a `# ==` comment
* naming the file that contributed the rows and any layers that patched them,
* so the output stays a loadable YAML document while showing which section
* comes from which file. Provenance is derived from single-call prefix
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
* only rewrites rows in place or appends, so a top-level index identifies one
* row across snapshots, and a layer whose addition changes the row (config
* replacement, disable, group insert) is listed as having patched it.
*
* A patch that matches no row is reported through `warn` with its layer
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
* see an identical preceding state in every snapshot that includes them, so
* each snapshot's warning list extends the previous one and the new tail
* belongs to the added layer.
* @param binName - the diagnostic prefix on read/parse errors.
* @param absoluteConfigPath - the base config file `boot()` would include.
* @param layers - overlay layers in application order (later wins).
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
* @returns the composed entry list rendered as a YAML document with
* provenance comment separators.
*/
export function renderConfigDump(
binName: string,
absoluteConfigPath: string,
layers: ConfigDumpLayer[],
warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
): string {
let content: string
try {
content = readFileSync(absoluteConfigPath, 'utf8')
} catch (error) {
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
}
let parsed: unknown
try {
parsed = yaml.load(content, { schema: entryListSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
}
const baseLabel = basename(absoluteConfigPath)
// The YAML boundary yields untyped rows; the include validates entry shape
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
// here is structural trust in the same file `boot()` would include.
const base = parsed as Parameters<typeof applyEntryPatches>[0]
// snapshot_k = ONE application of layers 1..k flattened — boot's exact call
// shape for that prefix. snapshot_N is therefore the mounted composition.
// The patches are cloned per call: applyEntryPatches detaches the entry
// list but pushes `insert` rows by reference from the patch list, so
// sharing patch objects across snapshot calls would leak a later
// snapshot's mutations into an earlier one's result.
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
// The include logs through cordis's printf-style logger (`%C` = code); a
// dump has no logger, so substitute inline for a plain line.
let index = 0
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
})
}
let previous = base
let previousWarnings: string[] = []
const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
let composed = base
for (let count = 1; count <= layers.length; count += 1) {
const layer = layers[count - 1]
/* v8 ignore next -- count iterates 1..length, so the slot exists */
if (layer === undefined) continue
const warnings: string[] = []
composed = snapshot(count, warnings)
for (const line of warnings.slice(previousWarnings.length)) {
warn(`${binName}: [${layer.label}] ${line}`)
}
const before = previous.map(entry => JSON.stringify(entry))
for (let index = 0; index < composed.length; index += 1) {
if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] })
else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label)
}
previous = composed
previousWarnings = warnings
}
return groupedDump(composed, provenance)
}
/** Render the composed rows grouped under one provenance comment per contiguous run. */
function groupedDump(
composed: readonly unknown[],
provenance: readonly { origin: string; patchedBy: string[] }[],
): string {
const lines: string[] = []
let currentLabel: string | undefined
let group: unknown[] = []
const flush = (): void => {
if (currentLabel === undefined || group.length === 0) return
lines.push(`# == ${currentLabel}`)
lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
group = []
}
for (let index = 0; index < composed.length; index += 1) {
const record = provenance[index]
/* v8 ignore next -- provenance is index-aligned with composed by construction */
if (record === undefined) continue
const label = record.patchedBy.length === 0
? record.origin
: `${record.origin}, patched by ${record.patchedBy.join(', ')}`
if (label !== currentLabel) {
flush()
currentLabel = label
}
group.push(composed[index])
}
flush()
return lines.join('\n') + '\n'
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
@@ -124,16 +298,46 @@ export interface FailLoudProcess {
exit(code: number): void
}
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
// exact reasons already folded into the boot diagnostic visible through the
// next process rejection checkpoint so the process guard can coalesce them.
const assembledActivationRejections = new Map<unknown, number>()
function retainAssembledRejection(reason: unknown): void {
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
}
function releaseAssembledRejection(reason: unknown): void {
const count = assembledActivationRejections.get(reason)
if (count === undefined || count === 1) {
assembledActivationRejections.delete(reason)
} else {
assembledActivationRejections.set(reason, count - 1)
}
}
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
for (const reason of reasons) retainAssembledRejection(reason)
try {
await new Promise<void>(resolve => setImmediate(resolve))
} finally {
for (const reason of reasons) releaseAssembledRejection(reason)
}
}
/**
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
* the returned function removes the handler.
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
* {@link assertEntriesActivated} is ignored during its process checkpoint;
* every other rejection remains fatal. Stdout remains untouched for ACP; the
* returned function removes the handler.
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
if (assembledActivationRejections.has(err)) return
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
@@ -157,15 +361,65 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
* makes `id` readable as the bare identifier `resumeSessionId` in a config
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
* so resuming a session needs no environment variable. A bin that never
* provides it leaves the identifier undeclared, so configs read it defensively
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
* Value mirrors used because Cordis's const enum has no runtime object to import.
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
* `packages/client/web/src/loader-status.ts`.
*/
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
const FIBER_PENDING = 0 as FiberState.PENDING
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_FAILED = 3 as FiberState.FAILED
/** Render a thrown plugin value without discarding an Error's original stack. */
function formatActivationError(error: unknown): string {
return error instanceof Error ? error.stack ?? error.message : String(error)
}
/**
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
* Plugin failures include the original thrown stack; pending entries name their
* unresolved services because no plugin error exists for that state. Active
* entries require no further wait; only failed fibers are awaited to recover
* their private rejection reason.
* @param ctx - the settled context whose Loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
* @returns nothing when every enabled entry is active.
* @throws after one process rejection checkpoint when an entry failed to
* import, rejected during activation, or did not become active.
*/
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
assertEntriesLoaded(ctx, binName)
const failures: string[] = []
const rejectionReasons: unknown[] = []
for (const entry of ctx.loader.entries()) {
const fiber = entry.fiber
if (fiber === undefined || entry.disabled) continue
const state = fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_FAILED) {
try {
await fiber.await()
} catch (error) {
rejectionReasons.push(error)
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
}
continue
}
if (state === FIBER_PENDING) {
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
const subject = missing.length === 1 ? 'service' : 'services'
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
if (rejectionReasons.length > 0) {
await observeLoaderRejectionCheckpoint(rejectionReasons)
}
const noun = failures.length === 1 ? 'entry' : 'entries'
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
@@ -175,16 +429,21 @@ export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
* bootstrap include is therefore statically imported and mounted as the
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. A missing fiber rejects here; a later init rejection is
* handled by {@link installFailLoud}. Built bins need the Loader's native
* helper for bare plugin specifiers; relative specifiers do not.
* config-relative. The package build embeds Include while leaving Loader
* external, so the built include tree and host share one Loader peer. A
* missing fiber rejects here; a later init rejection is rethrown with its
* original stack by {@link assertEntriesActivated}; later unhandled
* rejections remain covered by {@link installFailLoud}. Built bins need the
* Loader's native helper for bare plugin specifiers; relative specifiers do
* not.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
* @returns the root context once every entry has started.
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
* @returns the root context once every entry has started, or as soon as a
* surface disposed the tree while startup was still in flight.
*/
export async function boot(
binName: string,
@@ -193,10 +452,11 @@ export async function boot(
prepare?: (ctx: Context) => Promise<void> | void,
): Promise<Context> {
const ctx = new Context()
await prepare?.(ctx)
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await prepare?.(ctx)
await ctx.loader.create({
name: 'cordis:include',
config: {
@@ -205,7 +465,14 @@ export async function boot(
},
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
// A surface can finish and dispose the whole tree while that await is still
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
// typed before the last entry settles tears the context down under us. The
// Loader service goes with it, and the activation audit describes a live
// tree — reading `ctx.loader` here would throw a TypeError over an app that
// exited exactly as asked.
if (ctx.get('loader') === undefined) return ctx
await assertEntriesActivated(ctx, binName)
return ctx
}

View File

@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
it('does not report an activation rejection shared by entries in the boot audit', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('assembled activation failure')
const audit = assertEntriesActivated({
loader: {
entries: () => ['broken-a', 'broken-b'].map(name => ({
options: { name },
fiber: {
state: 3,
inject: {},
ctx: { get: () => undefined },
await: async () => { throw error },
},
})),
},
} as unknown as Context, NAME)
await Promise.resolve()
await Promise.resolve()
proc.handlers[0]!(error)
expect(proc.written).toEqual([])
expect(proc.exits).toEqual([])
await expect(audit).rejects.toThrow('assembled activation failure')
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
})
describe('assertEntriesLoaded', () => {
@@ -157,6 +184,116 @@ describe('assertEntriesLoaded', () => {
})
})
describe('assertEntriesActivated', () => {
interface FakeFiber {
state: number
inject: Record<string, unknown>
ctx: { get(name: string): unknown }
await(): Promise<unknown>
}
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
loader: { entries: () => entries },
}) as unknown as Context
const fiber = (
state: number,
error?: unknown,
inject: Record<string, unknown> = {},
services: string[] = [],
): FakeFiber => ({
state,
inject,
ctx: { get: name => services.includes(name) ? {} : undefined },
await: error === undefined ? async () => undefined : async () => { throw error },
})
it('passes active entries and ignores disabled entries', async () => {
let awaitCalls = 0
const active = fiber(2)
active.await = async () => {
awaitCalls++
return undefined
}
const disabled = fiber(3, new Error('disabled failure'))
disabled.await = async () => {
awaitCalls++
throw new Error('disabled failure')
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: active, options: { name: 'active' } },
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
]), NAME)).resolves.toBeUndefined()
expect(awaitCalls).toBe(0)
})
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
const original = new Error('actual plugin failure')
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
})
it('formats stackless and non-Error activation failures', async () => {
const stackless = new Error('stackless failure')
delete (stackless as { stack?: string }).stack
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
})
it('reports unresolved services for pending entries', async () => {
let awaitCalls = 0
const expected = [
`${NAME}: 3 entries did not activate`,
'waiting: pending (waiting for services: missingA, missingB)',
'single-wait: pending (waiting for service: missing)',
'unknown-wait: pending (waiting for services: unknown)',
].join('\n')
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
const singleWait = fiber(0, undefined, { missing: {} })
const unknownWait = fiber(0)
for (const item of [waiting, singleWait, unknownWait]) {
item.await = async () => {
awaitCalls++
return undefined
}
}
await expect(assertEntriesActivated(ctxWith([
{ fiber: waiting, options: { name: 'waiting' } },
{ fiber: singleWait, options: { name: 'single-wait' } },
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
]), NAME)).rejects.toThrow(expected)
expect(awaitCalls).toBe(0)
})
it('retains the numeric diagnostic for a settled unexpected state', async () => {
await expect(assertEntriesActivated(ctxWith([
{ fiber: fiber(4), options: { name: 'disposed' } },
]), NAME)).rejects.toThrow('disposed: fiber state 4')
})
})
describe('loadOverlayPatches', () => {
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
const dir = tmp()
const valid = join(dir, 'valid.yml')
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
const malformed = join(dir, 'malformed.yml')
writeFileSync(malformed, ': bad')
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
const mapping = join(dir, 'mapping.yml')
writeFileSync(mapping, 'id: target\n')
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, '- scalar\n')
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
@@ -176,7 +313,11 @@ describe('boot', () => {
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
expect(hostCtx.loader).toBeDefined()
expect([...hostCtx.loader.entries()]).toEqual([])
prepared.push(hostCtx)
})
try {
expect(prepared).toEqual([ctx])
} finally {
@@ -184,11 +325,68 @@ describe('boot', () => {
}
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
vi.stubEnv('DSH_HOME', dshHome)
writeFileSync(join(dir, 'capture.mjs'), [
'export const name = "capture"',
'export function apply(ctx, config) {',
' ctx.provide("capturedPath", config.path)',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), [
'- id: capture',
' name: ./capture.mjs',
' config:',
" path: !!js dshHomePath('sessions')",
'',
].join('\n'))
let ctx: Context | undefined
try {
ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
} finally {
await ctx?.fiber.dispose()
vi.unstubAllEnvs()
}
})
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
// fiber, which lands while boot() is still awaiting the Loader whenever the
// surface renders before the last entry settles. The Loader service goes
// with the tree, so reading it for the post-boot assertions would crash an
// app that exited exactly as the user asked.
const dir = tmp()
writeFileSync(join(dir, 'exiting.mjs'), [
'export const name = "exiting"',
'export function apply(ctx) {',
' void ctx.root.fiber.dispose()',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
const dir = tmp()
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
`${NAME}: 1 entry did not activate`,
'./waiting.mjs: pending (waiting for service: neverProvided)',
].join('\n'))
})
})
describe('addHarnessSourceSection', () => {

View File

@@ -0,0 +1,187 @@
/**
* `renderConfigDump` behavior: the offline composition must equal what
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
* expressions verbatim, separate provenance runs with comment lines while
* staying one loadable YAML document, and report skipped patches through
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
* shared overlay whose row exists only on another surface.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@cordisjs/plugin-include'
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
function writeBase(dir: string): string {
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: shared',
' name: ./noop.mjs',
' config:',
' value: base',
' key: !!js process.env.DSH_DUMP_SPEC',
'- id: untouched',
' name: ./noop.mjs',
'',
].join('\n'))
return base
}
describe('renderConfigDump', () => {
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
const dir = tmp()
const base = writeBase(dir)
const surface = join(dir, 'surface.yml')
writeFileSync(surface, [
'- id: shared',
' config:',
' value: surface',
' key: !!js process.env.DSH_DUMP_SPEC',
'- insert:',
' - id: surface-extra',
' name: ./noop.mjs',
'',
].join('\n'))
const personal = join(dir, 'personal.yml')
writeFileSync(personal, [
'- id: surface-extra',
' config:',
' value: personal',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
], () => {})
// Comments do not break loadability: the dump parses as one document
// equal to what boot() would mount.
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
id: string
config?: Record<string, unknown>
}[]
expect(parsed).toEqual([
{
id: 'shared',
name: './noop.mjs',
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
},
{ id: 'untouched', name: './noop.mjs' },
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
])
// Unevaluated: the expression text round-trips as a !!js scalar.
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
// Provenance separators: origin file, plus every layer that changed the
// row; an inserted row carries the inserting layer as its origin.
expect(dump).toContain('# == base.yml, patched by surface.yml')
expect(dump).toContain('# == base.yml\n- id: untouched')
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
})
it('groups contiguous same-provenance rows under one separator', () => {
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: a',
' name: ./noop.mjs',
'- id: b',
' name: ./noop.mjs',
'',
].join('\n'))
const dump = renderConfigDump(NAME, base, [], () => {})
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
expect(dump).toContain('# == base.yml\n- id: a')
})
it('composes all layers as one flattened patch list, exactly like boot()', () => {
// boot() flattens every layer into ONE applyEntryPatches call, whose id
// index sees inserted rows but NOT children introduced by a plain group
// `config` replacement. A per-layer composition would rebuild the index
// between layers and let the second layer patch that child — a tree the
// real boot never mounts. Pin the single-call semantics: the child patch
// is skipped (with the layer-labeled warning), matching boot.
const dir = tmp()
const base = join(dir, 'base.yml')
writeFileSync(base, [
'- id: g',
' name: ./group.mjs',
' group: true',
' config: []',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(NAME, base, [
{
label: 'a.yml',
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
},
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
], line => void warnings.push(line))
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
config?: { config?: { v?: number } }[]
}[]
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
// The skipped layer did not change the row, so it is not in provenance.
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
expect(dump).not.toContain('b.yml\n- id: g')
})
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
const dir = tmp()
const base = writeBase(dir)
const overlay = join(dir, 'overlay.yml')
writeFileSync(overlay, [
'- id: only-on-another-surface',
' config:',
' value: ignored',
'- id: shared',
' config:',
' value: patched',
'',
].join('\n'))
const warnings: string[] = []
const dump = renderConfigDump(
NAME, base,
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
line => void warnings.push(line),
)
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
expect(parsed[0]?.config?.value).toBe('patched')
})
it('defaults its warn sink to one stderr line per skipped patch', () => {
const dir = tmp()
const base = writeBase(dir)
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
} finally {
write.mockRestore()
}
})
it('fails loud on a missing, unparsable, or non-array base config', () => {
const dir = tmp()
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to read config `))
const invalid = join(dir, 'invalid.yml')
writeFileSync(invalid, 'invalid: [unclosed\n')
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
const scalar = join(dir, 'scalar.yml')
writeFileSync(scalar, 'id: not-a-list\n')
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
.toThrow('must be a top-level YAML array of entries')
})
})

View File

@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
}
})
})
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config`/personal composition: `dsh` includes one shared
// base and applies each source as its own patch list at the SAME include
// level, because patches never cross an include boundary. A later layer
// must therefore be able to reach a row an earlier layer inserted —
// otherwise every surface-only row (the whole TUI front door) would be
// invisible to the user's `~/.dsh/config.yaml`.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' - insert:',
' - id: surface-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' config:',
' value: personal',
' - id: surface-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown'
/**
* Embed Include while keeping Loader external so the built include tree and
* app host bind to one Loader peer.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
deps: {
alwaysBundle: ['@cordisjs/plugin-include'],
},
})