diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml new file mode 100644 index 0000000000..c462dcec34 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6 +2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md new file mode 100644 index 0000000000..338476924d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -0,0 +1,53 @@ +# Agent Note: Compiler-independent Typert type model + +Status: implemented + +English | [中文](2026-07-27-compiler-independent-typert-model.zh.md) + +## Problem + +Constructing Zod and reflection text directly from the TypeScript AST couples type analysis and business-semantic recognition to a single generation target. Such a generator can answer only “can this syntax be generated?” It cannot provide a canonical representation of packages, faces, public exports, services, events, objects, and their type relationships, nor can static checks and later generation targets reuse it. + +The host and client are independent TypeScript projects; placing both in one `ts.Program` merges conflicting Cordis `Context` and `Events` declarations. At the same time, client types still need to reference host types explicitly, so neither complete isolation nor duplicating types on both sides can express the actual dependencies. + +## Decision + +[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) builds separate `ts.Program` instances from the host and client projects and uses compiler nodes, symbols, and checkers only as extraction tools. After analysis, every generator and scanner consumes only Typert's own `WorkspaceModel`, `FaceModel`, and `TypeGraph`; the model retains no AST or checker objects. The generator has no dependency on `@deepseek-ai/dsh-typert-registry`. + +TypeGraph preserves the developer-authored, pre-evaluation type structure, including generic parameters and applications, explicit inheritance, conditional and mapped types, recursive references, and JSDoc. A reachable type that cannot be represented losslessly causes analysis to fail. If an emitter cannot handle an already modeled node, that emitter fails instead of flattening the type or degrading it to `unknown`. + +Each face independently owns a PackageModel and TypeGraph. Direct project references from `tsconfig.host.json` and `tsconfig.client.json` determine a package's face membership, while `package.json#exports` defines its public boundary. Cross-face relationships come only from explicit imports or re-exports in source and remain separate links; external npm types are recorded as External without reading or copying their declarations. + +PackageModel recognizes Cordis services, events, `@typert object` reference objects, and `@typert schema` data roots. Services and objects expose only public instance members, excluding constructors and static, private, and protected members; inheritance edges remain in TypeGraph instead of being copied into flattened members. When a public property, parameter, or return type lacks an annotation, `check` mode reports an error, while `write` mode writes the checker-inferred result, rebuilds the project, and analyzes it again in strict mode. + +[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas. + +Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry. + +At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog. + +## Verification contract + +A small two-face project in the repository snapshots the complete type model, including its source declaration index. Batched workspace analysis and direct focused analysis must produce model-equivalent `FaceModel` and `TypeGraph` results for the same faces. Compile-time exhaustive maps and runtime set comparisons ensure that every node, target, declaration, and member discriminant is exercised by source-authored TypeScript syntax; a field-semantics matrix covers every keyword, type operator, and literal value category, plus every state of generics, parameters, tuples, mapped modifiers, import attributes, abstract forms, predicates, and enum initializers. + +For every property in `SyntaxZoo`, the TypeScript printer normalizes the source type, which must exactly match the TypeGraph rendering; TypeScript then recompiles every rendered declaration. This layer checks that each node's internal information is preserved losslessly, including no-substitution template literals, type queries with type arguments, and constrained `infer`, without substituting discriminant coverage or code coverage for structural equivalence. + +Boundary cases pin explicit package imports within and across faces, cross-face named re-exports, exact export aliases, qualified `import()` links, and the External classification of global `@types` declarations; they reject TypeScript diagnostics originating in package-owned files, relative-path boundary crossings, references outside `package.json#exports`, and cross-face namespace re-exports without a model target. Interface declaration merging explicitly preserves every authored part; other merges that cannot be represented losslessly fail. + +For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute the JavaScript, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts. + +## Alternatives considered + +**Retain the TypeScript AST directly.** The AST preserves source syntax, but it would make every consumer depend on the compiler lifecycle, node identity, and checker context, preventing a stable architectural boundary. It is therefore used only during extraction. + +**Generate final types from the checker.** A flattened `ts.Type` is easy to traverse directly, but it loses the developer's expression of generics, conditional and mapped types, and alias applications, so it cannot support reflection and later generation needs. + +**Merge the host/client projects or duplicate host types.** Merging would contaminate Cordis declaration merging; duplication would create a second source of truth for types. Independent faces with explicit cross-face links preserve project isolation and actual reference relationships. + +**Make `dsh-typert-registry` responsible for type resolution and cross-package composition.** That would recouple the TypeScript compiler, Cordis lifecycle, and a specific schema policy. The registry remains a lifecycle container for generated artifacts, while the build-time model retains complex analysis. + +## Consequences + +New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. + +Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md new file mode 100644 index 0000000000..a88f4dbba5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 编译器无关的 Typert 类型模型 + +Status: implemented + +[English](2026-07-27-compiler-independent-typert-model.md) | 中文 + +## Problem + +直接从 TypeScript AST 拼接 Zod 和反射文本,会把类型分析、业务语义识别与单个生成目标绑在一起。这样的生成器只能回答“这段语法能否生成”,无法提供包、face、公开导出、service、event、对象及其类型关系的标准表示,也无法供静态检查和后续生成目标复用。 + +host 与 client 属于独立 TypeScript project;把两者放进同一个 `ts.Program` 会合并冲突的 Cordis `Context` 与 `Events` 声明。与此同时,client 类型仍需显式引用 host 类型,因此完全隔离或在两边复制类型都不能表达真实依赖。 + +## Decision + +[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) 分别从 host 和 client project 建立 `ts.Program`,只把 compiler node、symbol 和 checker 当作提取工具。分析结束后,所有生成器和扫描器只消费 Typert 自有的 `WorkspaceModel`、`FaceModel` 与 `TypeGraph`,模型中不保留 AST 或 checker 对象。生成器不依赖 `@deepseek-ai/dsh-typert-registry`。 + +TypeGraph 保存开发者写下的计算前类型结构,包括泛型参数与应用、显式继承、conditional、mapped、递归引用和 JSDoc。无法无损表示的可达类型使分析失败;某个 emitter 无法处理已经建模的节点时由该 emitter 失败,而不是把类型展平或降级为 `unknown`。 + +每个 face 独立拥有 PackageModel 和 TypeGraph。`tsconfig.host.json` 与 `tsconfig.client.json` 的直接 project references 决定 package 的 face 归属,`package.json#exports` 决定公开边界。跨 face 关系只来自源码中的显式 import 或 re-export,并作为独立 link 保留;外部 npm 类型记录为 External,不读取或复制其声明。 + +PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@typert schema` 数据根。service 与 object 只暴露 public instance member,排除 constructor、static、private 和 protected;继承边保留在 TypeGraph 中,不复制为扁平成员。缺少 public property、parameter 或 return 类型标注时,`check` 模式报错,`write` 模式写入 checker 推断结果后重建 project 并再次以严格模式分析。 + +[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。 + +包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。 + +构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。 + +## Verification contract + +提交内的小型双 face project 对完整类型模型及其源码声明索引做 snapshot。全仓分批分析与直接聚焦分析必须为相同 face 生成模型等价的 `FaceModel` 与 `TypeGraph`。类型级全集和运行时集合比较保证每种 node、target、declaration 与 member discriminant 都来自真实 TypeScript syntax;字段语义矩阵覆盖所有 keyword、type operator、literal value 类目,以及泛型、参数、tuple、mapped modifier、import attributes、abstract、predicate 和 enum initializer 的各个状态。 + +`SyntaxZoo` 中每个 property 的源码类型经 TypeScript printer 标准化后,必须与 TypeGraph 渲染结果逐项相等,随后所有渲染 declaration 再交给 TypeScript 编译。这一层检查节点内部信息是否无损,包括无插值 template literal、带 type argument 的 type query 和受约束 `infer`,不以 discriminant 覆盖或代码覆盖率代替结构等价。 + +边界用例固定同 face 与跨 face 的显式包导入、跨 face 命名 re-export、精确 export alias、qualified `import()` link 和全局 `@types` External 归属,并拒绝 package 自有 TypeScript 诊断、相对路径越界、`package.json#exports` 之外的引用,以及尚无模型 target 的跨 face namespace re-export。interface declaration merging 显式保留每个 authored part,无法无损表示的其他 merge 失败。 + +Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行 JavaScript,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。 + +## Alternatives considered + +**直接保存 TypeScript AST。** AST 能保留源码写法,但会让每个消费者依赖 compiler 生命周期、node identity 和 checker 上下文,无法形成稳定的架构边界,因此只在提取阶段使用。 + +**基于 checker 的最终类型生成。** 展平后的 `ts.Type` 便于直接遍历,却丢失泛型、conditional、mapped 和 alias application 的开发者表达,无法满足反射与后续生成需要。 + +**合并 host/client project 或复制 host 类型。** 合并会污染 Cordis declaration merging;复制会产生第二份类型事实源。独立 face 加显式 cross-face link 保留了 project 隔离与真实引用关系。 + +**让 `dsh-typert-registry` 承担类型解析和跨包合成。** 这会把 TypeScript compiler、Cordis 生命周期和具体 schema 策略重新耦合。注册表保持为生成 artifact 的生命周期容器,复杂分析留在构建期模型。 + +## Consequences + +新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 + +显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 8d57b6fdcc..809c14dedb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md 2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe -2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348 +2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 11ed3a5d17..66855c3c4f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( `packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: - **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 260ea57905..3ecd4e2dbe 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c -2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11 +2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 +2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 33ec23dd4f..ce25f40e96 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs. **Shared (here):** -- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop). +- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. - **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. @@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Consequences -Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. +Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 8e8c89a4ec..062160931f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。 - **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 +每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index cb3d84e8a9..ca5cc25241 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 4fc621a9fdfa8042ebf3eb1975f0930cb1bf116c -2026-07-05-skill-system.zh.md: 0dbebd211a1fa9e434d3f0a189c936f2b1c76574 +2026-07-05-skill-system.md: 242650ec8ba64fd0801a958711d5790fae07b259 +2026-07-05-skill-system.zh.md: da5f4af4b2f8bc0144be0a7ed608de7edd9a9947 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 4fc621a9fd..242650ec8b 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -18,13 +18,13 @@ Provider plugins register synchronously during `apply()`. Provider membership is The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. -Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. +Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations. `dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) -The `skill({ name })` tool loads one full skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation` retain distinct tool errors. The tool result is the model-visible disclosure path. +The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills with `invocation.modelInvocable: false` retain distinct tool errors. The tool result is the model-visible disclosure path. The data structures and catalog/tool contract are documented in [skills.md](../../../../docs/core-data-structures/skills.md), with service signatures in the generated [services catalog](../../../../docs/cordis-catalog/services.md). @@ -52,4 +52,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis ## Deferred -Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). +Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation ships as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 0dbebd211a..da5f4af4b2 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -18,13 +18,13 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 -每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 +每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 `dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) -`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 +注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。 @@ -52,4 +52,4 @@ agent-core 主干包含一个目录贡献者、一个本地提供方和一个面 ## 延后 -Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段,`user-invocable` frontmatter 字段同样不会被解析。直接用户调用本身则作为消费方层面的能力交付:TUI 前门基于注册表现有的 `list()` 与 `get()` 方法提供手动 `/skill:` 命令,无需变更注册表、提供方或工具契约——见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 +Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 6d9112d663..4574ce7c75 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 -2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +2026-07-21-tui-skill-slash-command.md: 872e1f109728731e0d55e81a538c81e794724856 +2026-07-21-tui-skill-slash-command.zh.md: 772e25745ea7ab25f715208a9c6b1d10cf0c6e65 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index 8370ab61f5..872e1f1097 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -10,17 +10,17 @@ The [skill system](2026-07-05-skill-system.md) shipped with model-initiated load ## Decision -The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. +The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool. Its visibility and loading policy comes from the shared [independent model and user skill invocation policy](2026-07-28-skill-invocation-policy.md). The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete filters the invocation-neutral `list()` result with `isUserInvocable`, and manual submission applies the same predicate after trusted `get()` resolves the definition. A user-only skill can therefore appear and load even when model invocation is disabled, while a user-disabled skill is neither advertised nor loadable by exact name. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, a user-disabled name, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. ## Alternatives considered -**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers. +**Add a `user-invocable` frontmatter field only inside the original TUI change.** Rejected there because a TUI-only field would have changed the registry, provider, and tool contract without a shared invocation model. The later [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md) adds it across every relevant consumer and preserves `get()` as a trusted primitive. **Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 66edec6ecd..772e25745e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的功能;它不新增任何面向模型的工具。其可见性和加载策略来自共享的[模型与用户独立 skill 调用策略](2026-07-28-skill-invocation-policy.md)。 TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全使用 `isUserInvocable` 过滤与调用策略无关的 `list()` 结果;手动提交则在受信的 `get()` 解析定义后应用相同判定。因此,即使模型调用已禁用,仅供用户调用的 skill 仍会显示并可加载;用户禁用的 skill 既不会展示,也无法按精确名称加载。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、用户禁用的名称以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 ## Alternatives considered -**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。 +**仅在最初的 TUI 变更内新增 `user-invocable` frontmatter 字段。** 当时未采纳,因为 TUI 独有的字段会在没有共享调用模型的情况下改变注册表、提供方和工具契约。后续的[独立调用策略决策](2026-07-28-skill-invocation-policy.md)将其扩展到每个相关消费方,并保留 `get()` 作为受信原语。 **把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml new file mode 100644 index 0000000000..f2b2b5b22e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140 +2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md new file mode 100644 index 0000000000..f74b0bcfdd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -0,0 +1,50 @@ +# Agent Note: Independent model and user skill invocation policy + +Status: implemented + +English | [中文](2026-07-28-skill-invocation-policy.zh.md) + +## Problem + +The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through `get()`. + +The local parser also exposed an internal camel-case spelling as frontmatter. Supporting the established negative `disable-model-invocation` and positive `user-invocable` fields requires a durable, symmetric domain representation without turning every possible YAML key into an untyped cross-package contract. + +## Decision + +`SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record`, then projects only recognized fields and their defaults into the normalized typed policy. + +`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. The model tool and TUI check the invocation-neutral summary before calling `get()`, then recheck the loaded definition so a denied name never reaches definition loading and a policy change between discovery and load cannot expose its body. + +The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. + +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. + +These rules permit all four combinations: + +| Policy | Model surface | User surface | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | included | included | +| `{ modelInvocable: true, userInvocable: false }` | included | excluded | +| `{ modelInvocable: false, userInvocable: true }` | excluded | included | +| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | + +This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md). + +## Alternatives considered + +**Store all frontmatter in a generic `Map` and read string keys in `isModelInvocable` / `isUserInvocable`.** Rejected because misspelled keys, non-boolean values, and consumer-specific coercion would cross package seams without type checking. The parser boundary remains open; the domain model is deliberately typed and narrow. + +**Keep `ctx.skills.list()` model-filtered and add a second user list.** Rejected because discovery, duplicate resolution, caching, and ordering are surface-neutral work. One complete catalog plus explicit predicates prevents those mechanisms from drifting while making each consumer's policy visible at its boundary. + +**Enforce invocation policy inside `ctx.skills.get()`.** Rejected because `get()` cannot know whether its caller is a model tool, a human command, or trusted orchestration. Filtering there would also make the both-disabled quadrant impossible to inspect or administer. + +**Treat camel-case frontmatter as an alias.** Rejected because the external format is the kebab-case Claude skills contract and the repository has no released compatibility obligation. Failing loud avoids silently preserving a nonstandard spelling. + +**Add a browser-side direct skill invocation RPC.** Rejected for this change because the existing browser flow inserts a model reference rather than a loaded instruction body. Its correct policy is therefore the intersection; a direct user-loading surface needs its own wire and logging design. + +## Consequences + +Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior. + +The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. The assembled keyless TUI snapshot discovers and loads a user-only skill by exact name, then rejects a model-only skill before loading its body; the real Loader/PTY smoke proves the same user-only path through the shipped terminal process. The real-host Chromium snapshot pins the browser intersection across all four policy quadrants. TUI unit coverage exercises those quadrants plus disposal races, while registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection. diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md new file mode 100644 index 0000000000..1a7117a382 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 模型与用户彼此独立的 skill(技能)调用策略 + +Status: implemented + +[English](2026-07-28-skill-invocation-policy.md) | 中文 + +## 问题 + +skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill,而 `ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。 + +本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation` 和正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。 + +## 决策 + +`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。 + +`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 `get()` 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。 + +本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 + +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 + +这些规则允许以下四种组合: + +| 策略 | 模型侧接口 | 用户侧接口 | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | +| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 | +| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | +| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | + +该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。 + +## 曾考虑的替代方案 + +**将所有 frontmatter 存入通用 `Map`,并在 `isModelInvocable` / `isUserInvocable` 中读取字符串键。** 不予采纳,因为拼写错误的键、非布尔值以及各消费方自行采用的类型转换都会越过包边界,且无法获得类型检查。解析器边界仍保持开放;领域模型则有意采用类型明确的窄接口。 + +**保持 `ctx.skills.list()` 仅返回允许模型调用的 skill,并另增一份用户列表。** 不予采纳,因为发现、重复项解析、缓存和排序都是与调用接口无关的工作。采用一份完整目录和显式判定函数,可以避免这些机制逐渐分化,并在各消费方边界清楚呈现其策略。 + +**在 `ctx.skills.get()` 内执行调用策略。** 不予采纳,因为 `get()` 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。 + +**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 契约,而本仓库尚未发布,无需承担兼容义务。快速失败可以避免暗中保留不符合标准的拼写。 + +**增加由浏览器端直接调用 skill 的 RPC。** 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。 + +## 后果 + +提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。 + +无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及资源释放竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index e0fc203ce6..de8869a96a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9 +2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index e0072458e4..e796620567 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -10,18 +10,22 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered **Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer. +**Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat. + +**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. + **Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat. **Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. ## Consequences -Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome. +Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 1cc25a9656..72d3b4e0cd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -10,18 +10,22 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 **在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。 +**给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 + +**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 + **把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 **通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 ## 后果 -已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。 +已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index 6d017a8961..3c41db871b 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-06-11-quality-gates.md: e1af110387936d644208dc1829fde4a4fdf8a3f9 -2026-06-11-quality-gates.zh.md: a4e57b7a08ecf20babb33b55d8c94414df1b10b1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-quality-gates.md +2026-06-11-quality-gates.md: 60db7ba5cfa8184c0fcce764aa027f32a9b721ab +2026-06-11-quality-gates.zh.md: a5ac7cd831255d479f7a1d75586785e546877fba diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index e1af110387..60db7ba5cf 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -15,11 +15,11 @@ This codebase is developed primarily by coding agents. Agents follow enforced ga Every mechanically checkable AGENTS.md promise gets a command that exits non-zero. CI invokes the exhaustive set, while Git hooks reserve their latency budget for cheap local defects: - Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary. -- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded. +- [Oxlint](2026-07-29-oxlint-linter.md) with type-aware TypeScript rules plus the @stylistic and SonarJS compatibility plugins, enforcing the house style and file-local duplicated-logic checks; vendored code excluded. - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit fixes staged lint, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. +- lefthook pre-commit applies formatting-only ESLint fixes before Oxlint validation and native fixes, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index a4e57b7a08..a5ac7cd831 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -15,11 +15,11 @@ Status: implemented 每条可机械检查的 AGENTS.md 承诺都有一个以非零状态退出的命令。CI 执行完整集合,而 Git 钩子将延迟预算留给可低成本发现的本地缺陷: - 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而包(package)/vendor 代码保持在各自 project-reference 边界之后。 -- ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。 +- [Oxlint](2026-07-29-oxlint-linter.md) 配合类型感知的 TypeScript 规则以及 @stylistic 和 SonarJS 兼容插件,强制执行统一代码风格和文件内重复逻辑检查;vendor 代码排除在外。 - jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit 修复已暂存文件的 lint 问题、拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 +- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 9821b8fea4..8547b425d1 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md -2026-07-06-parallel-pre-push-gates.md: d86642b7feb82908ec792db0c6a3da403cfc79fd -2026-07-06-parallel-pre-push-gates.zh.md: 0425cf1a01b56604a07be366dd46d3920c5fb487 +2026-07-06-parallel-pre-push-gates.md: 538e52c5318fb6d4eab2e8786513a08c1ff0ec55 +2026-07-06-parallel-pre-push-gates.zh.md: e93eec8757c20c8154703d9bdfd2f0c805e6a26c diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index d86642b7fe..538e52c531 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -14,7 +14,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains [scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. -The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that ESLint must not traverse; source compatibility checks can overlap the validation chain. +The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that the linter must not traverse; source compatibility checks can overlap the validation chain. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 0425cf1a01..e93eec8757 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -14,7 +14,7 @@ Status: implemented [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 -Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 ESLint 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 +Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 linter 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 68b4098d4f..ba77f5f044 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3 +2026-07-22-evidence-based-larger-hosted-runners.md: 983d5520bd73fc3cf82c37bf0d4a9ff1c6e6f51c +2026-07-22-evidence-based-larger-hosted-runners.zh.md: a86dcf2c60d7b950e7557e84ef6993e712a2ce09 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 67fc7ded5c..983d5520bd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from Oxlint discovery because the artifact check removes them while these processes overlap. The pnpm store is restored without putting cache uploads on the pull-request critical path; Oxlint has no repository-managed result cache. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 71c5c06736..a86dcf2c60 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 Oxlint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 会得到恢复,但缓存上传不会进入拉取请求关键路径;Oxlint 没有由仓库管理的结果缓存。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 361f7a0bd0..e4cd3fd34f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb -2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +2026-07-22-fast-local-git-hooks.md: 838024c4293372b1430d357774feb06cd9742b9b +2026-07-22-fast-local-git-hooks.zh.md: 460acf5270c075a808c6a4dc42635a808c7cd192 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index a07af1cd42..838024c429 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -12,7 +12,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave ## Decision -[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. +[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, [Oxlint](2026-07-29-oxlint-linter.md) validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. @@ -27,10 +27,10 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 - **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication. - **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits. - **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary. -- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`. +- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the auto-fix workflow; the formatting-only pass and Lefthook's `stage_fixed` preserve it without making ESLint a repository correctness runner or duplicating `git add`. ## Consequences -Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. +Normal commits take the staged formatter-and-lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 78d4ea8980..460acf5270 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -12,7 +12,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, ## 决策 -[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 +[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,[Oxlint](2026-07-29-oxlint-linter.md) 验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 @@ -27,10 +27,10 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 - **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。 - **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。 - **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。 -- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。 +- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留自动修复工作流;仅用于格式化的流程和 Lefthook 的 `stage_fixed` 会保留该工作流,而不会让 ESLint 成为仓库正确性检查运行器,也无需重复执行 `git add`。 ## 结果 -普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是暂存文件格式化与 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml new file mode 100644 index 0000000000..bb5a947cfd --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/process/2026-07-29-oxlint-linter.md +2026-07-29-oxlint-linter.md: 41a50a9d08819809f954aa99007081f270692f38 +2026-07-29-oxlint-linter.zh.md: 1ad72a00cb921ed688363583d56634f52b355b4e diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md new file mode 100644 index 0000000000..41a50a9d08 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md @@ -0,0 +1,45 @@ +# Agent Note: Oxlint as the repository linter + +Status: implemented + +English | [中文](2026-07-29-oxlint-linter.zh.md) + +## Problem + +The repository needs type-aware TypeScript correctness rules, consistent formatting, and file-local duplicate-logic checks across its owned source. ESLint supplied those checks through a JavaScript parser, a project service, and multiple plugins, but a clean lint run spent about one minute on the local migration baseline and required an 8 GiB Node heap, CI result caches, and separately tuned ESLint concurrency. + +A faster runner cannot justify losing rules. The migration must preserve the strict type-checked preset, repository overrides, inline suppressions, @stylistic fixes, SonarJS checks, host/client TypeScript separation, and the vendor exclusion. + +## Decision + +The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative repository lint configuration. The `lint` package script, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) for repository-wide, type-aware, or staged validation. The `lint:fix` script and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs), then run Oxlint. The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; their exact versions pin the tested parser/fixer pairing, and that config contains no correctness or type-aware rules. + +`options.typeAware` enables `oxlint-tsgolint`. Its backend performs per-file TypeScript-project discovery: package sources use their package projects, host tests/examples/website use `tsconfig.host.json`, and client tests plus `scripts/client-bundle-purity.spec.ts` use `tsconfig.client.json`. The program-less root solution is never flattened. Oxlint's `--tsconfig` override affects import resolution but is ignored by type-aware linting, so this repository does not set it. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration. + +Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations but does not execute their fixers, so the formatting-only ESLint pass owns only the corresponding auto-fixes; an executable parity check keeps those fixable rule definitions aligned while `max-len` remains validation-only. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace, and unused directives remain warnings; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. + +CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs Oxlint validation and native safe fixes, accepts selections containing only ignored files, and re-stages the result through lefthook. + +## Verification + +The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A one-time audit against the exact deleted ESLint configuration blob established source 88-to-88, examples 87-to-87, and tests 83-to-83 after the rule-name translations. The committed fingerprint pins those audited Oxlint profiles and the complete override shape; it neither executes the deleted configuration nor propagates later upstream preset changes. Evaluating `typescript-eslint@8.61.0` also confirms that `strictTypeChecked` did not enable `@typescript-eslint/no-empty-function`; the deleted tests-only `off` entry was inert. + +Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin unused-suppression reporting, ignored-only staged selections, formatter/validator rule parity, and final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. + +## Alternatives considered + +**Run both linters repository-wide.** Every correctness rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. A repository-wide ESLint fallback would preserve the slower project-service setup and two correctness configurations without adding a check; the retained ESLint pass is deliberately limited to project-free staged formatting. + +**Rely on compatibility-layer fixes.** The layer reports the established `@stylistic` rules but does not apply their fixes under either Oxlint fix mode. Keeping the narrow staged formatter preserves the contributor contract without broadening ESLint back into a repository linter. + +**Drop @stylistic or SonarJS rules that are not native.** This would remove dependencies but weaken the mechanical quality contract. The compatibility layer preserves those rules until native replacements can be evaluated as a separate decision. + +**Replace @stylistic with Oxfmt during the migration.** A formatter migration would change output beyond the lint-engine boundary and create a repository-wide formatting diff. Keeping the established rules makes this change reviewable and leaves formatter selection independent. + +## Consequences + +Local migration measurements reduced a clean type-aware lint run from about 61 seconds to about 8 seconds without a result cache. The exact ratio is host-dependent and is not a performance guarantee. + +Type-aware diagnostics now come from the TypeScript Go analyzer bundled through `oxlint-tsgolint`, so edge-case inference can differ from typescript-eslint even when `tsc` accepts the same program. Lint and typecheck remain separate required evidence. + +The JavaScript-plugin compatibility API and staged formatter are additional boundaries to maintain. Commits pay one project-free ESLint startup before Oxlint, and the root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md new file mode 100644 index 0000000000..1ad72a00cb --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 使用 Oxlint 作为仓库 linter + +Status: implemented + +[English](2026-07-29-oxlint-linter.md) | 中文 + +## 问题 + +仓库的自有源码需要类型感知的 TypeScript 正确性规则、一致的格式,以及文件内重复逻辑检查。ESLint 通过 JavaScript 解析器、项目服务和多个插件提供这些检查,但在本地迁移基线上,一次无问题的 lint 运行约需 1 分钟,并且需要 8 GiB Node 堆、CI 结果缓存和单独调优的 ESLint 并发度。 + +不能以提高运行速度为由丢失规则。迁移必须保留严格类型检查预设、仓库覆盖配置、内联抑制指令、@stylistic 修复、SonarJS 检查、host/client TypeScript 隔离和 vendor 排除规则。 + +## 决策 + +根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库 lint 配置的权威来源。`lint` 包(package)脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint,进行全仓库、类型感知或暂存验证。`lint:fix` 脚本和 lefthook 先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs),再运行 Oxlint。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 + +`options.typeAware` 启用 `oxlint-tsgolint`。其后端按文件发现 TypeScript 项目:包源码使用各自的包项目,host 测试、示例和网站使用 `tsconfig.host.json`,client 测试及 `scripts/client-bundle-purity.spec.ts` 使用 `tsconfig.client.json`。不含程序的根解决方案绝不会被扁平化。Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。 + +Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规,但不会执行其修复器,因此仅用于格式化的 ESLint 流程只负责相应的自动修复;一项可执行检查确保这些可修复规则定义保持一致,而 `max-len` 仅用于验证。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间,未使用的指令仍作为警告报告;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 + +CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行 Oxlint 验证和原生安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。 + +## 验证 + +解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。以已删除 ESLint 配置的精确 blob 为基准进行的一次性审核在完成规则名映射后确认:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项。已提交的指纹锁定这些经审核的 Oxlint 规则配置及完整的覆盖结构;它既不执行已删除的配置,也不纳入后续的上游预设变更。对 `typescript-eslint@8.61.0` 的评估还确认,`strictTypeChecked` 并未启用 `@typescript-eslint/no-empty-function`;已删除、仅用于测试的 `off` 条目不起作用。 + +可执行契约测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、格式化器与验证器之间的规则一致性,以及最终格式化后的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 + +## 考虑过的替代方案 + +**在全仓库范围内同时运行两个 linter。** 所有正确性规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。在全仓库范围启用 ESLint 回退会保留较慢的项目服务初始化和两套正确性配置,却不会增加任何检查;保留的 ESLint 流程被刻意限制为不加载项目的暂存文件格式化。 + +**依赖兼容层修复。** 兼容层会报告既有的 `@stylistic` 规则,但在 Oxlint 的两种修复模式下都不会应用这些规则的修复。保留窄范围的暂存文件格式化器,可以在不将 ESLint 扩张回仓库 linter 的情况下维持贡献者契约。 + +**移除尚无原生实现的 @stylistic 或 SonarJS 规则。** 这会移除依赖,但也会削弱机械质量契约。兼容层会保留这些规则,直到能够通过单独决策评估原生替代规则。 + +**迁移期间用 Oxfmt 替换 @stylistic。** 格式化器迁移会产生超出 lint 引擎边界的输出变化,并带来全仓库格式 diff。保留既有规则可使本次变更便于评审,并让格式化器选择保持独立。 + +## 结果 + +本地迁移测量显示,不使用结果缓存时,一次无问题的类型感知 lint 运行从约 61 秒缩短至约 8 秒。确切比例因主机而异,不构成性能保证。 + +类型感知诊断现在来自通过 `oxlint-tsgolint` 捆绑的 TypeScript Go 分析器,因此即使 `tsc` 接受同一程序,边界场景下的类型推断也可能与 typescript-eslint 不同。lint 与类型检查仍是两项相互独立的必要证据。 + +JavaScript 插件兼容 API 和暂存文件格式化器是需要维护的额外边界。每次提交在 Oxlint 之前需要启动一次不加载项目的 ESLint,根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e30b9394..6782024240 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,10 +180,9 @@ jobs: || 'dsh-enterprise-ubuntu-latest-32core-test' }} name: node 24 / snapshots and artifacts env: - DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '8' DSH_GATE_CONCURRENCY: '8' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' + DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' # Failover halves snapshot concurrency for the shared 64-core VM. DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} @@ -200,13 +199,6 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - - uses: actions/cache/restore@v4 - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm @@ -443,7 +435,7 @@ jobs: store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) echo "path=$store_path" >> "$GITHUB_OUTPUT" - # Master refreshes the caches that pull requests restore without saving. + # Master refreshes the pnpm store cache that pull requests restore without saving. # The store cache stays a hand-rolled actions/cache step rather than # setup-node's `cache: pnpm`: the enterprise pull-request jobs above # restore exactly this key and path, and setup-node's built-in cache @@ -456,13 +448,6 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -474,8 +459,8 @@ jobs: DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' - DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '1' + DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci @@ -528,8 +513,8 @@ jobs: DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' - DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '1' + DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci @@ -582,15 +567,6 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Master refreshes the small cache that pull requests restore without - # putting package-store extraction back on the Windows critical path. - - uses: actions/cache@v4 - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile @@ -600,8 +576,8 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' - DSH_ESLINT_CACHE: '1' DSH_GATE_CONCURRENCY: '1' + DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci @@ -776,14 +752,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - uses: actions/cache@v4 - if: matrix.platform == 'linux' - with: - path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- - - name: Install and prepare Linux if: matrix.platform == 'linux' run: | @@ -807,9 +775,8 @@ jobs: if: matrix.platform == 'linux' env: DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }} - DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: ${{ matrix.workers }} DSH_GATE_CONCURRENCY: ${{ matrix.workers }} + DSH_OXLINT_THREADS: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} run: pnpm run check:ci diff --git a/.gitignore b/.gitignore index 2ac3f1d7a9..4d3e1305d7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ examples/*/.sessions/ coverage/ .doc-typecheck-*/ .node-next-types-*/ +.oxlint-contract-*/ +.oxlintrc.contract-*.json +oxlint-contract-*.ts .humanize/ tmp/ .claude/commands/ @@ -33,3 +36,4 @@ apps/web/dist/ .worktrees/ worktrees/ .agents/worktrees/ +.typert-*/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..ed470ecd70 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,303 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "categories": { + "correctness": "off" + }, + "options": { + "reportUnusedDisableDirectives": "warn", + "typeAware": true + }, + "env": { + "builtin": true + }, + "ignorePatterns": [ + "**/lib/**", + "**/node_modules/**", + "**/.sessions/**", + ".claude/**", // Harness-local state belongs to other checkouts, not this checkout's sources. + "**/.doc-typecheck-*/**", + "**/.node-next-types-*/**", + "**/.oxlint-contract-*/**", // Scratch files created by the executable lint-contract tests. + "**/oxlint-contract-*", // Flat probes use real TypeScript project include paths. + "packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run. + "website/.generated/**", + "vendor/**", // Vendored source keeps upstream style and idioms. + "native/**", // The imported landlock-run subtree has its own gates; see native/README.md. + "**/*.js", + "**/*.mjs", + "**/*.config.ts", // Tool and app configs are outside the repository TypeScript programs. + "packages/client/tsdown.client.ts" // Shared client build preset, also outside a TypeScript program. + ], + "overrides": [ + { + // Shared strict type-aware rules. Source/test differences stay in the short overrides below. + "files": [ + "packages/*/*/src/**/*.{ts,tsx}", + "packages/*/*/tests/**/*.{ts,tsx}", + "apps/*/src/**/*.{ts,tsx}", + "apps/*/tests/**/*.{ts,tsx}", + "examples/**/*.{ts,tsx}", + "scripts/**/*.{ts,tsx}", + "website/**/*.{ts,tsx}" + ], + "rules": { + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "no-array-constructor": "error", + "no-unused-expressions": "error", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "no-useless-constructor": "error", + "typescript/await-thenable": "error", + "typescript/ban-ts-comment": [ + "error", + { + "minimumDescriptionLength": 10 + } + ], + "typescript/no-array-delete": "error", + "typescript/no-base-to-string": "error", + "typescript/no-confusing-void-expression": "error", + "typescript/no-deprecated": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-duplicate-type-constituents": "error", + "typescript/no-dynamic-delete": "error", + "typescript/no-empty-object-type": "off", // Merge-extensible maps intentionally use empty object types. + "typescript/no-explicit-any": "error", // Every intentional any needs a narrow suppression with rationale. + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-extraneous-class": "error", + // Lost promises in the agent loop are the repository's highest-value linted bug class. + "typescript/no-floating-promises": "error", + "typescript/no-for-in-array": "error", + "typescript/no-implied-eval": "error", + "typescript/no-invalid-void-type": "off", // Event signatures intentionally use void in source. + "typescript/no-meaningless-void-operator": "error", + "typescript/no-misused-new": "error", + "typescript/no-misused-promises": "error", + "typescript/no-misused-spread": "error", + "typescript/no-mixed-enums": "error", + "typescript/no-namespace": "off", // Cordis Config namespaces are the repository idiom. + "typescript/no-non-null-asserted-nullish-coalescing": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-redundant-type-constituents": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-boolean-literal-compare": "error", + "typescript/no-unnecessary-template-expression": "error", + "typescript/no-unnecessary-type-arguments": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unnecessary-type-conversion": "error", + "typescript/no-unnecessary-type-parameters": "error", + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-enum-comparison": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/no-unsafe-unary-minus": "error", + "typescript/no-useless-default-assignment": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-literal-enum-member": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/prefer-reduce-type-parameter": "error", + "typescript/prefer-return-this-type": "error", + "typescript/related-getter-setter-pairs": "error", + "typescript/restrict-plus-operands": [ + "error", + { + "allowAny": false, + "allowBoolean": false, + "allowNullish": false, + "allowNumberAndString": false, + "allowRegExp": false + } + ], + "typescript/return-await": [ + "error", + "error-handling-correctness-only" + ], + "typescript/triple-slash-reference": "error", + "typescript/unbound-method": "error", + "typescript/unified-signatures": "error", + "typescript/use-unknown-in-catch-callback-variable": "error", + "no-void": "off" // void foo() marks deliberate fire-and-forget arrow listeners. + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "packages/*/*/src/**/*.{ts,tsx}", + "apps/*/src/**/*.{ts,tsx}", + "examples/**/*.{ts,tsx}", + "scripts/**/*.{ts,tsx}", + "website/**/*.{ts,tsx}" + ], + "rules": { + "typescript/no-non-null-assertion": "error", + "typescript/no-unnecessary-condition": [ + "error", + { + "allowConstantLoopConditions": true + } + ], + "typescript/only-throw-error": "error", + "typescript/require-await": "error", + "typescript/restrict-template-expressions": [ + "error", + { + "allowNumber": true, + "allowBoolean": true + } + ], + "typescript/switch-exhaustiveness-check": [ + "error", + { + "considerDefaultExhaustiveForUnions": true + } + ] + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "examples/**/*.ts" + ], + "rules": { + "typescript/require-await": "off" // Demo callbacks conform to async interfaces without awaiting. + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "packages/*/*/tests/**/*.{ts,tsx}", + "apps/*/tests/**/*.{ts,tsx}", + "examples/*/tests/**/*.{ts,tsx}", + "scripts/**/*.spec.{ts,tsx}" + ], + "rules": { + "typescript/no-invalid-void-type": "error", + "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence. + "typescript/no-unnecessary-condition": "off", + "typescript/only-throw-error": "off", // Tests deliberately exercise non-Error throws. + "typescript/require-await": "off", // Mock execute() implementations must retain async signatures. + "typescript/restrict-template-expressions": "off" + }, + "plugins": [ + "typescript" + ] + }, + { + "files": [ + "packages/**/*.{ts,tsx}", + "apps/**/*.{ts,tsx}", + "examples/**/*.{ts,tsx}", + "scripts/**/*.{ts,tsx}", + "website/**/*.{ts,tsx}" + ], + "rules": { + "sonarjs/duplicates-in-character-class": "error", + "sonarjs/no-all-duplicated-branches": "error", + "sonarjs/no-duplicate-in-composite": "error", + "sonarjs/no-duplicate-test-title": "error", + "sonarjs/no-identical-conditions": "error", + "sonarjs/no-identical-expressions": "error", + "sonarjs/no-identical-functions": "error", + "sonarjs/no-duplicated-branches": "error" + }, + "jsPlugins": [ + "eslint-plugin-sonarjs" + ] + }, + { + "files": [ + "packages/**/*.{ts,tsx}", + "apps/**/*.{ts,tsx}", + "examples/**/*.{ts,tsx}", + "scripts/**/*.{ts,tsx}", + "website/**/*.{ts,tsx}" + ], + "rules": { + "@stylistic/indent": [ + "error", + 2 + ], + "@stylistic/semi": [ + "error", + "never" + ], + "@stylistic/quotes": [ + "error", + "single", + { + "avoidEscape": true + } + ], + "@stylistic/comma-dangle": [ + "error", + "always-multiline" + ], + "@stylistic/eol-last": [ + "error", + "always" + ], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/object-curly-spacing": [ + "error", + "always" + ], + "@stylistic/arrow-parens": [ + "error", + "as-needed", + { + "requireForBlockBody": true + } + ], + "@stylistic/member-delimiter-style": [ + "error", + { + "multiline": { + "delimiter": "none" + }, + "singleline": { + "delimiter": "semi", + "requireLast": false + } + } + ], + // Validation-only: line length has no safe formatter fix. + "@stylistic/max-len": [ + "error", + { + "code": 140, + "ignoreUrls": true, + "ignoreStrings": true, + "ignoreTemplateLiterals": true + } + ] + }, + "jsPlugins": [ + "@stylistic/eslint-plugin" + ] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index b0f5e87e02..d496ec471a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools subprocess/ subprocess seam + local process-tree impl diff --git a/apps/web/tests/session-actions.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts similarity index 50% rename from apps/web/tests/session-actions.snapshot.ts rename to apps/web/tests/built-boot.snapshot.ts index 684afe5532..69d5d5cfae 100644 --- a/apps/web/tests/session-actions.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -1,6 +1,15 @@ // @vitest-environment jsdom -// Session row actions in the assembled fixture app: Rename opens the -// browser-owned dialog and settles the title from the unary response. +// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the +// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's +// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph +// assembles — staged activation across the immediately tier and the inject +// layers, per-plugin CSS injection, and a rendered journey reaching chat +// content from the keyless FixtureApiClient transport. +// +// Behavior assertions do NOT belong here: component and wiring behavior is +// pinned by the per-package suites (SlotTestRuntime benches over src), which +// this smoke's plugin set cannot influence — bundling, module-table +// resolution, and boot layering are the only failure modes left to it. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -16,9 +25,18 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, - { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] }, - { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] const bundles = new Map(PLUGINS.map(plugin => [ @@ -42,16 +60,11 @@ let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() - history.replaceState(null, '', '/?fixture') document.title = 'DeepSeek Harness' - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => { callback(0) }, 0) as unknown as number) vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } }) afterEach(() => { @@ -60,7 +73,6 @@ afterEach(() => { cleanup() delete win.__DSH_BOOT__ delete win.__ModuleLoader__ - delete (globalThis as Record).__fxTiming document.body.innerHTML = '' document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' @@ -68,9 +80,12 @@ afterEach(() => { vi.unstubAllGlobals() }) -async function bootApp(): Promise { - const root = document.querySelector('#root') - if (root === null) throw new Error('snapshot root missing') +it('boots the built plugin graph and renders a fixture session end to end', async () => { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } act(() => { const entry = new AppWebEntry(root, { fetchBundle: (url) => { @@ -82,49 +97,21 @@ async function bootApp(): Promise { void entry.run() unmount = () => { entry.dispose() } }) - await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) -} -/** The session row element carrying the given visible label. */ -function rowOf(label: string): HTMLElement { - const tree = screen.getByRole('tree', { name: 'Sessions' }) - const row = within(tree).getByText(label).closest('[role="treeitem"]') - if (row === null) throw new Error(`session row "${label}" missing`) - return row -} + // The sidebar renders from the boot graph: every inject layer activated. + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + await within(tree).findByText('4 sessions') -/** Open the row's ... menu and click one action. The anchor button is - * CSS-hover-revealed (real stylesheets are injected in this assembled run, - * so role queries filter it as hidden); target it directly. */ -function pickRowAction(label: string, action: string): void { - const anchor = rowOf(label).querySelector(`button[aria-label="Session actions for ${label}"]`) - if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`) - fireEvent.click(anchor) - fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true })) -} + // Opening a session reaches chat content through the fixture transport. + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + await waitFor(() => { + expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() + }, { timeout: 10_000 }) -it('renames a session through the row-menu dialog; the row settles from the unary response', async () => { - await bootApp() - const sourceLabel = 'Fixture 历史会话' - await screen.findByText(sourceLabel) - - pickRowAction(sourceLabel, 'Rename') - const input = await screen.findByLabelText('Session name') - expect((input as HTMLInputElement).value).toBe(sourceLabel) - fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } }) - fireEvent.click(screen.getByRole('button', { name: 'Rename' })) - - // Host-side normalization collapses whitespace; the dialog closes on - // acceptance and the row re-labels without any push-frame wait. - const renamed = '分叉 实验记录' - await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() }) - await screen.findByText(renamed) - const tree = screen.getByRole('tree', { name: 'Sessions' }) - expect(within(tree).queryByText(sourceLabel)).toBeNull() - - const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({ - label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '', - })) - await expect(`${JSON.stringify(rows, null, 2)}\n`) - .toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json') + // Every bundle injected its plugin-owned style tag (the loader's CSS path). + const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] + .map(style => style.getAttribute('data-plugin')) + for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) { + expect(styleOwners).toContain(plugin) + } }) diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts deleted file mode 100644 index b7ab11f2c0..0000000000 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ /dev/null @@ -1,239 +0,0 @@ -// @vitest-environment jsdom -// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). -// Opens the fixture history session and pins the run_code turn's rendering: -// the code-variant parent row titled by the model-authored description, its -// three always-visible nested sub-rows (bash through the sample registration, -// read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, inert bash / file-link sub-row gestures, -// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call -// cells and timing overview. -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' -import { AppWebEntry } from '@deepseek-ai/dsh-client-web' - -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { - id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', - url: '/plugins/ui-workspace.js', - rev: 'fx', - inject: [ - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-sidebar', - ], - }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] - -const bundles = new Map(PLUGINS.map(plugin => [ - plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), -])) - -interface FixtureWindow extends Window { - __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } - __ModuleLoader__?: unknown -} - -class ResizeObserverStub { - observe(): void {} - disconnect(): void {} - unobserve(): void {} -} - -const win = window as FixtureWindow -let unmount: (() => void) | undefined - -beforeEach(() => { - localStorage.clear() - document.title = 'DeepSeek Harness' - vi.stubGlobal('ResizeObserver', ResizeObserverStub) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - setTimeout(() => { callback(0) }, 0) as unknown as number) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) -}) - -afterEach(() => { - act(() => { unmount?.() }) - unmount = undefined - cleanup() - delete win.__DSH_BOOT__ - delete win.__ModuleLoader__ - delete (globalThis as Record).__fxTiming - document.body.innerHTML = '' - document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) - document.title = '' - history.replaceState(null, '', '/') - vi.unstubAllGlobals() -}) - -/** Boot the complete built client graph against the populated fixture branch. */ -function boot(): void { - history.replaceState(null, '', '/?fixture') - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } - act(() => { - const entry = new AppWebEntry(root, { - fetchBundle: (url) => { - const code = bundles.get(url) - return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) - }, - executeBundle: (code) => { (0, eval)(code) }, - }) - void entry.run() - unmount = () => { entry.dispose() } - }) -} - -/** Collapse decorative whitespace while preserving the text a user sees. */ -function visibleText(element: Element): string { - return (element.textContent ?? '').replace(/\s+/g, ' ').trim() -} - -/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */ -async function openFixtureSession(): Promise { - const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const group = within(tree).getByText('4 sessions').closest('[role="treeitem"]') - if (group === null) throw new Error('fixture Workspace group missing') - if (group.getAttribute('aria-expanded') === 'false') { - fireEvent.click(within(group).getByText('fixture')) - await waitFor(() => { - expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true') - }) - } - const session = await within(tree).findByText('Fixture 历史会话') - fireEvent.click(session) - await waitFor(() => { - expect(document.querySelector('[data-variant="code"]')).not.toBeNull() - }, { timeout: 10_000 }) -} - -it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => { - boot() - await openFixtureSession() - - const codeRoot = document.querySelector('[data-variant="code"]') - if (codeRoot === null) throw new Error('code-variant row missing') - const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]') - if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row') - - expect({ - parentRow: visibleText(codeRoot), - // The three sub-rows in dispatch order: bash rides the sample plugin's - // keyed registration (the same one a native top-level bash row uses), - // both reads ride GenericToolCard. - bashSample: nest.querySelector('[data-sample="bash-global"]') !== null, - subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText), - errorSubRow: nest.querySelector('[data-state="error"]') !== null, - }).toMatchInlineSnapshot(` - { - "bashSample": true, - "errorSubRow": true, - "parentRow": "CodeRead the notes files and summarize", - "subRows": [ - "BashList notes", - "Readnotes/demo.txt", - "Readnotes/missing.txt", - ], - } - `) -}) - -it('expands the code row into the program body; sub-row clicks do not open details', async () => { - boot() - await openFixtureSession() - - // Expand: the leading control reveals the program (shiki-tokenized: the - // text splits into styled spans inside one
 tree).
-  const codeRoot = document.querySelector('[data-variant="code"]')
-  if (codeRoot === null) throw new Error('code-variant row missing')
-  const toggle = codeRoot.querySelector('button[aria-expanded]')
-  if (toggle === null) throw new Error('code row expand control missing')
-  fireEvent.click(toggle)
-  await waitFor(() => {
-    // Scope to THIS row: the markdown fixture turn also renders shiki pres.
-    const pre = codeRoot.querySelector('pre.shiki')
-    if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
-      throw new Error('highlighted program body missing under the code row')
-    }
-  })
-
-  // Tool rows no longer drive the details panel: bash is inert, file paths
-  // are host-open links (fixture openPath is a no-op success).
-  const nest = document.querySelector('[data-subcalls]')
-  if (nest === null) throw new Error('sub-call nest missing')
-  const bashRow = nest.querySelector('[data-sample="bash-global"]')
-  if (bashRow === null) throw new Error('bash sample sub-row missing')
-  const fileLink = nest.querySelector('button')
-  if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
-  const frame = document.querySelector('[data-details-collapsed]')
-  if (frame === null) throw new Error('app frame missing')
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  fireEvent.click(bashRow)
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  fireEvent.click(fileLink)
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  expect({
-    fileLink: visibleText(fileLink),
-    detailsCollapsed: frame.getAttribute('data-details-collapsed'),
-  }).toMatchInlineSnapshot(`
-    {
-      "detailsCollapsed": "true",
-      "fileLink": "notes/demo.txt",
-    }
-  `)
-})
-
-it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
-  boot()
-  await openFixtureSession()
-
-  // Switch to the trajectory tab (same slot ring the chat view registers in).
-  fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
-  await waitFor(() => {
-    expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-  const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
-  expect({
-    // Three Subtool cells nested under the run_code Tool cell in dispatch
-    // order, each paired with its result preview.
-    subCells: subCells.map(cell => visibleText(cell)),
-  }).toMatchInlineSnapshot(`
-    {
-      "subCells": [
-        "SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
-        "SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
-        "SUBTOOLread{"path":"notes/missing.txt"}→error",
-      ],
-    }
-  `)
-
-  const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
-  expect({
-    count: timelineSubCalls.length,
-    measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
-  }).toMatchInlineSnapshot(`
-    {
-      "count": 3,
-      "measured": [
-        true,
-        true,
-        true,
-      ],
-    }
-  `)
-})
diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts
deleted file mode 100644
index 4667b85079..0000000000
--- a/apps/web/tests/session-title.snapshot.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-// @vitest-environment jsdom
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
-  { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] },
-  { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureTiming {
-  appendTitle(id: string, title: string): void
-}
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  history.replaceState(null, '', '/?fixture')
-  document.title = 'DeepSeek Harness'
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Read only the stable, user-facing title surfaces from the assembled app. */
-function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const sidebar = within(tree).getByText(label).textContent ?? ''
-  const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
-    .getByRole('button', { name: label }).textContent ?? ''
-  return { sidebar, breadcrumb, documentTitle: document.title }
-}
-
-it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
-  const root = document.querySelector('#root')
-  if (root === null) throw new Error('snapshot root missing')
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  // The fixture Intent selects the workspace, so the current-group effect
-  // already expanded it; clicking the header would now collapse (the twist
-  // stays live since intent stopped forcing expansion).
-  await within(tree).findByText('4 sessions')
-
-  const initialLabel = 'Fixture 历史会话'
-  const initialRowLabel = await screen.findByText(initialLabel)
-  const initialRow = initialRowLabel.closest('[role="treeitem"]')
-  if (initialRow === null) throw new Error('fixture session row missing')
-  fireEvent.click(initialRow)
-  await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
-  const initial = titleSurfaces(initialLabel)
-
-  const revisedLabel = 'Fixture 修订标题'
-  const timing = (globalThis as Record).__fxTiming as FixtureTiming
-  act(() => { timing.appendTitle('fx-alpha', revisedLabel) })
-  await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
-  const revised = titleSurfaces(revisedLabel)
-
-  // fx-alpha carries the fixture's resident answerable approval, so the
-  // approval panel has taken over the composer (the real takeover behavior);
-  // answer it to restore the composer chrome before asserting the model seat.
-  fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
-  const modelTrigger = await screen.findByRole('button', {
-    name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
-  })
-  fireEvent.click(modelTrigger)
-  fireEvent.click(screen.getByRole('menuitem', { name: /Model/ }))
-  fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
-  await waitFor(() => {
-    expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium')
-  })
-  fireEvent.click(modelTrigger)
-  fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
-  fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' }))
-  await waitFor(() => {
-    expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max')
-  })
-
-  // fx-alpha starts in the running state. Selecting above is intentionally
-  // allowed for the next turn; stop the fixture's resident run before sending
-  // the route-report prompt.
-  fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
-  const composer = await screen.findByPlaceholderText('给智能体发消息')
-  fireEvent.change(composer, { target: { value: 'report model' } })
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
-
-  await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
-    .toMatchFileSnapshot('./snapshots/session-title.json')
-})
diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts
new file mode 100644
index 0000000000..925ff924ca
--- /dev/null
+++ b/apps/web/tests/skill-invocation-policy.e2e.ts
@@ -0,0 +1,115 @@
+// Web e2e scenario: the real host filters skill.list to the model-and-user
+// intersection before the browser slash source renders candidates. A real
+// chromium connects a fresh workspace seeded with all four policy quadrants;
+// no model call is issued, so a stray stream fails loud on the open LLM seam.
+import { mkdir, writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
+const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const MODE = webSnapshotMode()
+
+interface SeedSkill {
+  name: string
+  description: string
+  frontmatter: string
+}
+
+const SKILLS: readonly SeedSkill[] = [
+  {
+    name: 'policy-shared',
+    description: 'Available to both model and user invocation',
+    frontmatter: '',
+  },
+  {
+    name: 'policy-model-only',
+    description: 'Available only to model invocation',
+    frontmatter: 'user-invocable: false\n',
+  },
+  {
+    name: 'policy-user-only',
+    description: 'Available only to user invocation',
+    frontmatter: 'disable-model-invocation: true\n',
+  },
+  {
+    name: 'policy-trusted-only',
+    description: 'Available only to trusted internal callers',
+    frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
+  },
+]
+
+async function seedSkills(workspaceCwd: string): Promise {
+  for (const skill of SKILLS) {
+    const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
+    await mkdir(directory, { recursive: true })
+    const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
+    await writeFile(join(directory, 'SKILL.md'), [
+      '---',
+      `name: ${skill.name}`,
+      `description: ${skill.description}`,
+      ...policyLines,
+      '---',
+      '',
+      `# ${skill.name}`,
+      '',
+    ].join('\n'))
+  }
+}
+
+describe('web e2e: skill invocation policy through the real host', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSkills(scaffold.workspaceCwd)
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('renders only the model-and-user intersection in slash candidates', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
+    const input = page.locator('textarea').first()
+    await input.fill('/policy')
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    await expect.poll(
+      () => menu.getByRole('option', { name: /policy-shared/ }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
+
+    expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
+    expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
+    expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
+
+    const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
+  })
+})
diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts
deleted file mode 100644
index c650d8e6de..0000000000
--- a/apps/web/tests/slash-flow.snapshot.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-// @vitest-environment jsdom
-// Assembled keyless snapshot of the slash/input/session convergence under the
-// agent-parity model: the New Session view state locks the composer until a
-// Workspace is picked (connectWorkspace materializes the full Session+Agent),
-// the '/' menu serves the session's wire command catalog (sessions are always
-// agent-backed — no draft/materialized split), a leadingInput command claims,
-// submits over the wire, and notices its result, and the SAME composer
-// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
-// flips blank and surfaces the session in lists. This is the user-visible
-// acceptance anchor — package mocks do not substitute for the assembled
-// application transcript.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
-  { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
-  { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-// jsdom has no scrollIntoView; the slash menu follows its highlighted option.
-const scrollIntoView = vi.fn()
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  Element.prototype.scrollIntoView = scrollIntoView
-  scrollIntoView.mockClear()
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against one keyless fixture branch. */
-function boot(search: string): void {
-  history.replaceState(null, '', `/${search}`)
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Type into the machine-driven composer and let the change echo back. */
-async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise {
-  fireEvent.change(composer, { target: { value } })
-  await waitFor(() => { expect(composer.value).toBe(value) })
-}
-
-it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
-  boot('?fixture=empty')
-
-  // View state: no session entity — the composer renders locked; only the
-  // workspace picker is live.
-  const locked = await screen.findByPlaceholderText(
-    'Choose a workspace to start', {}, { timeout: 10_000 },
-  )
-  expect(locked.disabled).toBe(true)
-
-  // Pick (create) a Workspace: connectWorkspace materializes the full
-  // Session+Agent and the provider swaps in the live blank-session hero.
-  fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
-    .find(el => el.getAttribute('aria-haspopup') === 'menu')!)
-  fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
-  const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
-  fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
-    target: { value: 'nova' },
-  })
-  fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
-
-  const composer = await screen.findByPlaceholderText(
-    'Describe what you want to build', {}, { timeout: 10_000 },
-  )
-  expect(composer.disabled).toBe(false)
-
-  // '/' opens the menu with the session's wire command catalog (the session
-  // is agent-backed from birth — the catalog is the single-address list).
-  await typeComposer(composer, '/')
-  const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
-  await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
-  const menuText = visibleText(menu)
-
-  // Pick /echo (leadingInput): the claim token lands in the same textarea.
-  fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
-  await waitFor(() => { expect(composer.value).toBe('/echo ') })
-
-  // Type args and submit: the claim executes over the wire and notices its
-  // result; the token is consumed and the draft returns to plain text.
-  await typeComposer(composer, '/echo hello parser')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await screen.findByText('hello parser', {}, { timeout: 10_000 })
-  await waitFor(() => { expect(composer.value).toBe('') })
-
-  // Slash execution does not flip blank: the selected row remains New Session.
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  expect(within(tree).getByText('1 session')).toBeDefined()
-  expect(within(tree).getByText('New Session')).toBeDefined()
-
-  // First plain send through the SAME textarea: acceptance logs the user
-  // message and converts the existing sidebar row out of blank.
-  const before = composer
-  await typeComposer(composer, 'build me a parser')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await waitFor(() => {
-    expect(screen.queryByText("Let's start building")).toBeNull()
-  }, { timeout: 10_000 })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-  const after = document.querySelector('textarea')
-
-  expect({
-    menuHadEcho: menuText.includes('echo'),
-    menuHadCompact: menuText.includes('compact'),
-    composerSurvivedConversion: after === before,
-    sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
-  }).toMatchInlineSnapshot(`
-    {
-      "composerSurvivedConversion": true,
-      "menuHadCompact": true,
-      "menuHadEcho": true,
-      "sessionListed": "nova1 session",
-    }
-  `)
-})
diff --git a/apps/web/tests/snapshots/session-actions/rename-rows.json b/apps/web/tests/snapshots/session-actions/rename-rows.json
deleted file mode 100644
index db30b0d121..0000000000
--- a/apps/web/tests/snapshots/session-actions/rename-rows.json
+++ /dev/null
@@ -1,14 +0,0 @@
-[
-  {
-    "label": "fixture4 sessions"
-  },
-  {
-    "label": "New Sessionnow"
-  },
-  {
-    "label": "分叉 实验记录now"
-  },
-  {
-    "label": "fixture2min"
-  }
-]
diff --git a/apps/web/tests/snapshots/session-title.json b/apps/web/tests/snapshots/session-title.json
deleted file mode 100644
index 2063036803..0000000000
--- a/apps/web/tests/snapshots/session-title.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "initial": {
-    "sidebar": "Fixture 历史会话",
-    "breadcrumb": "Fixture 历史会话",
-    "documentTitle": "Fixture 历史会话 — DeepSeek Harness"
-  },
-  "revised": {
-    "sidebar": "Fixture 修订标题",
-    "breadcrumb": "Fixture 修订标题",
-    "documentTitle": "Fixture 修订标题 — DeepSeek Harness"
-  }
-}
diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md
new file mode 100644
index 0000000000..ba62c84f91
--- /dev/null
+++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md
@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: 技能
+  - option "policy-shared Available to both model and user invocation" [selected]
diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts
deleted file mode 100644
index 088a6e8326..0000000000
--- a/apps/web/tests/terminal-card.snapshot.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-// @vitest-environment jsdom
-// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
-// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
-// Opens the fixture history session and pins the `card: 'terminal'` render
-// intent at both of its conversation render sites, for both chat-row shapes:
-// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
-// turn 65's `bash` on the keyed BashRow registration (resident body). Turn 65
-// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
-// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
-// pill; turn 60 carries the multi-line command's per-line prompt rows.
-//
-// The details panel's Output section is NOT covered here: tool rows stopped
-// being details-panel click targets, and nothing else in the assembled
-// application opens that panel, so the surface cannot be driven end to end.
-// Its terminal rendering stays pinned in ui-conversation's
-// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
-// directly.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against the populated fixture branch. */
-function boot(): void {
-  history.replaceState(null, '', '/?fixture')
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/**
- * Read one terminal card's user-visible state. Output lines keep their interior
- * whitespace: holding column alignment is what this card exists for, so
- * collapsing runs of spaces would hide the behavior under test.
- */
-function readCard(card: Element) {
-  const status = card.querySelector('[class*="_status_"]')
-  const expander = card.querySelector('button[aria-expanded]')
-  return {
-    // One entry per command line: a multi-line command is one row per line.
-    prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
-      `${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
-    // Dots per prompt row: exactly one, on the first row — the exit status the
-    // view carries is the whole call's, so a dot per line would assert a
-    // per-line outcome bash does not report.
-    dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
-      row.querySelectorAll('[data-state]').length),
-    status: status === null ? null : status.textContent,
-    copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
-    lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
-    expander: expander === null ? null : {
-      label: expander.getAttribute('aria-label'),
-      text: expander.textContent,
-      expanded: expander.getAttribute('aria-expanded'),
-    },
-    // The run-state dot at the head of the prompt line, by its StateDot state.
-    runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
-    runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
-    // Every color the ANSI parser emits resolves through a --dsw-* token, so
-    // the card follows the theme instead of painting literal terminal rgb.
-    // Scoped to the output lines: the run-state dot is an inline-styled span
-    // too, and its geometry is not an ANSI-resolved color.
-    colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')]
-      .map(span => span.getAttribute('style')))],
-  }
-}
-
-/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
-async function openFixtureSession(): Promise {
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  // Anchor on the expandable Workspace group row: the title and the blank
-  // session row can both read "fixture".
-  const group = (await within(tree).findAllByText('fixture'))
-    .map(el => el.closest('[role="treeitem"]'))
-    .find(el => el?.getAttribute('aria-expanded') !== null)
-  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
-  if (group.getAttribute('aria-expanded') === 'false') {
-    fireEvent.click(within(group).getByText('fixture'))
-    await waitFor(() => {
-      expect(group.getAttribute('aria-expanded')).toBe('true')
-    })
-  }
-  fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
-  await waitFor(() => {
-    expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-}
-
-/** The keyed BashRow of fixture turn 65 (the one carrying the ANSI sample). */
-function keyedBashRow(): Element {
-  // Anchored on the BashRow wrapper (summary row + resident card), not on the
-  // summary row itself: the summary now shows the presenter's description (the
-  // contract's above-card text), so the command lives only in the card below it.
-  const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
-    .map(node => node.parentElement)
-    .find((node): node is HTMLElement => node !== null && visibleText(node).includes('pnpm run check'))
-  if (row === undefined) throw new Error('keyed bash row for turn 65 missing')
-  return row
-}
-
-/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
-function fallbackBashRow(): Element {
-  const row = document.querySelector('[data-tool="fx-bash"]')
-  if (row === null) throw new Error('fx-bash fallback row missing')
-  return row
-}
-
-it('renders the keyed bash row with a resident terminal card', async () => {
-  boot()
-  await openFixtureSession()
-
-  const row = keyedBashRow()
-  const card = row.parentElement?.querySelector('[data-terminal]')
-  if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
-  // The prompt shortens the nested cwd to its last segment, the exit pill comes
-  // from the sample's authored exit status (its body deliberately carries no
-  // `[exit code: N]` marker, since the real presenter consumes that one), ANSI
-  // runs land on theme tokens, and the chat cap (8) collapses the middle into a
-  // head/tail split with an expander between them.
-  expect(readCard(card)).toMatchInlineSnapshot(`
-    {
-      "colors": [
-        "font-weight: 700;",
-        "color: var(--dsw-alias-state-success-primary);",
-        "color: var(--dsw-alias-state-error-primary);",
-      ],
-      "copy": "复制",
-      "dotsPerPromptRow": [
-        1,
-      ],
-      "expander": {
-        "expanded": "false",
-        "label": "展开其余 13 行输出",
-        "text": "… 其余 13 行",
-      },
-      "lines": [
-        "Running 4 checks",
-        "✓ typecheck                                          1.82s",
-        "✓ lint                                               0.94s",
-        "✓ duplication                                        2.10s",
-        "StateDot.tsx                100%     100%        100%         -",
-        "markdown/Markdown.tsx       100%     100%        100%         -",
-        "",
-        "1 of 4 checks failed",
-      ],
-      "prompt": [
-        "nested pnpm run check",
-      ],
-      "runState": "error",
-      "runStateLabel": "失败",
-      "status": "退出码 1",
-    }
-  `)
-})
-
-it('the fallback row reaches the same card through its expand control', async () => {
-  boot()
-  await openFixtureSession()
-
-  const row = fallbackBashRow()
-  expect(row.querySelector('[data-terminal]')).toBeNull()
-  const toggle = row.querySelector('button[aria-expanded]')
-  if (toggle === null) throw new Error('fallback row expand control missing')
-  fireEvent.click(toggle)
-  const card = await waitFor(() => {
-    const found = row.querySelector('[data-terminal]')
-    if (found === null) throw new Error('terminal card missing after expanding the fallback row')
-    return found
-  })
-  // Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
-  expect(readCard(card)).toMatchInlineSnapshot(`
-    {
-      "colors": [],
-      "copy": "复制",
-      "dotsPerPromptRow": [
-        1,
-        0,
-      ],
-      "expander": null,
-      "lines": [
-        "total 2",
-        "drwxr-xr-x fixture",
-        "-rw-r--r-- demo.txt",
-      ],
-      "prompt": [
-        "fixture ls -la",
-        "$ echo done",
-      ],
-      "runState": "done",
-      "runStateLabel": "已完成",
-      "status": null,
-    }
-  `)
-})
-
-it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
-  boot()
-  await openFixtureSession()
-
-  const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
-  if (card === null || card === undefined) throw new Error('resident terminal card missing')
-  const expander = card.querySelector('button[aria-expanded]')
-  if (expander === null) throw new Error('height-cap expander missing')
-  const capped = card.querySelectorAll('[class*="_line_"]').length
-
-  fireEvent.click(expander)
-  await waitFor(() => {
-    expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
-  })
-  expect({
-    cappedLines: capped,
-    expandedLines: card.querySelectorAll('[class*="_line_"]').length,
-    expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
-    // The card sits outside the summary row's click target, so toggling it
-    // left the details panel shut.
-    detailsOpen: screen.queryByText('Input') !== null,
-  }).toMatchInlineSnapshot(`
-    {
-      "cappedLines": 8,
-      "detailsOpen": false,
-      "expandedLines": 21,
-      "expanderLabel": "收起输出",
-    }
-  `)
-})
diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts
deleted file mode 100644
index da621b6e35..0000000000
--- a/apps/web/tests/todo-display.snapshot.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-// @vitest-environment jsdom
-// Todo display snapshot over the BUILT client graph (the code-mode-fixture
-// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
-// Opens the fixture history session and pins the todo_write turn's two
-// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
-// derived from the call args) and the TodoPanel plan strip riding the
-// 'conversation.input.dock' slot (fed by the host `todos` projection via
-// useProjection, seeded by the tail history page), including the collapse
-// interaction and the next-turn clearance of the standing plan.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against the populated fixture branch. */
-function boot(): void {
-  history.replaceState(null, '', '/?fixture')
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
-async function openFixtureSession(): Promise {
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  // Anchor on the expandable Workspace group row: the title and the blank
-  // session row can both read "fixture", and the session-count meta shifts
-  // when a blank session joins the group.
-  const group = (await within(tree).findAllByText('fixture'))
-    .map(el => el.closest('[role="treeitem"]'))
-    .find(el => el?.getAttribute('aria-expanded') !== null)
-  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
-  if (group.getAttribute('aria-expanded') === 'false') {
-    fireEvent.click(within(group).getByText('fixture'))
-    await waitFor(() => {
-      expect(group.getAttribute('aria-expanded')).toBe('true')
-    })
-  }
-  const session = await within(tree).findByText('Fixture 历史会话')
-  fireEvent.click(session)
-  await waitFor(() => {
-    expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-}
-
-it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
-  boot()
-  await openFixtureSession()
-
-  const row = document.querySelector('[data-sample="todo-row"]')
-  if (row === null) throw new Error('todo row missing')
-  const panel = document.querySelector('[data-testid="todo-panel"]')
-  if (panel === null) throw new Error('todo panel missing from the input dock')
-
-  // Header spans are adjacent inline nodes; textContent joins "To-dos" +
-  // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
-  expect({
-    row: visibleText(row),
-    rowState: row.getAttribute('data-state'),
-    panelHeader: visibleText(panel.querySelector('button') ?? panel),
-    panelItems: [...panel.querySelectorAll('li')].map(item => ({
-      status: item.getAttribute('data-status'),
-      text: visibleText(item),
-    })),
-  }).toMatchInlineSnapshot(`
-    {
-      "panelHeader": "To-dos1/3 tasks · 1 in progress",
-      "panelItems": [],
-      "row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
-      "rowState": "ok",
-    }
-  `)
-})
-
-it('expands the default-collapsed plan strip and restores its folded state', async () => {
-  boot()
-  await openFixtureSession()
-
-  const panel = document.querySelector('[data-testid="todo-panel"]')
-  if (panel === null) throw new Error('todo panel missing from the input dock')
-  const header = panel.querySelector('button')
-  if (header === null) throw new Error('todo panel header missing')
-
-  expect({
-    collapsedHeader: visibleText(header),
-    expanded: header.getAttribute('aria-expanded'),
-    listGone: panel.querySelector('ul') === null,
-  }).toMatchInlineSnapshot(`
-    {
-      "collapsedHeader": "To-dos1/3 tasks · 1 in progress",
-      "expanded": "false",
-      "listGone": true,
-    }
-  `)
-
-  fireEvent.click(header)
-  expect(panel.querySelectorAll('li')).toHaveLength(3)
-  expect(header.getAttribute('aria-expanded')).toBe('true')
-
-  fireEvent.click(header)
-  expect(panel.querySelector('ul')).toBeNull()
-  expect(header.getAttribute('aria-expanded')).toBe('false')
-})
-
-it('hides the plan strip when the next turn starts', async () => {
-  boot()
-  await openFixtureSession()
-  expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
-
-  const composer = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 })
-  fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
-  fireEvent.keyDown(composer, { key: 'Enter' })
-
-  await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
-  await waitFor(() => {
-    expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
-  }, { timeout: 10_000 })
-
-  expect({
-    promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
-    panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
-    // Historical todo_write row stays in the flow; only the dock strip clears.
-    rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
-  }).toMatchInlineSnapshot(`
-    {
-      "panelGone": true,
-      "promptVisible": "下一轮清空计划",
-      "rowStillPresent": true,
-    }
-  `)
-})
diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts
deleted file mode 100644
index 86a20e73fe..0000000000
--- a/apps/web/tests/workspace-flow.snapshot.ts
+++ /dev/null
@@ -1,397 +0,0 @@
-// @vitest-environment jsdom
-// Assembled keyless snapshots of the New Session flow under the agent-parity
-// model: startup auto-connects the recent Workspace's blank session when one
-// exists; without any Workspace the composer is locked in the pure view
-// state until one is chosen. Picking one materializes the full Session+Agent
-// (reuse-or-create of the workspace's blank session), the first ACCEPTED
-// prompt flips blank and surfaces the session in lists, and failures leave
-// no client-side transaction state: a failed attach keeps the view state
-// locked, a rejected prompt keeps the session blank with the draft restored.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
-  { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-  // Dual-face host package: its browser half fills the directory-flow holes
-  // (the same composition row apps/cli mounts for the node-side backend).
-  {
-    id: '@deepseek-ai/dsh-host-directory-picker-browse',
-    dir: '../host/directory-picker-browse',
-    url: '/plugins/directory-picker-browse.js',
-    rev: 'fx',
-    inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against one keyless fixture branch. */
-function boot(search: string): void {
-  history.replaceState(null, '', `/${search}`)
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
-function workspaceChip(): HTMLElement {
-  const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
-    .find(element => element.getAttribute('aria-haspopup') === 'menu')
-  if (chip === undefined) throw new Error('Workspace chip missing')
-  return chip
-}
-
-/** The locked view-state composer (no session yet). */
-async function findLockedComposer(): Promise {
-  return await screen.findByPlaceholderText(
-    'Choose a workspace to start', {}, { timeout: 10_000 },
-  )
-}
-
-/** The live blank-session hero composer (session materialized). */
-async function findHeroComposer(): Promise {
-  return await screen.findByPlaceholderText(
-    'Describe what you want to build', {}, { timeout: 10_000 },
-  )
-}
-
-/** Edit the machine-owned controlled input and assert the same-tick echo. */
-function setComposerText(composer: HTMLElement, value: string): void {
-  fireEvent.change(composer, { target: { value } })
-  expect((composer as HTMLTextAreaElement).value).toBe(value)
-}
-
-/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
-async function createWorkspaceViaPicker(name: string): Promise {
-  fireEvent.click(workspaceChip())
-  fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
-  const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
-  fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
-    target: { value: name },
-  })
-  fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
-}
-
-/** Pick an existing Workspace row from the chip menu. */
-async function pickWorkspace(title: string): Promise {
-  fireEvent.click(workspaceChip())
-  fireEvent.click(await screen.findByRole('menuitem', { name: title }))
-}
-
-it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
-  boot('?fixture=empty')
-
-  const composer = await findLockedComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-
-  expect({
-    headline: visibleText(screen.getByText("Let's start building")),
-    chip: visibleText(workspaceChip()),
-    composerDisabled: composer.disabled,
-    sendDisabled: screen.getByRole('button', { name: 'Send message' }).disabled,
-    sidebar: visibleText(tree),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "Choose workspace",
-      "composerDisabled": true,
-      "headline": "Let's start building",
-      "sendDisabled": true,
-      "sidebar": "No sessions yet",
-    }
-  `)
-})
-
-it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
-  boot('?fixture=empty')
-
-  await findLockedComposer()
-  fireEvent.click(workspaceChip())
-  const menu = await screen.findByRole('menu')
-  // The composed flow package occupies the directory-flow hole, so the
-  // picking affordance is present (no advertised-kind read exists anymore).
-  expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
-    .toEqual(['Open local folder…', 'Create a new workspace'])
-  fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
-  // The browse occupant renders the Select Workspace Directory dialog at the
-  // fixture home; select Documents, advance into project, and adopt it.
-  const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
-  // Row targeting goes through the visible label text: listitem accessible-name
-  // computation differs across dom-accessibility-api environments, while the
-  // row's name span is stable (clicks bubble to the row button).
-  fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
-  fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
-  // Open disables while the selection's child listing is in flight; wait for
-  // the enabled state or the click lands on a dead button on slow runners.
-  await waitFor(() => {
-    expect(within(dialog).getByRole('button', { name: '打开' }).disabled).toBe(false)
-  }, { timeout: 10_000 })
-  fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
-  await findHeroComposer()
-  await waitFor(() => {
-    expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
-  })
-})
-
-it('selects the recent Workspace and opens its blank Session on first load', async () => {
-  boot('?fixture')
-
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
-
-  expect({
-    chip: visibleText(workspaceChip()),
-    composerDisabled: composer.disabled,
-    blankRow: within(tree).getByText('New Session').textContent,
-  }).toMatchInlineSnapshot(`
-    {
-      "blankRow": "New Session",
-      "chip": "fixture",
-      "composerDisabled": false,
-    }
-  `)
-})
-
-it('creating a Workspace materializes and lists its selected blank Session', async () => {
-  boot('?fixture=empty')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-
-  // The pick connected the workspace: full Session+Agent exists, composer live.
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
-  expect(within(tree).getByText('New Session')).toBeDefined()
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('created Workspace projection missing')
-
-  expect({
-    composerDisabled: composer.disabled,
-    chip: visibleText(workspaceChip()),
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "nova",
-      "composerDisabled": false,
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
-  boot('?fixture=empty')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-  await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-
-  // New Session resolves through the recent Workspace and reuses its blank
-  // session in place: no locked interlude, no second entity.
-  const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
-    .find(button => visibleText(button) === 'New Session')
-  if (newSessionButton === undefined) throw new Error('New Session button missing')
-  fireEvent.click(newSessionButton)
-  const composer = await findHeroComposer()
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-
-  setComposerText(composer, 'first light')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-
-  // Conversion: the accepted prompt flips blank without adding a second row.
-  await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('converted Session projection missing')
-
-  expect({
-    workspace: visibleText(group),
-    promptVisible: screen.getByText('first light', { exact: true }).textContent,
-  }).toMatchInlineSnapshot(`
-    {
-      "promptVisible": "first light",
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('a failed Workspace attach recovers by reusing the published blank session', async () => {
-  boot('?fixture&fixtureAttach=fail')
-
-  // The rejected startup connect surfaces the locked view state first: the
-  // failure leaves no client-side transaction state to unwind.
-  await findLockedComposer()
-
-  // The host published the session before rejecting attachment (blank, with
-  // the workspace cwd), so the next connect — retry or manual pick — reuses
-  // it instead of minting a duplicate, and the hero opens on it.
-  await pickWorkspace('fixture')
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
-  if (group === null) throw new Error('fixture Workspace projection missing')
-
-  expect({
-    headline: visibleText(screen.getByText("Let's start building")),
-    composerDisabled: composer.disabled,
-    chip: visibleText(workspaceChip()),
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "fixture",
-      "composerDisabled": false,
-      "headline": "Let's start building",
-      "workspace": "fixture3 sessions",
-    }
-  `)
-})
-
-it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
-  boot('?fixture=empty&fixturePrompt=reject')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-  const composer = await findHeroComposer()
-
-  setComposerText(composer, 'do not lose this')
-  fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
-
-  const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
-  // Failure restore rides the machine (no pendingPrompt transaction): the
-  // draft returns to the same resident textarea one render later. The
-  // attempt flips the composer out of the hero (engaging = retry chrome),
-  // but acceptance never happened: the session row stays New Session.
-  const retained = await screen.findByDisplayValue('do not lose this')
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('rejected-send Workspace projection missing')
-
-  expect({
-    error: visibleText(alert),
-    prompt: (retained as HTMLTextAreaElement).value,
-    blankRow: within(tree).getByText('New Session').textContent,
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "blankRow": "New Session",
-      "error": "fixture: prompt rejected before acceptance (agent-busy)",
-      "prompt": "do not lose this",
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('switching Workspace before the first message carries the draft to the new blank session', async () => {
-  boot('?fixture')
-
-  const composer = await findHeroComposer()
-  setComposerText(composer, 'carry me')
-
-  // Switch = session switch: the new workspace's blank session takes over,
-  // the typed draft moves machine-to-machine, the old blank stays hidden.
-  await createWorkspaceViaPicker('nova')
-  await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
-  const carried = await screen.findByDisplayValue('carry me')
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
-  const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
-
-  expect({
-    chip: visibleText(workspaceChip()),
-    prompt: (carried as HTMLTextAreaElement).value,
-    fixtureWorkspace: visibleText(fixtureGroup),
-    novaWorkspace: visibleText(novaGroup),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "nova",
-      "fixtureWorkspace": "fixture3 sessions",
-      "novaWorkspace": "nova1 session",
-      "prompt": "carry me",
-    }
-  `)
-})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 276251910c..6bc412bbe9 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -36,7 +36,8 @@
     "tests/sidebar-scrollbar.e2e.ts",
     "tests/code-mode-round.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
-    "tests/message-actions.e2e.ts"
+    "tests/message-actions.e2e.ts",
+    "tests/skill-invocation-policy.e2e.ts"
   ],
   "references": [
     {
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 4bc87d1ee1..6cbb040158 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -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 docs/architecture.md
-architecture.md: bb5414d6bb108056bf2ff25366e5afe261e1803a
-architecture.zh.md: 6d39a320019a1bf87141be0874a5a20a51fc3fbb
+architecture.md: 1fe5c1dfa4aee8c3bfe5ac634f47bb68f36afe9f
+architecture.zh.md: d754c6a2ea5bcd38524d31d02bc4f38ca2074942
diff --git a/docs/architecture.md b/docs/architecture.md
index bb5414d6bb..1fe5c1dfa4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -47,6 +47,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
 | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
 | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider |
 | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
+| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas |
 | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
 
 ## Event
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 6d39a32001..d754c6a2ea 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -47,6 +47,7 @@
 | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
 | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 |
 | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) |
+| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 |
 | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
 
 ## 事件
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 43f5b20716..65c0ed1842 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -29,6 +29,9 @@ flowchart LR
   pkg_invariants["invariants"]
   svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] + pkg_typert_registry["typert-registry"] + svc_typert["ctx.typert
Runtime type registry"] + pkg_typert_loader["typert-loader"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -224,6 +227,7 @@ flowchart LR pkg_tools --> svc_tools pkg_tui --> svc_tui pkg_tui --> svc_userInteraction + pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web pkg_web_fetch_local --> svc_web @@ -318,6 +322,7 @@ flowchart LR svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web @@ -334,6 +339,7 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e504691195..191d96b255 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1273,7 +1273,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:48`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:49`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1748,7 +1748,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:25`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` @@ -2022,6 +2022,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +## `@deepseek-ai/dsh-typert-loader` + +Requires: `typert` · `loader` + +```ts config-catalog +/** Additional package artifacts whose owning plugins are nested behind another Loader entry. */ +export interface Config { + /** Exact npm package names that must resolve and export `./typert`. */ + packages?: string[] +} +``` + +Source: [`packages/typert/loader/src/index.ts:47`](../packages/typert/loader/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -2264,6 +2278,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) +- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) @@ -2315,3 +2330,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) +- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 234db6273e..8621128e27 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43 -adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md +adding-a-package.md: 2dd9165c4b5a7e04ecc7af0507f364fe89b294bb +adding-a-package.zh.md: 79f022531de500eed1d53b0915ee933b047121ff diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1859310965..2dd9165c4b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -37,7 +37,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. -Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`. +Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 22f574a046..79f022531d 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -37,7 +37,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 -以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。 +以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`、`scripts/check-workspace-constraints.ts`。 ## 3. 确定包拓扑 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index 98c5ee7696..448c548cc9 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2 -adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md +adding-a-vendored-package.md: a951a96f62d2ea3aa693a24d83bf46a1a12070cd +adding-a-vendored-package.zh.md: 878adbb203f8c79db0f127cb1ac58cd9e7a09171 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 71ca9fccc9..a951a96f62 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -42,7 +42,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index c340630aeb..878adbb203 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -42,7 +42,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | -以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor//tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 +以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor//tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 ## 3. 注意 manifest 守卫 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 48128a817d..3b6c3c1ac4 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -9,7 +9,7 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md). -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer). +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). ## `agent/*` @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -119,7 +119,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -142,7 +142,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -164,7 +164,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -187,7 +187,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -211,7 +211,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +241,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:361`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -263,7 +263,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -288,7 +288,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:390`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -308,7 +308,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -332,7 +332,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -358,7 +358,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -659,76 +659,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts) - -## `slash/*` - -### `slash/input-begin-command` — bail - -Applies one command claim to the scoped Input. Dispatched with the session's scope carrier; the owning session's input listener returns `true` only after the phase and span CAS checks pass and the machine actually mutated — producers treat anything else as "not applied". - -```ts cordis-catalog -/** - * Applies one command claim to the scoped Input. Dispatched with the - * session's scope carrier; the owning session's input listener returns - * `true` only after the phase and span CAS checks pass and the machine - * actually mutated — producers treat anything else as "not applied". - * @param request - Claim and menu-time span CAS. - * @mode bail - */ -'slash/input-begin-command'(request: BeginCommandRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:232`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-consume-token` — bail - -Consumes one command token after business success (popup settle / menu-pick execute). Same carrier routing and applied-truth contract. - -```ts cordis-catalog -/** - * Consumes one command token after business success (popup settle / - * menu-pick execute). Same carrier routing and applied-truth contract. - * @param request - Exact span or bare-token guard. - * @mode bail - */ -'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:246`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-insert-reference` — bail - -Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command). - -```ts cordis-catalog -/** - * Inserts one reference into the scoped Input (same carrier routing and - * applied-truth contract as begin-command). - * @param request - Reference and menu-time span CAS. - * @mode bail - */ -'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:239`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-insert-text` — bail - -Replaces the trigger token span with literal text — the plain-text reference path (decision 21). Same carrier routing and applied-truth contract; the draft gains ordinary characters, no occurrence entry. - -```ts cordis-catalog -/** - * Replaces the trigger token span with literal text — the plain-text - * reference path (decision 21). Same carrier routing and applied-truth - * contract; the draft gains ordinary characters, no occurrence entry. - * @param request - Replacement text and menu-time span CAS. - * @mode bail - */ -'slash/input-insert-text'(request: InsertTextRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:254`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b8a1651b5c..93e7821075 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -978,7 +978,7 @@ signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise +async kill(owner: Agent, id: PtySessionId, reason: string = 'model request'): Promise /** * List fresh snapshots for exactly one owner. @@ -1447,7 +1447,7 @@ Exact-read consumer that prepares immutable cross-session message context. * @param signal - optional cancellation boundary for host autocomplete teardown. * @returns candidates labeled by latest title or, when absent, session id. */ -async listCandidates( agent: Agent, query = '', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise +async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** * Snapshot all references before enqueue and return one aggregated durable context. @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1641,7 +1641,7 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages ## `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. +Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. ```ts cordis-catalog /** @@ -1658,22 +1658,23 @@ registerProvider(create: (control: SkillProviderControl) => SkillProvider): () = * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and * receives a no-op disposer so it cannot remove the winner. - * @param skill - the complete skill definition to expose for discovery. + * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void /** - * List model-invocable skill summaries for a workspace. Lookup options and - * provider candidates are readonly same-process values borrowed throughout - * discovery. + * List invocation-neutral skill summaries for a workspace. Consumers apply + * model or user invocation policy at their operational boundary. Lookup + * options and provider candidates are readonly same-process values borrowed + * throughout discovery. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries, excluding skills disabled for model invocation. + * @returns all sorted winning summaries. */ async list(options: SkillLookupOptions = {}): Promise /** - * Observe the current model-invocable catalog and whether discovery completed within a stable revision. + * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. @@ -1694,7 +1695,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise void + +/** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ +get(key: string): TypertSchemaRecord | undefined + +/** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ +resolve(key: string): TypertSchemaRecord + +/** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ +list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] + +/** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ +getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined + +/** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ +listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] + +/** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ +toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema +``` + +Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts) + ## `ctx.userInteraction` — `UserInteractionService` `ctx.userInteraction`: one active UI provider plus an `ask()` surface. diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9f6970d88d..251ff9926e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 docs/core-data-structures/core.md -core.md: b9df539136c2661537775ba9a425bdf7ef1fd958 -core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5 +core.md: 0ab58864bf70a52554d0c4b9da10fa3fc49e9dc2 +core.zh.md: 5719c603d0d7576e9fc030e73fb9a6857fef458c diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b9df539136..0ab58864bf 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -477,7 +477,10 @@ type AgentCancelCause = `Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** + * Public live-agent handle with aliases over the unified delivery primitive. + * @typert object + */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1c75e8484d..5719c603d0 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -485,7 +485,10 @@ type AgentCancelCause = `Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** + * Public live-agent handle with aliases over the unified delivery primitive. + * @typert object + */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 4f6391518d..3e35c3a051 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 docs/core-data-structures/session.md -session.md: 6ae0ab79b5c7bc3bc1859bf819ce25679672a7f0 -session.zh.md: 79ed40f7eee7a8cae05a366d646f85580c73d5d2 +session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6 +session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6ae0ab79b5..fd8285eebd 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -302,6 +302,7 @@ The body-stripped declaration keeps the plain class's public constructor, state * * Plain class (not a Service) — create instances via `ctx.sessions.create()`. * Seeding with an existing event log replays/forks a session. + * @typert object */ declare class Session { /** The ordered surface over this session's event log. */ diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 79ed40f7ee..1033bfda11 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -304,6 +304,7 @@ interface SurfaceFoldResult { * * Plain class (not a Service) — create instances via `ctx.sessions.create()`. * Seeding with an existing event log replays/forks a session. + * @typert object */ declare class Session { /** The ordered surface over this session's event log. */ diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 87869a6b99..f5c134815e 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -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 docs/core-data-structures/skills.md -skills.md: a52a21d2bfe4c4e4529953fdf09998a9a281cefd -skills.zh.md: 6ebc167f7e5dc436ac1f79b8747940e4beb30222 +skills.md: d4b41845bea009444653739abad712e9ce3afb13 +skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index a52a21d2bf..d4b41845be 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -87,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## Summaries, candidates, and complete definitions -`SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name. +`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model. ```ts type-equiv -/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ +/** Invocation controls shared by skill discovery consumers. */ +interface SkillInvocationPolicy { + /** Whether model-facing catalogs and loaders include this skill. */ + readonly modelInvocable: boolean + /** Whether human-facing command catalogs and loaders include this skill. */ + readonly userInvocable: boolean +} +``` + +```ts type-equiv +/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */ interface SkillSummary { - /** Kebab-case identifier used with the `skill` tool. */ + /** Kebab-case identifier used to address the skill. */ readonly name: string - /** Short routing description shown to the model. */ + /** Short routing description shown by discovery consumers. */ readonly description: string - /** Optional extra routing guidance shown to the model. */ + /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ - readonly disableModelInvocation?: boolean + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -109,12 +119,14 @@ interface SkillSummary { } ``` -`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing a consumer to retain its last-good model catalog and retry. +`ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding required field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, defaults omitted fields to `true`, and projects every parsed skill into this normalized policy. + +`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted invocation-neutral summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing each consumer to retain its last-good filtered catalog and retry. ```ts type-equiv /** One catalog observation plus whether discovery completed within a stable catalog revision. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries collected in this observation. */ + /** Sorted invocation-neutral summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed without a concurrent catalog revision. */ readonly complete: boolean @@ -159,11 +171,16 @@ interface SkillDefinition extends SkillSummary { } ``` -Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches. +Runtime skill inputs may omit invocation controls and the provider label. The registry resolves both defaults once, then uses the same complete definition shape and first-wins collection order as providers. The returned disposer removes the contribution and invalidates discovery caches. ```ts type-equiv /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -type SkillRegistration = Omit & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## Lookup and configuration @@ -198,4 +215,4 @@ interface Config { Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State. -The model-facing `skill({ name })` tool validates the kebab-case name, rereads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. +The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 6ebc167f7e..8d67931290 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -87,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## 摘要、候选项与完整定义 -`SkillSummary` 是注册表中可供模型调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用 body 或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 +`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。 ```ts type-equiv -/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ +/** Invocation controls shared by skill discovery consumers. */ +interface SkillInvocationPolicy { + /** Whether model-facing catalogs and loaders include this skill. */ + readonly modelInvocable: boolean + /** Whether human-facing command catalogs and loaders include this skill. */ + readonly userInvocable: boolean +} +``` + +```ts type-equiv +/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */ interface SkillSummary { - /** Kebab-case identifier used with the `skill` tool. */ + /** Kebab-case identifier used to address the skill. */ readonly name: string - /** Short routing description shown to the model. */ + /** Short routing description shown by discovery consumers. */ readonly description: string - /** Optional extra routing guidance shown to the model. */ + /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ - readonly disableModelInvocation?: boolean + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -109,12 +119,14 @@ interface SkillSummary { } ``` -`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集并排序的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此消费方可以保留上一份可用模型目录并重试。 +`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。 + +`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集、排序且与调用策略无关的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此每个消费方可以保留上一份经过自身过滤的可用目录并重试。 ```ts type-equiv /** One catalog observation plus whether discovery completed within a stable catalog revision. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries collected in this observation. */ + /** Sorted invocation-neutral summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed without a concurrent catalog revision. */ readonly complete: boolean @@ -159,11 +171,16 @@ interface SkillDefinition extends SkillSummary { } ``` -运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 +运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 ```ts type-equiv /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -type SkillRegistration = Omit & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## 查找与配置 @@ -198,4 +215,4 @@ interface Config { 在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 重新读取完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它为调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将未解析的 skill 报告为 unknown 或 no longer available,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 311f872b4a..306a1d2d08 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 docs/development.md -development.md: 0a18e29d3da4f694707521e230017e6b22cad740 -development.zh.md: 885b51c701267215cc50d31ecd1694ae2c9af9ca +development.md: 859de959dc5d93c2f0ddbe5c7f700d4bdaf9e09b +development.zh.md: faa6a07731deed77663727b3ee1f0fe060580c53 diff --git a/docs/development.md b/docs/development.md index 0a18e29d3d..859de959dc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -106,8 +106,8 @@ pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates -pnpm run lint # eslint . -pnpm run lint:fix # eslint . --fix +pnpm run lint # oxlint . +pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source pnpm run verify-cordis-catalog # fail if either cordis catalog is stale diff --git a/docs/development.zh.md b/docs/development.zh.md index 885b51c701..faa6a07731 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 @@ -106,8 +106,8 @@ pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates -pnpm run lint # eslint . -pnpm run lint:fix # eslint . --fix +pnpm run lint # oxlint . +pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source pnpm run verify-cordis-catalog # fail if either cordis catalog is stale diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6e85b34820..5a7eaa5d17 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:361`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:390`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | @@ -35,11 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:157`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:232`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:246`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:239`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:254`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -67,9 +63,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `hmr`, `modules`, `webserver` | +| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | +| `slash/input-begin-command` | - | `ui-conversation` | +| `slash/input-consume-token` | - | `ui-conversation` | +| `slash/input-insert-reference` | - | `ui-conversation` | +| `slash/input-insert-text` | - | `ui-conversation` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 2b873c1ed3..5321645dcb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -241,6 +241,11 @@ flowchart TD pkg_session_telemetry["session-telemetry"] pkg_session_telemetry_otel["session-telemetry-otel"] end + subgraph group_typert["packages/typert"] + pkg_typert_generator["typert-generator"] + pkg_typert_loader["typert-loader"] + pkg_typert_registry["typert-registry"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -263,7 +268,6 @@ flowchart TD pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants - pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -274,6 +278,8 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_typert_generator --> pkg_invariants + pkg_typert_registry --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout @@ -297,10 +303,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime @@ -321,6 +323,8 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_typert_loader --> pkg_invariants + pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -336,12 +340,19 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_slash --> pkg_client_locale pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_primitives @@ -609,6 +620,7 @@ flowchart TD pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime pkg_client_ui_model --> pkg_client_ui_command pkg_client_ui_model --> pkg_client_ui_conversation @@ -992,7 +1004,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -1003,6 +1014,8 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | +| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -1010,7 +1023,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1019,12 +1031,15 @@ flowchart TD | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | @@ -1089,7 +1104,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | diff --git a/docs/typert-catalog-integration-design.i18n.yaml b/docs/typert-catalog-integration-design.i18n.yaml new file mode 100644 index 0000000000..a7290fae68 --- /dev/null +++ b/docs/typert-catalog-integration-design.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 docs/typert-catalog-integration-design.md +typert-catalog-integration-design.md: c7d601730655f61f3b875ad5ad6997d3c888bfea +typert-catalog-integration-design.zh.md: abaddfe1f740d4bd7cff5b2db8fd91e34626aab8 diff --git a/docs/typert-catalog-integration-design.md b/docs/typert-catalog-integration-design.md new file mode 100644 index 0000000000..c7d6017306 --- /dev/null +++ b/docs/typert-catalog-integration-design.md @@ -0,0 +1,133 @@ +# Typert Catalog Integration Design + +English | [中文](typert-catalog-integration-design.zh.md) + +## Current State and Problem + +Typert already provides separate host/client `FaceModel` instances, a `TypeGraph` with explicit cross-face references, and analysis support for services, events, `@typert object`, generics, inheritance, and External types. The TypeScript compiler API should only translate source code into this standard model; downstream consumers should not traverse the TypeScript AST again. + +The repository currently has two catalog pipelines that analyze TypeScript source directly: the static API catalog consumed by `tool-cordis`, and the generation and freshness gate for `docs/cordis-catalog/events.md` and `docs/cordis-catalog/services.md`. They analyze the same services, events, and related types, but maintain separate collection and rendering logic, so they cannot prove that the Typert model is sufficient to represent the existing domain semantics. + +The first phase makes both pipelines consume the Typert model while keeping the three committed artifacts character-for-character identical to their pre-migration versions: + +- `docs/cordis-catalog/events.md` +- `docs/cordis-catalog/services.md` +- `packages/cordis/tool-cordis/src/api-catalog.ts` + +This phase does not require product plugins to publish Typert subpaths, example applications to load Typert, or changes to the runtime dependencies of `tool-cordis`. + +## Options + +### Drive `tool-cordis` from the Runtime Registry + +Each plugin publishes and loads Typert artifacts, then `tool-cordis` reads the current runtime model from `ctx.typert`. This path reflects the set of plugins actually loaded, but it requires every product package represented in the catalog to add package exports, generated artifacts, registry contributions, and application assembly. That integration surface is much larger than the analysis capability being validated now. + +### Publish Typert Artifacts Repository-Wide, Then Aggregate Them Statically + +All product packages generate host/client JS and DTS during the normal build/typecheck process, then the catalog generator aggregates those artifacts. This path establishes the complete publication protocol up front, but it also changes many package manifests and the build topology at once, coupling catalog migration to repository-wide Typert publication. + +### Analyze at Build Time, Then Project the Catalog + +`WorkspaceAnalyzer` builds a `WorkspaceModel` and `TypeGraph` from the host TypeScript project. The repository-specific `CordisCatalogProjector` consumes only that model and generates the three texts. `tool-cordis` continues to import the committed static `api-catalog.ts`, so the runtime does not need the Typert service. + +This phase uses build-time projection. It directly verifies that the standard Typert model can replace the existing AST collector while leaving runtime publication and automatic loading to separate follow-up decisions. + +## Phase-One Architecture + +```text +tsconfig.host.json + │ + ▼ +WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 + │ + ▼ +WorkspaceModel + TypeGraph + │ + ▼ +CordisCatalogProjector ── 不依赖 TypeScript AST + ├── docs/cordis-catalog/events.md + ├── docs/cordis-catalog/services.md + └── packages/cordis/tool-cordis/src/api-catalog.ts +``` + +The objects have the following responsibilities: + +- `WorkspaceAnalyzer` analyzes packages, exports, services, events, type declarations, and reference relationships, and produces a compiler-independent model. +- `WorkspaceModel` and `TypeGraph` are the standard data structures shared by all generation and scanning analyses. They preserve developer-authored generics, inheritance, and type trees without retaining the TypeScript AST. +- The root entry point of `@deepseek-ai/dsh-typert-generator` exports `CordisCatalogProjector`, which performs model-driven selection, sorting, summary extraction, source location handling, JSDoc completeness checks, type-link closure, and rendering in three text formats. Its implementation remains in a dedicated Cordis catalog file, but it does not create another package subpath or embed a list of repository type names. +- `scripts/gen-cordis-catalog.ts` provides `LINK_MAP`, `FOUNDATION_TYPE_NAMES`, `TYPE_LINK_EXEMPTIONS`, and the inherited Cordis list, injects them explicitly into the projector through `CordisCatalogPolicy`, and owns the write/check CLI behavior. The vendor Cordis core pages continue to be generated by a separate pinned-source projector. +- `tool-cordis` imports only the static `api-catalog.ts` and does not depend on `typert-registry` or `typert-loader`. + +`CordisCatalogProjector` is a repository-specific downstream consumer and is not part of Typert's general-purpose model. When adding another category, first extend the standard model, then add the corresponding projector. The Typert analyzer must not absorb Cordis documentation formats or `tool-cordis` presentation logic. + +## Model Additions + +In addition to type structure, the catalog's character-for-character projection needs the declaration forms written by developers and exact source locations. The standard model therefore retains event/service locations, body-free text for events and members, parameter initializers, and the export status and canonical text of type declarations. `SourceDeclarationModel` also indexes top-level exported declarations for ambiguity checks and static type closure, without promoting them to domain graph roots. + +```ts +interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +interface EventModel { + readonly location: SourceLocation + readonly text: string +} +``` + +Repository-wide analysis supports building bounded `ts.Program` instances in package batches, then merging them through source-location-stable graph ids into a face model equivalent to monolithic analysis. This capability changes only the memory boundary of the compiler program; it does not change package, declaration, or type graph semantics. + +All information required by the projector must come from `WorkspaceModel` or `TypeGraph`. If a fact required for character-for-character compatibility cannot be expressed by the model, extend the standard model; do not reintroduce `ts.Node`, `ts.Symbol`, or `ts.TypeChecker` in the projector or script. + +## Character-for-Character Migration Oracle + +Before migration, retain the three texts produced by the old generator against the same source state. After migration, run the new analyzer and projector and require the three outputs to be byte-for-byte identical. Newlines, spaces, ordering, JSDoc, source pointers, and generated headers are all part of the comparison. + +`pnpm run verify-cordis-catalog` retains its `--check` mode, which reads the three committed artifacts and compares them directly with the newly computed results. A missing file or any differing character makes the artifact stale, and the error points to the single `pnpm run gen-cordis-catalog` repair command. + +Tests pin both of the following layers: + +- Typert fixture snapshots pin the `WorkspaceModel`, `TypeGraph`, JS, DTS, and Zod outputs, proving the behavior of the standard model and general-purpose emitters. +- Cordis catalog tests or snapshots pin the projector's three complete texts, proving that the repository-specific product projection does not bypass the standard model and providing directly reviewable textual evidence. + +The three committed artifacts are the migration oracle between the old and new implementations and the continuing freshness oracle after migration. The old `gen-cordis-api` AST collector is removed. The scripts and commands with that name remain only as compatibility entry points for the unified projector because the generated file header itself contains the command; retaining the entry point preserves the character-for-character oracle without creating a second source of truth. + +## Exact Change List + +### Typert Generator + +- Add the locations, authored declaration text, parameter initializers, export status, and top-level source declaration index needed for character-for-character projection, with coverage in analyzer and model snapshots. +- Support bounded package-batch analysis and prove that direct and batched models are equivalent. +- Confirm that the catalog's required service declarations, public instance members, JSDoc, generics, inheritance, and referenced types are all available from the model. +- Keep the TypeScript compiler API encapsulated within the analyzer; the public model and projector inputs do not expose compiler objects. + +### Cordis Catalog Projector + +- Select the complete set of Cordis services and events from the host `WorkspaceModel`. +- Preserve the old generator's JSDoc rules: events must have `@mode` and payload `@param` tags; service methods must have a matching `@param` for every parameter; non-void returns must have `@returns`. +- Compute the type links used by signatures and the transitive public type closure required by `tool-cordis` from the type graph. +- Receive caller-maintained type classifications and the inherited surface through an explicit `CordisCatalogPolicy`; do not maintain the repository documentation taxonomy inside the generator package. +- Preserve the existing output rules for source pointers, signatures, summaries, ordering, declaration truncation, and the inherited context catalog. +- Project once and render the events Markdown, services Markdown, and TypeScript API catalog, preventing drift between documentation and tool data. + +### Commands and Consumers + +- `scripts/gen-cordis-catalog.ts` maintains repository policy data, assembles the analyzer and projector, and writes/checks all three artifacts together. Parsing, validation, and rendering logic lives in the generator's dedicated Cordis source file and is exported uniformly from the package root entry point. +- Narrow `scripts/gen-cordis-api.ts` to a logic-free compatibility entry point for the unified CLI; the root `gen-cordis-api` and `verify-cordis-api` aliases point to that entry point. +- Restore the static catalog default in `tool-cordis` and remove its dependencies on `ctx.typert`, `typert-registry`, and runtime package-model completeness. +- `gen-doc-graphs` obtains the projector's model-level result once and reuses its services and events; it must not continue to import the AST collector or analyze the repository again. + +### Narrow the Scope of Phase-One Changes + +- Remove the newly added `./typert` and `./client/typert` exports and `lib/typert.*` files from product plugin package.json files. +- Remove `typert-registry` and `typert-loader` assembly from examples. +- Normal build/typecheck does not run repository-wide `gen-typert` or require product-package Typert artifacts to exist before it runs on a clean tree. +- Retain `packages/typert/generator`, `packages/typert/registry`, and `packages/typert/loader`, along with their independent fixture, emitter, and runtime registration tests. + +## Future Extensions + +The runtime registry remains the receiving and query layer for generated JS/Zod, and the loader remains the automatic loading mechanism; neither supplies data to the first-phase static catalog. When product packages need runtime reflection, they can opt in by publishing `package/typert` and `package/client/typert`, which the loader then registers with `ctx.typert`. + +Future integration does not change the phase-one layering: only the analyzer handles TypeScript, the standard model serves both static generation and scan analysis, and the emitter produces runtime artifacts from that same model. Whether to extend publication to more packages, enable the loader by default, or extend the runtime registry's query capabilities are separate review decisions and remain decoupled from the Cordis catalog migration. diff --git a/docs/typert-catalog-integration-design.zh.md b/docs/typert-catalog-integration-design.zh.md new file mode 100644 index 0000000000..abaddfe1f7 --- /dev/null +++ b/docs/typert-catalog-integration-design.zh.md @@ -0,0 +1,133 @@ +# Typert catalog 接入设计 + +[English](typert-catalog-integration-design.md) | 中文 + +## 现状与问题 + +Typert 已经具备独立的 host/client `FaceModel`、可显式跨 face 引用的 `TypeGraph`,以及 service、event、`@typert object`、泛型、继承和 External 类型的分析能力。TypeScript compiler API 只应负责把源码转换成这套标准模型;后续消费者不应再次遍历 TypeScript AST。 + +仓库目前有两条直接分析 TypeScript 源码的 catalog 链路:`tool-cordis` 使用的静态 API catalog,以及 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md` 的生成与 freshness gate。它们分析的是同一批 service、event 和相关类型,却分别维护收集与渲染逻辑,不能证明 Typert 模型足以承载现有业务语义。 + +第一阶段的目标是让这两条链路共同消费 Typert 模型,并保持三份已提交产物与迁移前字符级一致: + +- `docs/cordis-catalog/events.md` +- `docs/cordis-catalog/services.md` +- `packages/cordis/tool-cordis/src/api-catalog.ts` + +本阶段不要求业务插件发布 Typert 子路径,不要求示例应用加载 Typert,也不改变 `tool-cordis` 的运行时依赖关系。 + +## 可选路径 + +### 运行时 registry 驱动 `tool-cordis` + +每个插件发布并加载 Typert 产物,`tool-cordis` 再从 `ctx.typert` 读取当前运行时模型。这条路径可以反映实际加载的插件集合,但会要求所有参与 catalog 的业务包增加 package exports、生成产物、registry contribution 和应用装配,接入面远大于当前要验证的分析能力。 + +### 全仓发布 Typert 产物后静态汇总 + +所有业务包在普通 build/typecheck 中生成 host/client JS 与 DTS,再由 catalog 生成器汇总这些产物。这条路径能够提前建立完整的发布协议,但会同时修改大量 package manifest 和构建拓扑,使 catalog 迁移与 Typert 的全仓发布绑定。 + +### 构建期分析后投影 catalog + +`WorkspaceAnalyzer` 从 host TypeScript project 构建 `WorkspaceModel` 与 `TypeGraph`,仓库专用的 `CordisCatalogProjector` 只消费该模型并生成三份文本。`tool-cordis` 继续导入已提交的静态 `api-catalog.ts`,运行时不需要 Typert service。 + +本阶段采用构建期投影。它直接验证 Typert 标准模型能否替代现有 AST collector,同时把运行时 publication 和自动加载留在独立的后续决策中。 + +## 第一阶段架构 + +```text +tsconfig.host.json + │ + ▼ +WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 + │ + ▼ +WorkspaceModel + TypeGraph + │ + ▼ +CordisCatalogProjector ── 不依赖 TypeScript AST + ├── docs/cordis-catalog/events.md + ├── docs/cordis-catalog/services.md + └── packages/cordis/tool-cordis/src/api-catalog.ts +``` + +各对象的职责如下: + +- `WorkspaceAnalyzer` 负责 package、export、service、event、类型声明和引用关系的分析,并产生 compiler-independent model。 +- `WorkspaceModel` 与 `TypeGraph` 是所有生成和扫描分析共用的标准数据结构,保留开发者写出的泛型、继承和类型树,不保存 TypeScript AST。 +- `@deepseek-ai/dsh-typert-generator` 根入口导出的 `CordisCatalogProjector` 负责模型驱动的选择、排序、摘要、源位置、JSDoc 完整性、类型链接闭包和三种文本格式;实现仍单独放在 Cordis catalog 专用文件中,但不形成额外的 package subpath,也不内置仓库类型名单。 +- `scripts/gen-cordis-catalog.ts` 提供 `LINK_MAP`、`FOUNDATION_TYPE_NAMES`、`TYPE_LINK_EXEMPTIONS` 和 inherited Cordis 清单,通过 `CordisCatalogPolicy` 显式注入 projector,并负责 write/check 的命令行行为;vendor Cordis core 页面仍由独立的 pinned-source projector 生成。 +- `tool-cordis` 只导入静态 `api-catalog.ts`,不依赖 `typert-registry` 或 `typert-loader`。 + +`CordisCatalogProjector` 是仓库业务消费者,不进入 Typert 通用模型。新增其他类别时,先扩展标准模型,再增加对应 projector;Typert analyzer 不吸收 Cordis 文档格式或 `tool-cordis` 展示逻辑。 + +## 模型补充 + +Catalog 的字符级投影除了类型结构,还需要开发者写下的声明形式和精确源码位置。标准模型因此保留 event/service location、event/member 的 body-free text、parameter initializer,以及 type declaration 的 export 状态和 canonical text;`SourceDeclarationModel` 另外索引顶层导出声明,供歧义检查和静态类型闭包使用,但不把它们提升为业务 graph root。 + +```ts +interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +interface EventModel { + readonly location: SourceLocation + readonly text: string +} +``` + +全仓分析支持按 package 分批构建有界 `ts.Program`,再依靠源码位置稳定的 graph id 合并为与一次性分析等价的 face model。该能力只改变 compiler program 的内存边界,不改变 package、declaration 或 type graph 语义。 + +projector 所需信息必须来自 `WorkspaceModel` 或 `TypeGraph`。如果字符级兼容需要的事实无法从模型表达,应补充标准模型;不得在 projector 或脚本中重新引入 `ts.Node`、`ts.Symbol` 或 `ts.TypeChecker`。 + +## 字符级迁移 oracle + +迁移前,在同一份源码状态下保留旧生成器产生的三份文本。迁移后运行新的 analyzer 与 projector,要求三份输出逐字节相等;换行、空格、排序、JSDoc、source pointer 和生成头都属于比较内容。 + +`pnpm run verify-cordis-catalog` 的 `--check` 模式继续读取三份 committed artifact,并与本次计算结果直接比较。任一文件缺失或任一字符不同都视为 stale,错误信息指向统一的 `pnpm run gen-cordis-catalog` 修复命令。 + +测试同时固定以下两层: + +- Typert fixture snapshots 固定 `WorkspaceModel`、`TypeGraph`、JS、DTS 与 Zod 输出,证明标准模型和通用 emitter 的行为。 +- Cordis catalog 测试或 snapshot 固定 projector 的三份完整文本,证明仓库业务投影没有绕过标准模型,并给出可直接评审的文本证据。 + +三份 committed artifact 是旧实现与新实现的迁移 oracle,也是迁移完成后的持续 freshness oracle。旧 `gen-cordis-api` AST collector 被删除;同名脚本和命令只作为统一 projector 的兼容入口保留,因为生成文件头本身包含该命令,保留入口可以维持字符级 oracle 而不产生第二套真源。 + +## 精确改造清单 + +### Typert generator + +- 补齐字符级投影所需的 location、authored declaration text、parameter initializer、export 状态和顶层 source declaration index,并在 analyzer 与 model snapshots 中覆盖。 +- 支持有界 package batch 分析,并证明 direct 与 batched model 等价。 +- 确认 catalog 所需的 service 声明、public instance member、JSDoc、泛型、继承和引用类型均可从 model 读取。 +- 保持 TypeScript compiler API 封装在 analyzer 内;公共 model 和 projector 输入不暴露 compiler 对象。 + +### Cordis catalog projector + +- 从 host `WorkspaceModel` 选择完整的 Cordis service/event 集合。 +- 保留旧生成器的 JSDoc 规则:event 必须有 `@mode` 和 payload `@param`,service method 必须有参数对应的 `@param`,非 void 返回必须有 `@returns`。 +- 从 type graph 计算签名涉及的类型链接和 `tool-cordis` 所需的传递 public type closure。 +- 通过显式 `CordisCatalogPolicy` 接收调用方维护的类型分类和 inherited surface,不在 generator 包内维护仓库文档 taxonomy。 +- 保留 source pointer、签名、摘要、排序、声明截断和 inherited context catalog 的既有输出规则。 +- 一次投影并渲染 events Markdown、services Markdown 与 TypeScript API catalog,避免文档和工具数据漂移。 + +### 命令与消费方 + +- `scripts/gen-cordis-catalog.ts` 维护仓库 policy 数据、组装 analyzer/projector,并同时 write/check 三份产物;解析、校验和渲染逻辑位于 generator 的 Cordis 专用源文件,并统一从 package 根入口导出。 +- 将 `scripts/gen-cordis-api.ts` 收窄为统一 CLI 的无逻辑兼容入口;根目录的 `gen-cordis-api`、`verify-cordis-api` aliases 指向该入口。 +- `tool-cordis` 恢复静态 catalog 默认值,移除对 `ctx.typert`、`typert-registry` 和运行时 package model 完整性的依赖。 +- `gen-doc-graphs` 一次取得 projector 的 model-level 结果并复用 services/events,不能继续导入 AST collector 或重复分析全仓。 + +### 收窄本阶段改动面 + +- 撤销业务插件 package.json 中新增的 `./typert`、`./client/typert` exports 和 `lib/typert.*` files。 +- 撤销 examples 中的 `typert-registry`、`typert-loader` 装配。 +- 普通 build/typecheck 不运行全仓 `gen-typert`,也不要求 clean tree 预先存在业务包 Typert artifact。 +- 保留 `packages/typert/generator`、`packages/typert/registry`、`packages/typert/loader` 及其独立 fixture、emitter 和 runtime registration 测试。 + +## 后续扩展 + +Runtime registry 继续作为生成 JS/Zod 后的接收与查询层,loader 继续作为自动装载机制;两者不承担第一阶段静态 catalog 的数据来源。业务包需要运行时反射时,可以按 package opt-in 发布 `package/typert` 与 `package/client/typert`,再由 loader 注册到 `ctx.typert`。 + +后续接入不改变本阶段的分层:TypeScript 只进入 analyzer,标准模型同时服务静态生成与扫描分析,runtime artifact 由 emitter 从同一模型产生。是否把更多 package 接入 publication、是否默认启用 loader,以及 runtime registry 最终提供哪些查询能力,分别评审,不与 Cordis catalog 迁移捆绑。 diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 696b082828..0000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,188 +0,0 @@ -import stylistic from '@stylistic/eslint-plugin' -import sonarjs from 'eslint-plugin-sonarjs' -import tseslint from 'typescript-eslint' - -// Strict type-aware correctness rules plus repository formatting. Tests/examples relax deliberate -// mock unsafety; vendored sources retain upstream style and receive only selected safety checks. -export default tseslint.config( - { - ignores: [ - '**/lib/**', - '**/node_modules/**', - '**/.sessions/**', - '.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources - '**/.doc-typecheck-*/**', - '**/.node-next-types-*/**', - 'website/.generated/**', - 'vendor/**', // vendored source keeps upstream style and idioms - 'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md) - '**/*.js', - '**/*.mjs', - '*.config.ts', // root tool configs (vitest, tsdown) — no project service - 'apps/*/*.config.ts', // app build configs — outside their project programs - '**/tsdown.config.ts', // package build configs — in no tsconfig program, and TS syntax breaks the parserless fallback - 'packages/client/tsdown.client.ts', // shared client build preset, same standing - ], - }, - - // --- our packages: full strictness ------------------------------------- - { - files: [ - 'packages/*/*/src/**/*.{ts,tsx}', - 'apps/*/src/**/*.{ts,tsx}', - 'examples/**/*.{ts,tsx}', - 'scripts/**/*.{ts,tsx}', - 'website/**/*.{ts,tsx}', - ], - extends: [ - ...tseslint.configs.strictTypeChecked, - ], - languageOptions: { - parserOptions: { - // One project service resolves each file to its owning tsconfig and shares dependency - // graphs. Per-package programs duplicated path-mapped and Cordis closures, reaching ~5 GB. - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - rules: { - // The bug class this repo cares most about: lost promises in the loop. - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - '@typescript-eslint/require-await': 'error', - '@typescript-eslint/switch-exhaustiveness-check': ['error', { - considerDefaultExhaustiveForUnions: true, - }], - '@typescript-eslint/no-unnecessary-condition': ['error', { - allowConstantLoopConditions: true, - }], - // `any` requires a justification comment — enforced as: no bare casts. - '@typescript-eslint/no-explicit-any': 'error', - // Style points where the codebase intentionally diverges from preset: - '@typescript-eslint/no-namespace': 'off', // Cordis Config-namespace idiom - '@typescript-eslint/no-empty-object-type': 'off', // merge-extensible maps - '@typescript-eslint/no-invalid-void-type': 'off', // event signatures - '@typescript-eslint/restrict-template-expressions': ['error', { - allowNumber: true, - allowBoolean: true, - }], - // `void foo()` in arrow listeners is our idiom for intentional fire-and-forget - 'no-void': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }], - }, - }, - - // --- examples: demo code conforms to async interfaces without awaiting --- - { - files: ['examples/**/*.ts'], - rules: { - '@typescript-eslint/require-await': 'off', - }, - }, - - // --- tests: same rules, minus the friction that fights test ergonomics -- - { - files: [ - 'packages/*/*/tests/**/*.{ts,tsx}', - 'apps/*/tests/**/*.{ts,tsx}', - 'examples/*/tests/**/*.{ts,tsx}', - 'scripts/**/*.spec.{ts,tsx}', - ], - extends: [ - ...tseslint.configs.strictTypeChecked, - ], - languageOptions: { - parserOptions: { - // Same shared project service as the src block: test files resolve - // through the root solution to tsconfig.host.json (its include covers - // every host tests/ tree). - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - rules: { - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-non-null-assertion': 'off', // assertions follow expect()s - '@typescript-eslint/no-unnecessary-condition': 'off', - '@typescript-eslint/require-await': 'off', // mock execute() signatures - '@typescript-eslint/no-empty-function': 'off', // stub agents - '@typescript-eslint/only-throw-error': 'off', // testing non-Error throws - '@typescript-eslint/no-namespace': 'off', - '@typescript-eslint/no-empty-object-type': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }], - }, - }, - - // --- client tests: the root program excludes packages/client (host/client - // Context merges collide), so the shared project service cannot resolve - // them — parse these through the client aggregate explicitly. - { - files: [ - 'packages/client/*/tests/**/*.{ts,tsx}', - 'scripts/client-bundle-purity.spec.ts', - ], - languageOptions: { - parserOptions: { - projectService: false, - project: ['./tsconfig.client.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - - // --- file-local duplication (all owned TypeScript) --------------------- - { - files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'], - plugins: { sonarjs }, - rules: { - // Cross-file clones are covered separately by jscpd. - 'sonarjs/duplicates-in-character-class': 'error', - 'sonarjs/no-all-duplicated-branches': 'error', - 'sonarjs/no-duplicate-in-composite': 'error', - 'sonarjs/no-duplicate-test-title': 'error', - 'sonarjs/no-identical-conditions': 'error', - 'sonarjs/no-identical-expressions': 'error', - 'sonarjs/no-identical-functions': 'error', - 'sonarjs/no-duplicated-branches': 'error', - }, - }, - - // --- formatting (everything we own) ------------------------------------- - { - files: [ - 'packages/**/*.{ts,tsx}', - 'apps/**/*.{ts,tsx}', - 'examples/**/*.{ts,tsx}', - 'scripts/**/*.{ts,tsx}', - 'website/**/*.{ts,tsx}', - 'eslint.config.mjs', - ], - plugins: { '@stylistic': stylistic }, - rules: { - '@stylistic/indent': ['error', 2], - '@stylistic/semi': ['error', 'never'], - '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], - '@stylistic/comma-dangle': ['error', 'always-multiline'], - '@stylistic/eol-last': ['error', 'always'], - '@stylistic/no-trailing-spaces': 'error', - '@stylistic/object-curly-spacing': ['error', 'always'], - '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], - '@stylistic/member-delimiter-style': ['error', { - multiline: { delimiter: 'none' }, - singleline: { delimiter: 'semi', requireLast: false }, - }], - '@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }], - }, - }, -) diff --git a/eslint.format.config.mjs b/eslint.format.config.mjs new file mode 100644 index 0000000000..5681688e81 --- /dev/null +++ b/eslint.format.config.mjs @@ -0,0 +1,59 @@ +import stylistic from '@stylistic/eslint-plugin' +import parser from '@typescript-eslint/parser' + +// Oxlint's JavaScript-plugin compatibility layer reports these rules but does +// not execute their fixers. Keep this config formatting-only: Oxlint remains +// the authoritative repository linter after this pass applies safe fixes. +export default [ + { + ignores: [ + '**/lib/**', + '**/node_modules/**', + '**/.sessions/**', + '.claude/**', + '**/.doc-typecheck-*/**', + '**/.node-next-types-*/**', + // Do not mirror Oxlint's contract-fixture ignore: those files must reach this formatter. + 'website/.generated/**', + 'vendor/**', + 'native/**', + '**/*.js', + '**/*.mjs', + '**/*.config.ts', + 'packages/client/tsdown.client.ts', + ], + }, + { + files: ['**/*.{ts,tsx,mts,cts}'], + languageOptions: { + parser, + parserOptions: { + sourceType: 'module', + }, + }, + plugins: { + '@stylistic': stylistic, + }, + rules: { + '@stylistic/indent': ['error', 2], + '@stylistic/semi': ['error', 'never'], + '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/eol-last': ['error', 'always'], + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/object-curly-spacing': ['error', 'always'], + '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], + '@stylistic/member-delimiter-style': ['error', { + multiline: { delimiter: 'none' }, + singleline: { delimiter: 'semi', requireLast: false }, + }], + }, + }, + { + // TypeGraph coverage must retain source-authored syntax that the normal quote rule forbids. + files: ['packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'], + rules: { + '@stylistic/quotes': 'off', + }, + }, +] diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f4cd6e95c4..2be63076e8 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -226,10 +226,16 @@ const SCENARIOS: Scenario[] = [ headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, - // Prompt-submit blocks are authored keylessly. Admission rejects before a - // turn opens, so only the ACP stop reason is observable and no log is harvested. + // Prompt-submit blocks are authored keylessly with malformed matcher fields, + // which these matcherless events must ignore. Admission rejects before a turn + // opens, so only the ACP stop reason is observable and no log is harvested. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false }, { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false }, + // Each invalid matcher follows a runnable prompt blocker. Reaching the replay + // model without any hook audit rows proves config loading is atomic through + // the real Loader/app path, rather than retaining the earlier valid group. + { name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false }, + { name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false }, // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). // SessionStart/SubagentStart are excluded because detached injection races log diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..32b1461b7c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'must not run' >&2; exit 2" } + ] + } + ], + "PreToolUse": [ + { + "matcher": "[", + "hooks": [ + { "type": "command", "command": "exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json index ee3da88fb1..d4ef9cc633 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" } ] diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..f4374b94a3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'must not run' >&2; exit 2" } + ] + } + ], + "PreToolUse": [ + { + "matcher": "[", + "hooks": [ + { "type": "command", "command": "exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json index 84bc6f37d0..f3fc9de501 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" } ] diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index cf5b8e6026..6fc71b0dd0 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"9c670f1c-3508-4b98-9cae-21f363652d6e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1784903324927,"data":{"turn":1,"step":1}} {"type":"request/header","seq":5,"time":1784903324928,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..ac7c02e29f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Prove user-disabled skills remain available to the model. +user-invocable: false +--- + +Follow these model-only snapshot instructions. diff --git a/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..4b02550d15 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: Prove model-disabled skills stay outside the model catalog. +disable-model-invocation: true +--- + +Follow these user-only snapshot instructions. diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl b/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl new file mode 100644 index 0000000000..e7d6e3aeec --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} +{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} +{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt new file mode 100644 index 0000000000..2d6068d48b --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt @@ -0,0 +1,157 @@ +=== skill autocomplete === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=13 viewportRow=5 bufferRow=5 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Recorded replay: skill-invocation-policy" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +5| " dsh > /skill " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 13-13 inverse +6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " + style 7-78 fg=bright-magenta +7-35| + + +=== loaded exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=30 bufferRow=30 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +30| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +31-35| + + +=== denied exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Skill \"model-only-skill\" is not available for user invocation. " + style 0-61 fg=yellow +30| +31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..3608a840f5 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Model-only assembled snapshot skill. +user-invocable: false +--- + +MODEL-ONLY BODY MUST NOT LOAD diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..fa231cd99d --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: User-only assembled snapshot skill. +disable-model-invocation: true +--- + +USER-ONLY BODY diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 139c8a1900..cd5f83e4e3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -217,11 +217,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => { - // The whole manual-invocation path in one keyless boot: `ctx.get('skills')` + // The whole user-only invocation path in one keyless boot: `ctx.get('skills')` // resolves in the shipped tree, the client-side `/skill:` command parses, - // the local provider loads `scripted-skill` from the agents home, and the - // rendered `` block reaches the model — proven by the - // scripted adapter echoing the fixture's body marker only when it arrives. + // and the local provider admits a model-disabled skill by the omitted + // `user-invocable` default. The rendered `` block reaches + // the model — proven by the scripted adapter echoing the fixture's body + // marker only when it arrives. const output = await smoke({ label: 'tui-agent skill', tempDirPrefix: 'tui-agent-skill-', @@ -232,6 +233,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { '---', 'name: scripted-skill', 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', + 'disable-model-invocation: true', '---', '', 'SCRIPTED SKILL BODY MARKER', diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index af7c027d6d..95b960e3b6 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -41,6 +41,7 @@ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi type SnapshotMode = 'replay' | 'record' | 'refresh' type Composition = 'native' | 'code' | 'advanced' +type ScenarioInteraction = 'skill-invocation-policy' interface Scenario { name: string @@ -65,6 +66,8 @@ interface Scenario { * preview + locator while the program value stays whole. */ spillMaxInlineBytes?: number + /** Run scenario-specific terminal input instead of replaying recorded user prompts. */ + interaction?: ScenarioInteraction } const SCENARIOS: Scenario[] = [ @@ -98,6 +101,14 @@ const SCENARIOS: Scenario[] = [ recorded: true, seedWorkspace: true, }, + { + name: 'skill-invocation-policy', + composition: 'native', + expectedTools: [], + recorded: false, + seedWorkspace: true, + interaction: 'skill-invocation-policy', + }, { name: 'code-mode', composition: 'code', @@ -269,9 +280,10 @@ async function runScenario(scenario: Scenario): Promise { const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) - const fixture = await readFile(fixtureFile, 'utf8') - const prompts = userPrompts(fixture) - expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + const prompts = userPrompts(await readFile(fixtureFile, 'utf8')) + if (scenario.interaction === undefined) { + expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + } const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) const displayCwd = `/tmp/${basename(cwd)}` @@ -310,6 +322,63 @@ async function runScenario(scenario: Scenario): Promise { }) await settleTerminal(terminal) + let interactionSnapshot: string | undefined + if (scenario.interaction === 'skill-invocation-policy') { + terminal.send('/skill') + await settleTerminal(terminal) + const discovery = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(discovery).toContain('user-only-skill') + expect(discovery).not.toContain('model-only-skill') + + terminal.send('\x03') + await settleTerminal(terminal) + const skillContext = ctx + const skillTurnEnded = new Promise((resolve) => { + const detach = skillContext.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + detach() + resolve() + }) + }) + terminal.send('/skill:user-only-skill') + terminal.send('\r') + await skillTurnEnded + await agent.whenIdle() + await settleTerminal(terminal) + const loaded = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(loaded).toContain('USER-ONLY SKILL LOADED') + + terminal.send('/skill:model-only-skill') + terminal.send('\r') + await settleTerminal(terminal) + const denied = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(denied).toContain('model-only-skill') + expect(denied).toContain('not available for user invocation.') + expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD') + interactionSnapshot = [ + '=== skill autocomplete ===', + discovery, + '', + '=== loaded exact invocation ===', + loaded, + '', + '=== denied exact invocation ===', + denied, + ].join('\n') + } + let remainingPrompts = prompts if (scenario.enterPlanMode === true) { const firstPrompt = prompts[0]! @@ -392,7 +461,7 @@ async function runScenario(scenario: Scenario): Promise { } expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([]) - const snapshot = normalizeTerminalSnapshot( + const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot( await terminal.snapshot({ includeScrollback: true }), cwd, displayCwd, diff --git a/knip.json b/knip.json index e9c8f79e8f..e5b7110957 100644 --- a/knip.json +++ b/knip.json @@ -154,6 +154,25 @@ "tests/**/*.ts" ] }, + "packages/core/tools": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/typert/generator": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/type-model/**/*.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/bash/bash-sandbox": { "entry": [ "tests/**/*.spec.ts", diff --git a/lefthook.yml b/lefthook.yml index ab7986ee2c..1a4e004842 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,11 +4,18 @@ pre-commit: jobs: + - name: format (staged) + glob: '*.{ts,tsx,mts,cts,mjs}' + exclude: + - 'vendor/*/src/**' + run: node_modules/.bin/eslint --config eslint.format.config.mjs --fix --no-warn-ignored {staged_files} + stage_fixed: true + - name: lint (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: - 'vendor/*/src/**' - run: node_modules/.bin/eslint --fix {staged_files} + run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true - name: whitespace (staged) diff --git a/package.json b/package.json index a51261a4c2..ecb7215e66 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", - "lint": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js .", - "lint:fix": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --fix", + "lint": "tsx scripts/run-oxlint.ts .", + "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -117,9 +117,10 @@ "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", + "@typescript-eslint/parser": "8.61.0", "@vitest/coverage-v8": "^4.1.8", "@yarnpkg/cli-dist": "4.17.1", - "eslint": "^10.4.1", + "eslint": "10.5.0", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", "fast-check": "^4.8.0", @@ -133,11 +134,12 @@ "mdast-util-gfm": "^3.1.0", "mermaid": "11.16.0", "micromark-extension-gfm": "^3.0.0", + "oxlint": "1.76.0", + "oxlint-tsgolint": "7.0.2001", "publint": "^0.3.21", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8" } diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ba5ab61b06..f36f320355 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d -README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc +README.md: fd5e1e8ec1a0ca426ed717cfa9613c51728c60e1 +README.zh.md: ad4f315171377677a934d8bb02d15c2db96e0e91 diff --git a/packages/README.md b/packages/README.md index 7a86e0f034..fd5e1e8ec1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index bfcba626be..ad4f315171 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -11,6 +11,7 @@ | 组 | 职责 | 发布预期 | |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | | [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3253efbb06..1b47b9072f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -601,7 +601,7 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { const source = event.data?.source if (source?.kind !== 'goal' || source.round !== 0) continue const change = source.change - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (change === undefined || change.kind !== 'goal/change') continue if (change.operation === 'clear') return null return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } diff --git a/packages/client/hmr/src/invariant.ts b/packages/client/hmr/src/invariant.ts index 6eb962efb9..cc875f3e13 100644 --- a/packages/client/hmr/src/invariant.ts +++ b/packages/client/hmr/src/invariant.ts @@ -32,7 +32,7 @@ const install: InvariantInstaller = (ctx, fail) => { const baselines = new WeakMap() // Async listener by design: emitPluginDisposed awaits-and-logs returned // promises, so a violation surfaces loudly instead of unhandled. - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // oxlint-disable-next-line typescript/no-misused-promises ctx.on('internal/plugin', async (fiber) => { if (fiber.name !== 'client-hmr') return if (fiber.uid !== null) { diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 1a73e06872..05f332a9a1 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -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/client/locale/README.md -README.md: 9015af2b44a33771b06863ace139fe97695df616 -README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf +README.md: c2adbcabc77def740094288da4643032873aa5b8 +README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 9015af2b44..c2adbcabc7 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). ## Model Experience @@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. -- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. +- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs. +- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 12205e21bb..c6ecb31e21 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。 +locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 ## 模型体验 @@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 ## 已知限制与暂缓事项 -- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。 -- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。 +- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。 +- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index a824bc6752..febf732792 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -5,23 +5,22 @@ * settings surface. */ import { useState } from 'react' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from './settings-contract.ts' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface LanguageRowInjected { - /** Translate a `settings.locale` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the active locale (a registered locale id). */ setLocale: (id: string) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type LanguageRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.locale'> & LanguageRowInjected /** * Render the Language row. diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index cb6bb6f861..65eeddb9e2 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -4,11 +4,21 @@ * preference row into the settings General section — the locale feature owns * its own settings surface. */ +/* oxlint-disable typescript/no-redundant-type-constituents -- + * `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see + * ui-slots): in THIS unit the map holds only this package's own merges, but + * consumers merge more namespaces in and the intersection keeps them + * string-typed. The rule fires on the narrow-map view, not real redundancy. */ import type { Context } from 'cordis' -import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { + deferRegistration, + type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, +} from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' +import { en, zh, type CommonKey } from '../locales/index.ts' +import { + en as settingsEn, zh as settingsZh, type SettingsLocaleKey, +} from '../locales/settings.ts' import type { LanguageRowInjected } from './LanguageRow.tsx' import { LanguageRow } from './LanguageRow.tsx' import { createLanguageRowStore } from './settings-store.ts' @@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts' export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' +export type { CommonKey } from '../locales/index.ts' -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record) => string +// The translate currency lives in ui-slots (the render machinery synthesizes +// the seat); re-exported here so dictionary owners import one package. +// TranslateNS<'model'> is the namespace-addressed developer-facing form. +export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */ + common: CommonKey + /** This feature's own settings-row copy (the Language row). */ + 'settings.locale': SettingsLocaleKey + } +} /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record @@ -50,7 +72,10 @@ declare module 'cordis' { } interface Events { /** - * Locale state changed (active locale switched or registry updated). + * The active locale switched. Dictionary registrations do NOT emit this + * event (listeners may re-register slots in response, and boot registers + * one namespace per package); continuous render refresh rides the + * LocaleFace revision instead. * @param snapshot - Current immutable locale snapshot. * @mode emit */ @@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ ]) /** - * Dictionary registry plus locale preference. Lookup chain per key: active - * locale -> zh fallback -> the key itself (missing text stays visible, fail - * loud in the UI rather than blank). Reads go through {@link getLocale}; - * writes only through {@link setLocale}; continuous sync only through the - * `locale/change` event. + * Dictionary registry plus locale preference. Lookup chain per key: the + * entry's namespace in the active locale -> that namespace's zh fallback -> + * the shared common namespace (active, then zh) -> the key itself (missing + * text stays visible, fail loud in the UI rather than blank). Reads go + * through {@link getLocale}; writes only through {@link setLocale}; + * continuous sync through the `locale/change` event, or through the + * LocaleFace getSnapshot/subscribe pair the render machinery consumes + * (installed via `ctx.slots.installLocale`). */ export class LocaleService { private dicts = new Map>() private bound = new Map() private snapshot: LocaleSnapshot + private listeners = new Set<() => void>() private readonly ctx: Context /** @@ -105,6 +134,27 @@ export class LocaleService { return this.snapshot } + /** + * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable + * reference between changes, uSES-safe). + * @returns the current snapshot. + */ + getSnapshot(): LocaleSnapshot { + return this.snapshot + } + + /** + * LocaleFace subscribe: notified on every snapshot change (locale switch + * or dictionary registration — registrations bump the revision so already + * rendered outlets pick up late-arriving dictionaries). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn) + return () => { this.listeners.delete(fn) } + } + /** * Switch the active locale — the only preference write entry. Persists the * id and emits `locale/change`. @@ -114,44 +164,80 @@ export class LocaleService { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - this.snapshot = Object.freeze({ - active: match.id, - locales: this.snapshot.locales, - revision: this.snapshot.revision + 1, - }) persistPreference(match.id) - this.ctx.emit('locale/change', this.snapshot) + this.publish(match.id, true) } /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). + * Register a declared namespace's dictionaries, all locales in one call — + * the typed form: each dictionary is checked against the namespace's + * {@link LocaleNamespaceMap} key union (a missing or extra key is a + * compile error), and every shipped locale is required (bilingual balance + * enforced at the seam). Duplicate (ns, locale) throws (single occupant; a + * namespace's texts have one owner). Registration bumps the revision so + * mounted outlets pick up late-arriving dictionaries. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @param dicts - complete dictionaries keyed by locale id. + * @returns disposer removing every locale registered by this call (idempotent). + */ + register(ns: N, dicts: Record>): () => void + /** + * Single-locale untyped form for namespaces outside the merge table + * (dynamic composition, tests). * @param ns - namespace. - * @param locale - locale tag (zh/en to start). + * @param locale - locale tag. * @param dict - dictionary. * @returns disposer (idempotent). */ - register(ns: string, locale: string, dict: LocaleDict): () => void { + register(ns: string, locale: string, dict: LocaleDict): () => void + register(ns: string, localeOrDicts: string | Record, dict?: LocaleDict): () => void { + const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string' + // Overload guarantees dict on the single-locale arm. + ? [[localeOrDicts, dict as LocaleDict]] + : Object.entries(localeOrDicts) let locales = this.dicts.get(ns) if (!locales) { locales = new Map() this.dicts.set(ns, locales) } - if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) + for (const [locale] of pairs) { + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + } + for (const [locale, entries] of pairs) locales.set(locale, entries) + this.publish(this.snapshot.active, false) return () => { const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) + /* v8 ignore next -- defensive: a namespace's locales map is created on + * first register and never removed, so the disposer always finds it. */ + if (!owner) return + let removed = false + for (const [locale, entries] of pairs) { + if (owner.get(locale) === entries) { + owner.delete(locale) + removed = true + } + } + if (removed) this.publish(this.snapshot.active, false) } } /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the active locale at call time). + * Bind a declared namespace to a translate function typed to its + * dictionary key union (plus the shared common vocabulary) — the same key + * domain the framework-injected `t` seat carries. The returned reference + * is stable per namespace (repeat binds return the same function), so it + * can ride inject surfaces without breaking memoization. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @returns the typed translate function (reads the active locale at call time). */ + bind(ns: N): TranslateNS + /** + * Untyped form for namespaces outside the merge table (dynamic + * composition, tests). + * @param ns - namespace. + * @returns the translate function. + */ + bind(ns: string): Translate bind(ns: string): Translate { let t = this.bound.get(ns) if (!t) { @@ -163,14 +249,43 @@ export class LocaleService { } private translate(ns: string, key: string, params?: Record): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.snapshot.active)?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] + const template = this.lookup(ns, key) + ?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined) ?? key if (!params) return template return template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) } + + private lookup(ns: string, key: string): string | undefined { + const locales = this.dicts.get(ns) + return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key] + } + + /** + * Advance the snapshot revision and notify LocaleFace subscribers (render + * refresh). Only an active-locale switch additionally emits + * `locale/change` — dictionary registrations stay off the event so + * registration-heavy boot cannot storm event listeners (which may + * re-register slots in response). + */ + private publish(active: LocaleId, localeChanged: boolean): void { + this.snapshot = Object.freeze({ + active, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + if (localeChanged) this.ctx.emit('locale/change', this.snapshot) + for (const fn of [...this.listeners]) { + try { + fn() + } catch (error) { + // One throwing subscriber must not strand the rest on a stale + // revision (outlets would keep the previous language). + console.error('locale subscriber crashed:', error) + } + } + } } /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ @@ -208,11 +323,12 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) - locale.register(COMMON_NS, 'zh', zh) - locale.register(COMMON_NS, 'en', en) - locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) - locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) + locale.register(COMMON_NS, { zh, en }) + locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) ctx.provide('locale', locale) + // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it + // so the render machinery can synthesize the `t` standard seat. + ctx.slots.installLocale(locale) const store = createLanguageRowStore() let bound: BoundActions | undefined @@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(locale.getLocale()) return { - t: locale.bind(SETTINGS_NS), setLocale: (id) => { locale.setLocale(id) }, } } @@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void { id: 'language', order: 0, store, + locale: SETTINGS_NS, inject: injected, }, LanguageRow)) return () => { deferred.dispose() } diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index f649177ac0..b12965c6f5 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -1,2 +1,29 @@ -/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const en: Record = {} +import type { CommonKey } from './zh.ts' + +/** en base dictionary for the common namespace, checked complete against the zh key set. */ +export const en = { + 'ok': 'OK', + 'cancel': 'Cancel', + 'close': 'Close', + 'copy': 'Copy', + 'copied': 'Copied', + 'retry': 'Retry', + 'loading': 'Loading…', + 'load.failed': 'Failed to load', + 'submit': 'Submit', + 'submitting': 'Submitting…', + 'next': 'Next', + 'previous': 'Previous', + 'skip': 'Skip', + 'delete': 'Delete', + 'edit': 'Edit', + 'save': 'Save', + 'search': 'Search', + 'more': 'More', + 'collapse': 'Collapse', + 'expand': 'Expand', + 'back': 'Back', + 'unknown': 'Unknown', + 'none': 'None', + 'truncated': 'Truncated', +} satisfies Record diff --git a/packages/client/locale/src/locales/index.ts b/packages/client/locale/src/locales/index.ts new file mode 100644 index 0000000000..6ac5335f5b --- /dev/null +++ b/packages/client/locale/src/locales/index.ts @@ -0,0 +1,8 @@ +/** + * The common-namespace dictionary pair. zh is the source of truth for the + * key set (Chinese-first repo convention); en is checked complete against it + * — a missing or extra en key is a compile error. + */ +export { zh } from './zh.ts' +export { en } from './en.ts' +export type { CommonKey } from './zh.ts' diff --git a/packages/client/locale/src/locales/settings.ts b/packages/client/locale/src/locales/settings.ts new file mode 100644 index 0000000000..0419b60095 --- /dev/null +++ b/packages/client/locale/src/locales/settings.ts @@ -0,0 +1,14 @@ +/** `settings.locale` namespace dictionaries (the Language row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'language.title': '语言', +} satisfies Record + +/** The settings.locale namespace key union. */ +export type SettingsLocaleKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'language.title': 'Language', +} satisfies Record diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index f3ff22d2b8..5bb62c4344 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -1,2 +1,30 @@ -/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const zh: Record = {} +/** zh base dictionary for the common namespace: cross-feature standard words. */ +export const zh = { + 'ok': '确定', + 'cancel': '取消', + 'close': '关闭', + 'copy': '复制', + 'copied': '复制成功', + 'retry': '重试', + 'loading': '加载中…', + 'load.failed': '加载失败', + 'submit': '提交', + 'submitting': '正在提交…', + 'next': '下一步', + 'previous': '上一步', + 'skip': '跳过', + 'delete': '删除', + 'edit': '编辑', + 'save': '保存', + 'search': '搜索', + 'more': '更多', + 'collapse': '收起', + 'expand': '展开', + 'back': '返回', + 'unknown': '未知', + 'none': '无', + 'truncated': '已截断', +} satisfies Record + +/** The common vocabulary key union (zh is the key-set source of truth). */ +export type CommonKey = keyof typeof zh diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 25dbdfe239..c603cbc5f0 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -69,16 +69,18 @@ describe('locale apply', () => { // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') - const { instance, face } = faceOf(b.slots) + const { entry, instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().active).toBe('en') expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) - expect(face.t('language.title')).toBe('Language') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(entry.locale).toBe(SETTINGS_NS) + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') face.setLocale('zh') expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') - expect(face.t('language.title')).toBe('语言') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 3f9efaed19..80fe699e34 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -29,6 +29,24 @@ describe('LocaleService', () => { expect(t('missing.key')).toBe('missing.key') }) + it('falls through to the common vocabulary after the namespace misses (production keys)', () => { + const { svc } = make() + // The shipped common pair is registered by apply; the bench registers it + // directly to pin the production chain: ns -> common -> zh -> key. + svc.register('common', 'zh', { retry: '重试' }) + svc.register('common', 'en', { retry: 'Retry' }) + svc.register('ns', 'zh', { own: '自有' }) + const t = svc.bind('ns') + expect(t('retry')).toBe('重试') + svc.setLocale('en') + expect(t('retry')).toBe('Retry') + expect(t('own')).toBe('自有') + // common itself must not recurse: a miss inside common echoes the key. + // (Wide-string ns hits the untyped bind overload — the typed one rejects + // unknown keys at compile time, which is the point of the seam.) + expect(svc.bind('common' as string)('nope')).toBe('nope') + }) + it('interpolates {name} params and leaves unknown placeholders intact', () => { const { svc } = make() svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) @@ -56,6 +74,47 @@ describe('LocaleService', () => { expect(t('k')).toBe('v2') }) + it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => { + const { svc } = make() + const seen: number[] = [] + const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + expect(svc.getSnapshot()).toBe(svc.getLocale()) + const r0 = svc.getSnapshot().revision + svc.register('ns', 'zh', { k: 'v' }) + expect(svc.getSnapshot().revision).toBe(r0 + 1) + svc.setLocale('en') + expect(seen).toEqual([r0 + 1, r0 + 2]) + off() + svc.setLocale('zh') + expect(seen).toHaveLength(2) + }) + + it('isolates a throwing subscriber: the rest still see the new revision', () => { + const { svc } = make() + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const seen: number[] = [] + svc.subscribe(() => { throw new Error('boom') }) + svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + svc.setLocale('en') + expect(seen).toEqual([1]) + expect(spy).toHaveBeenCalledOnce() + } finally { + spy.mockRestore() + } + }) + + it('register disposer republishes (mounted outlets drop the dead dictionary)', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v' }) + const before = svc.getSnapshot().revision + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + // Second run hits the idempotent arm: nothing removed, no republish. + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + }) + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() svc.setLocale('en') diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index d18377e052..aeb7100a03 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -9,7 +9,7 @@ * with the last holding entry, session instances cleared (with persisted * state) on scope death. */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- +/* oxlint-disable typescript/no-redundant-type-constituents -- * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only * holds this package's 'root' row in this compilation unit, but consumers * merge keys in; the rule fires on the narrow-map view, not on real @@ -18,7 +18,7 @@ import { Service } from 'cordis' import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { - OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, + LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' @@ -70,6 +70,8 @@ interface ErasedRegisterOptions { select?: (owner: never) => unknown /** Chain-slot explicit ordering override (ascending; registration order otherwise). */ priority?: number + /** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */ + locale?: string registrant?: string } @@ -82,6 +84,7 @@ export class SlotsService extends Service { /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ private readonly _stores = new Map() private _renderer: SlotRenderer | undefined + private _locale: LocaleFace | undefined private _host: SlotRendererHost | undefined /** @@ -127,6 +130,23 @@ export class SlotsService extends Service { }, 'slots.install()') } + /** + * Install the locale face backing the `t` standard seat (the locale + * plugin's product; same boot-once discipline as the renderer install). + * Runs through the caller's ctx.effect, so the installing fiber's unload + * uninstalls the face. + * @param face - namespace binder + revision observable. + */ + installLocale(face: LocaleFace): void { + if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)') + this.ctx.effect(() => { + this._locale = face + return () => { + if (this._locale === face) this._locale = undefined + } + }, 'slots.installLocale()') + } + /** * The single ctx-level render entry: the shell renders 'root'; every other * key renders inside components through the props renderSlot face. All @@ -246,6 +266,12 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } + // `locale` is a live getter: the face installs (and, under HMR, swaps) + // on the locale plugin's own fiber lifetime, while this host object is + // built once — a captured value would strand renders on a dead face. The + // alias is required: `this` inside the getter is the host literal. + // oxlint-disable-next-line typescript/no-this-alias + const service = this this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -259,6 +285,7 @@ export class SlotsService extends Service { provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, + get locale() { return service._locale }, } return this._host } @@ -310,6 +337,6 @@ export class SlotsService extends Service { // The core's overloads proved the shares; the implementation works on // the erased view (same pattern as the core's own implementation arm). const options = rawOptions as ErasedRegisterOptions - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return this.ctx.effect(() => this['_register'](options, component), 'slots.register()') } diff --git a/packages/client/runtime/src/invariant.ts b/packages/client/runtime/src/invariant.ts index 8c0ac292ab..2b055ede0b 100644 --- a/packages/client/runtime/src/invariant.ts +++ b/packages/client/runtime/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- +/* oxlint-disable typescript/no-redundant-type-constituents -- * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty * in this compilation unit (intersection reads `never`) but consumers merge * keys in; the rule fires on the empty-map view, not on real redundancy. */ diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 54ec218765..7fd6934827 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -286,3 +286,78 @@ describe('WorkspacesService', () => { await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) }) + +describe('startInitialSelection', () => { + function bench() { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + return { api, sessions, workspaces } + } + + it('connects the recent Workspace blank session once baselines are ready and opens it', async () => { + const b = bench() + const stop = b.workspaces.startInitialSelection() + // Nothing happens before both baselines land. + expect(b.api.callsOf('session.create')).toHaveLength(0) + + b.api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], + })) + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') })) + await b.workspaces.refresh() + await b.sessions.refresh() + // Store notifications and the connect round trip are microtask-batched. + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }]) + expect(b.sessions.list.getSnapshot().current).toBe('s-new') + stop() + }) + + it('stays idle when a session is already current or no recent Workspace exists', async () => { + const withCurrent = bench() + withCurrent.api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[], + })) + await withCurrent.sessions.refresh() + withCurrent.sessions.open(sid('s1')) + withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] })) + const stopCurrent = withCurrent.workspaces.startInitialSelection() + await withCurrent.workspaces.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(withCurrent.api.callsOf('session.create')).toHaveLength(0) + stopCurrent() + + const noRecent = bench() + const stopEmpty = noRecent.workspaces.startInitialSelection() + await noRecent.workspaces.refresh() + await noRecent.sessions.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(noRecent.api.callsOf('session.create')).toHaveLength(0) + expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/) + stopEmpty() + }) + + it('a failed connect returns to waiting and retries on the next list change', async () => { + const b = bench() + b.api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], + })) + b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} })) + const stop = b.workspaces.startInitialSelection() + await b.workspaces.refresh() + await b.sessions.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toHaveLength(1) + expect(b.sessions.list.getSnapshot().current).toBeUndefined() + + // Recovery: the next workspace-list change re-runs the reconcile. + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') })) + await b.workspaces.refresh() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(b.api.callsOf('session.create')).toHaveLength(2) + expect(b.sessions.list.getSnapshot().current).toBe('s-retry') + stop() + }) +}) diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 1b70e10d79..987039b385 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -10,7 +10,7 @@ * machinery — everything mounts the production implementations. * @module @deepseek-ai/dsh-client-test-runtime */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- +/* oxlint-disable typescript/no-redundant-type-constituents -- * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): * this compilation unit sees only the runtime's 'root' row, but consumer * programs merge their own keys in; the rule fires on the narrow-map view. */ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 59dd7d9186..c43a25b777 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -237,6 +237,20 @@ export class TestSessions implements ISessions { await this.stabilize(() => { record.snapshot.update(mutate) }) } + /** + * Update a session's list row (the wire-echo stand-in: title settles, + * running flips — components subscribed via useSessions re-render). + * @param id - session id. + * @param patch - summary fields to merge over the row. + */ + async updateSummary(id: string, patch: Partial>): Promise { + const record = this.require(id) + record.summary = { ...record.summary, ...patch } + await this.stabilize(() => { + this.list.update((draft) => { draft.byId[id as SessionId] = record.summary }) + }) + } + /** * Switch the current selection (undefined = the no-session empty state). * @param id - session id to select, or undefined to clear. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 31a714a655..f4419555ee 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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/client/ui-conversation/README.md -README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f -README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e +README.md: 09adb3fd7504b6e79402e3eb220ec474d76c182f +README.zh.md: d92f2ca72764faae767f0f4892037af2c574d8de diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5e24e4aad5..09adb3fd75 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ None; this package neither assembles nor sends a provider request. - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 89a34041e1..d92f2ca727 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index d988cf52f8..8fdba2baa0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -34,11 +34,3 @@ /* Optical align with 28px icon hit targets that pad 6px past the glyph. */ margin-left: -6px; } - -/* Hover-capable pointers: reveal shared actions on root hover/focus. */ -@media (hover: hover) { - .root:hover .actions, - .root:focus-within .actions { - opacity: 1; - } -} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index e5a89a9e88..904aeee0d8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,7 +4,8 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized nodes append IconActions (copy / branch / clock) once streaming ends. +// Finalized content (text) nodes append IconActions once streaming ends; +// Think / tool-head-only nodes stay chrome-free. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -38,6 +39,11 @@ function copyText(blocks: readonly AssistantBlock[]): string { return parts.join('') } +/** True when the node has model-visible text content worth chrome under. */ +function hasContentText(blocks: readonly AssistantBlock[]): boolean { + return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + /** Reasoning block as the Think variant summary row (figma 39:28304). */ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( @@ -64,8 +70,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null - // Footer only after the turn settles with a known event time; streaming omits it. - const showActions = !streaming && time !== undefined + // Footer only under settled content text; Think-only / streaming omit it. + const showActions = !streaming && time !== undefined && hasContentText(blocks) return (
diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index 30d6920609..b247b7e2bf 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -1,5 +1,5 @@ /* Shared message IconActions row (user + assistant). Parent modules own - hover-reveal selectors and layout offsets via the composed className. */ + layout offsets via the composed className. Always visible when mounted. */ .actions { display: flex; @@ -25,14 +25,6 @@ white-space: nowrap; } -/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */ -@media (hover: hover) { - .actions { - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); - } -} - .action { display: inline-flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 7579a4c249..fc76cfb753 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -18,7 +18,7 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** When true, append the stub edit control (user bubble). */ edit?: boolean | undefined - /** Parent layout / hover-reveal class composed onto the actions row. */ + /** Parent layout class composed onto the actions row. */ className?: string | undefined } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 260382d530..2667024bcd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,14 +20,6 @@ color: var(--dsw-alias-label-primary); } -/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */ -@media (hover: hover) { - .userRow:hover .actions, - .userRow:focus-within .actions { - opacity: 1; - } -} - .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index cf376473d0..b005b8404b 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -8,7 +8,7 @@ export async function writeClipboard(text: string): Promise { // lib.dom types clipboard non-optional, but insecure contexts omit it — // that runtime gap is exactly what this guard detects. - /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ + /* oxlint-disable-next-line typescript/no-unnecessary-condition */ if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text) @@ -19,7 +19,7 @@ export async function writeClipboard(text: string): Promise { } // execCommand('copy') is the only clipboard fallback where the async API // is missing (insecure contexts); deprecated but deliberately retained. - /* eslint-disable @typescript-eslint/no-deprecated */ + /* oxlint-disable typescript/no-deprecated */ const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined @@ -36,7 +36,7 @@ export async function writeClipboard(text: string): Promise { } catch { // Clipboard unavailable; the button stays idle. } - /* eslint-enable @typescript-eslint/no-deprecated */ + /* oxlint-enable typescript/no-deprecated */ el.remove() } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 24359965f5..22f645f0ce 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -105,7 +105,7 @@ export function InputBar({ // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. - // eslint-disable-next-line @typescript-eslint/no-deprecated + // oxlint-disable-next-line typescript/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() @@ -165,8 +165,8 @@ export function InputBar({ if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) - // selectionStart is number|null in lib.dom; the eslint program narrows it. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. + // oxlint-disable-next-line typescript/no-unnecessary-condition keyboard.track(next, e.target.selectionStart ?? next.length) } @@ -178,13 +178,13 @@ export function InputBar({ // too (one char = one step). Mouse selection of a chip is handled in the // backdrop click handler below. Undo/redo must NOT reach the browser: the // machine owns the transaction log. - // selectionStart/End are number|null in lib.dom; the eslint program narrows them. - /* eslint-disable @typescript-eslint/no-unnecessary-condition */ + // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. + /* oxlint-disable typescript/no-unnecessary-condition */ const selectionOf = (el: HTMLTextAreaElement) => ({ start: el.selectionStart ?? 0, end: el.selectionEnd ?? el.selectionStart ?? 0, }) - /* eslint-enable @typescript-eslint/no-unnecessary-condition */ + /* oxlint-enable typescript/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { const el = e.currentTarget diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx new file mode 100644 index 0000000000..5d7f4c05e7 --- /dev/null +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/** + * Assembly-level acceptance on SlotTestRuntime (real apply, real slot + * machinery, real renderer; data fed as fixtures) for surfaces that were + * previously pinned only by the assembled-app jsdom snapshots + * (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts): + * + * - the todo_write turn reaches BOTH surfaces through the product + * registrations (keyed toolview row in the flow, plan strip in the input + * dock via the 'todos' projection) and the strip follows projection + * retirement; + * - the bash keyed row carries its resident terminal card, and the fallback + * row reaches the same card through its expand control; + * - the resident composer textarea survives the blank→active conversion as + * the SAME DOM node (focus/IME continuity rides React reconciliation: + * component identity + tree position, which this assembled tree pins). + * + * Component-level behavior (collapse interaction, card model arms, summary + * derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this + * suite only proves the assembled wiring. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' + +const SID = 's1' as SessionId + +afterEach(cleanup) +beforeEach(() => { + localStorage.clear() +}) + +const TODOS: TodoItem[] = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, +] + +const todoResult = (seq: number): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`, + call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) + +const bashResult = (seq: number, callId: string, over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: seq * 1_000 - 500, + content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false, + callView: { card: 'terminal', title: 'ls -la', description: 'List files' }, + resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 }, + ...over, +}) + +/** Test-owned AppFrame role: declares and renders the resident conversation area. */ +type AppRootProps = PropsRenderSlots<'conversation' | 'details'> +function AppRoot({ renderSlot }: AppRootProps) { + return <>{renderSlot('conversation', {})} +} + +const LAYOUT_CHILDREN = { + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, +} as const + +async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, + snapshot: { + nodes, + ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}), + }, + session: { + loadOlder: vi.fn(), + prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), + }, + }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + return runtime +} + +describe('todo_write assembly (product registrations, no outlet twins)', () => { + it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => { + const runtime = await bench([todoResult(3)]) + // The dock strip reads the host-computed 'todos' projection. + runtime.sessions.behavior(SID).projections.set('todos', TODOS) + const view = runtime.renderRoot() + + // Keyed toolview registration took the row (summary derived from args). + const row = view.container.querySelector('[data-sample="todo-row"]') + expect(row).not.toBeNull() + expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本') + + // The plan strip sits in the input dock, fed by the projection + // (default-collapsed: the header summary shows; rows appear on expand). + const panel = view.container.querySelector('[data-testid="todo-panel"]') + expect(panel).not.toBeNull() + expect(panel!.textContent).toContain('1/3 tasks · 1 in progress') + fireEvent.click(panel!.querySelector('button')!) + expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status'))) + .toEqual(['completed', 'in_progress', 'pending']) + + // Next turn retires the standing plan (host pushes null): the strip + // clears while the historical row stays in the flow. + await runtime.flush() + runtime.sessions.behavior(SID).projections.set('todos', null) + await waitFor(() => { + expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull() + }) + expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull() + await runtime.dispose() + }) +}) + +describe('terminal card assembly', () => { + it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => { + const runtime = await bench([ + bashResult(3, 'c-keyed'), + // An unregistered tool with terminal views: GenericToolCard fallback. + bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }), + ]) + const view = runtime.renderRoot() + + // Keyed BashRow renders the card residently (no expand gesture). + const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement + expect(keyed?.querySelector('[data-terminal]')).not.toBeNull() + + // Fallback row: card appears only after its expand control. + const fallback = view.container.querySelector('[data-tool="fx-bash"]') + expect(fallback).not.toBeNull() + expect(fallback!.querySelector('[data-terminal]')).toBeNull() + fireEvent.click(fallback!.querySelector('button[aria-expanded]')!) + await waitFor(() => { + expect(fallback!.querySelector('[data-terminal]')).not.toBeNull() + }) + await runtime.dispose() + }) +}) + +describe('resident composer', () => { + it('renders the locked view state while no session exists at all', async () => { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + // No session entity: the inert twin renders (disabled textarea), and the + // workspace picker chip is the only live control. + const textarea = view.container.querySelector('textarea') + expect(textarea).not.toBeNull() + expect(textarea!.disabled).toBe(true) + expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy() + await runtime.dispose() + }) + + + it('the textarea survives the blank→active conversion as the same DOM node', async () => { + const runtime = await bench([], { blank: true }) + // The hero renders the LIVE composer only when the blank session's + // workspace resolves a chip title; an ownerless blank session shows the + // disabled twin instead (deleted-workspace semantics). + await runtime.workspaces.update((draft) => { + draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never + }) + const view = runtime.renderRoot() + const hero = view.container.querySelector('textarea') + expect(hero).not.toBeNull() + expect(hero!.disabled).toBe(false) + + // First acceptance: the session leaves blank and the composer docks. + await runtime.sessions.updateSnapshot(SID, (draft) => { + draft.blank = false + draft.composerPhase = 'active' + }) + const docked = view.container.querySelector('textarea') + expect(docked).toBe(hero) + await runtime.dispose() + }) +}) + +describe('prompt rejection through the assembled composer', () => { + it('renders the promptError alert strip and keeps the draft in the machine', async () => { + const runtime = await SlotTestRuntime.create() + runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) + const prompt = vi.fn(async () => ({ + ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, + session: { prompt, loadOlder: vi.fn() }, + }) + await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + + const composer = view.container.querySelector('textarea')! + fireEvent.change(composer, { target: { value: 'do not lose this' } }) + fireEvent.keyDown(composer, { key: 'Enter' }) + await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() }) + + // The rejection lands in snapshot.promptError (the Session's own path); + // the fixture mirrors that hop — the assembled InputBar renders it. + await runtime.sessions.updateSnapshot(SID, (draft) => { + draft.promptError = { + op: 'send', + error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + } + }) + const alert = await view.findByRole('alert') + expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)') + // Failure restore: the machine returned the draft to the same textarea. + await waitFor(() => { + expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this') + }) + await runtime.dispose() + }) +}) + +describe('title projection across assembled surfaces', () => { + it('one summary update re-labels the breadcrumb and document.title consumers together', async () => { + const runtime = await bench([]) + const view = runtime.renderRoot() + // The strict session header breadcrumb reads useSessions ancestry. + const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement) + expect(crumb.getByText('S')).toBeTruthy() + + await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' }) + await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() }) + expect(crumb.queryByText('S')).toBeNull() + await runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9bb6ba539a..c9d92aafec 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -177,7 +177,7 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) - it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => { + it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -199,6 +199,17 @@ describe('small branch tails', () => { expect(writeText).toHaveBeenCalledWith('answer body') settled.unmount() + const thinkOnly = render( + , + ) + expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull() + expect(thinkOnly.queryByText('14:24')).toBeNull() + thinkOnly.unmount() + const streaming = render( , ) diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index a4d412dffd..2e9a199805 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -36,6 +37,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", @@ -49,6 +51,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 6e4aa3be11..4cc2687832 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client- import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' @@ -34,10 +35,13 @@ interface EffortChoice { /** * Render the composer model seat. - * @param props - owner share (locked) + injected face (shared directory store/verbs). + * @param props - owner share (locked) + injected face (shared directory + * store/verbs) + the standard locale seat. * @returns the trigger and, while open, the two-level menu. */ -export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { +export function ModelSelect( + { locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>, +) { const state = useSyncExternalStore( fn => directory.subscribe(fn), () => directory.getSnapshot(), @@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje const effortLabel = reasoning === undefined ? undefined : effectiveEffort === undefined - ? 'Provider default' + ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort const effortChoices = useMemo(() => reasoning === undefined ? [] : [ ...reasoning.defaultEffort === undefined - ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }] + ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }] : [], ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({ key: `effort:${effort.id}`, @@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje label: effort.name, ...effort.description === undefined ? {} : { description: effort.description }, })), - ], [reasoning]) + ], [reasoning, t]) const busy = state.status === 'selecting' // Mount-time load resolves the trigger label; every open refreshes. @@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` itemRefs.current = [] let itemIndex = 0 @@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje ref={triggerRef} type="button" className={css.trigger} - aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`} + aria-label={effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje id={`${id}-menu`} className={css.menu} role="menu" - aria-label="模型与推理等级" + aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} > {pane === 'root' && ( <> {reasoning !== undefined && ( @@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {pane === 'model' && ( <> {state.status === 'loading' && ( -
正在刷新模型列表…
+
{t('status.loading')}
)} {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {state.failures.map(failure => (
- {failure.name} 加载失败:{failure.message} - + {t('warning.groupLoad', { name: failure.name, message: failure.message })} +
))}
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {model.description} )} {model.unlisted === true && ( - 当前模型 · 未列入目录 + {t('option.currentUnlisted')} )} @@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje })}
{state.status === 'ready' && choices.length === 0 && ( -
没有可用的模型。
+
{t('empty.models')}
)} )} @@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje <> {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {effortChoices.length === 0 - ?
当前模型未提供推理等级。
+ ?
{t('empty.efforts')}
: effortChoices.map(level => (
{index + 1} / {questions.length} )} {draft.customOpen && ( @@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { value={draft.custom} disabled={busy !== null} rows={2} - placeholder="输入你的答案" + placeholder={t('custom.placeholder')} onChange={(event) => { const value = event.target.value updateDraft(current => ({ @@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
-
{error}
+
{error === null ? null : 'key' in error ? t(error.key) : error.text}
diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index e3c3e815bf..54e87c016d 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * cancelled error encoding, receipt checks — lives HERE, with the package * that consumes it. */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Also pulls ui-conversation's SlotMap merge (the 'conversation.composer' // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -70,8 +70,9 @@ export class PendingQuestion { /** * Full component props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the question carrier. No injected - * share: the carrier plus the domain face above carry the whole behavior - * surface. + * selector result, already narrowed to the question carrier — plus the + * standard locale seat; the carrier plus the domain face above carry the + * whole behavior surface. */ -export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait } +export type QuestionComposerProps = + PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'> diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 328fa6c6ce..63f7517c3a 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -1,18 +1,32 @@ /** * Web question plugin, browser half: QuestionComposer registered as a - * selector-routed entry of the conversation-declared composer chain. Pure - * consumer — the selector narrows the owner's currency to the question - * carrier (matched prop), and the whole behavior surface rides the carrier - * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no - * service dependency beyond slots. Export discipline: packages/client/AGENTS.md. + * selector-routed entry of the conversation-declared composer chain, plus the + * `question` dictionaries. The selector narrows the owner's currency to the + * question carrier (matched prop), and the whole behavior surface rides the + * carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides + * the standard locale seat. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { QuestionWait } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' +import { en, zh, type QuestionKey } from './locales.ts' export { PendingQuestion } from './contract/slots.ts' export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts' +export type { QuestionKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The question composer's copy. */ + question: QuestionKey + } +} + +/** Dictionary namespace owned by this plugin. */ +const NS = 'question' /** * Required services (cordis fiber inject). 'conversation' is an ordering @@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont * declared by ui-conversation's apply, and register() into an undeclared * slot throws — service waiting orders this apply after the declaring one. */ -export const inject = ['slots', 'conversation'] +export const inject = ['slots', 'conversation', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu } /** - * Client plugin body: register the question composer into the composer chain. - * Zero business face — data and verbs both live on the matched carrier. + * Client plugin body: register the `question` dictionaries and the question + * composer into the composer chain. Zero business face — data and verbs live + * on the matched carrier; t rides the standard locale seat. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const slots = ctx.slots + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') + ctx.effect( - () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer), + () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + ), 'ui-question: composer chain registration', ) } diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts new file mode 100644 index 0000000000..95465f4af2 --- /dev/null +++ b/packages/client/ui-question/src/client/locales.ts @@ -0,0 +1,34 @@ +/** `question` namespace dictionaries. */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'error.incomplete': '请先完成这道问题。', + 'error.unanswered': '请选择一个选项或填写自定义答案。', + 'title.multi': '可多选', + 'nav.prev': '上一题', + 'nav.next': '下一题', + 'nav.cancel': '放弃整组问题', + 'option.recommended': '推荐', + 'option.custom': '其他,请填写自定义答案', + 'custom.placeholder': '输入你的答案', + 'action.skip': '跳过本题', + 'action.next': '下一题', +} satisfies Record + +/** The question namespace key union. */ +export type QuestionKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'error.incomplete': 'Please complete this question first.', + 'error.unanswered': 'Please select an option or enter a custom answer.', + 'title.multi': 'Multi-select', + 'nav.prev': 'Previous question', + 'nav.next': 'Next question', + 'nav.cancel': 'Dismiss all questions', + 'option.recommended': 'Recommended', + 'option.custom': 'Other — enter a custom answer', + 'custom.placeholder': 'Type your answer', + 'action.skip': 'Skip this question', + 'action.next': 'Next', +} satisfies Record diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 832da15318..0acc7fac82 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { apply, inject } from '../src/client/index.ts' @@ -24,12 +25,13 @@ async function bench() { // 'conversation' inject is an ordering edge (the declaring plugin provides // it after declaring the chain); the bench declares the chain itself. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation']) + expect(inject).toEqual(['slots', 'conversation', 'locale']) }) it('fails loud when no live entry has declared the composer slot', async () => { @@ -38,6 +40,7 @@ describe('apply', () => { // Satisfy the ordering inject without declaring the chain: apply must // then hit the undeclared-slot throw, not sit waiting on the service. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) await expect(ctx.plugin({ inject: [...inject], apply })) .rejects.toThrow(/slot "conversation.composer" is not declared/) }) @@ -47,8 +50,10 @@ describe('apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const entry = slots.entries('conversation.composer')[0]! expect(entry.component).toBe(QuestionComposer) - // The whole behavior surface rides the matched carrier: no business face. + // The whole behavior surface rides the matched carrier: no business face; + // copy rides the standard locale seat. expect(entry.inject).toBeUndefined() + expect(entry.locale).toBe('question') // The selector narrows the chain currency: question wait in → that wait; none → null. const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown const question = { kind: 'question' } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 7df9f2bde9..4a8983571c 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -8,10 +8,12 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { PendingQuestion } from '../src/client/contract/slots.ts' +import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' +import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' afterEach(cleanup) @@ -28,6 +30,11 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + // The seat's key domain is question ∪ common; the stub mirrors the real + // lookup chain: package dictionary, then common vocabulary, then the key. + t: (key => (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 4c4138b80d..6b5b0acc3a 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7d85b111cf..7bff9523ed 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -25,7 +25,8 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-layout" + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-locale" ], "platform": "web" }, @@ -38,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -46,6 +48,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 30d705c780..f7b83c29b6 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -32,6 +32,7 @@ export function SidebarRoot({ width, startSession, toggleSidebar, + t, renderSlot, }: SidebarRootComponentProps) { // Wide content stays mounted while the collapse animates (fading via @@ -67,7 +68,7 @@ export function SidebarRoot({
- + diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4362b7d071..dea4fea6c9 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.settings` registrant's (ui-settings). */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -68,7 +68,9 @@ export type SidebarRootInjected = { /** * Full component props: layout owner state/actions plus the declared holes' - * render shares and this package's injected callbacks. No store is registered. + * render shares, this package's injected callbacks, and the standard locale + * seat. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> + & SidebarRootInjected & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 061f587dbd..3d7ed23aa4 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,17 +1,33 @@ /** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' +import { en, zh, type SidebarKey } from './locales.ts' export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' +export type { SidebarKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Sidebar shell controls copy. */ + sidebar: SidebarKey + } +} + +/** Dictionary namespace owned by this plugin (shell controls copy). */ +const NS = 'sidebar' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] /** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') + const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action // (recent-Workspace targeting; explicit Workspace wins for scoped actions). @@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'sidebar', + locale: NS, // The shell owns geometry; ui-workspace registers the whole browsing // region (header, search, session list, workspace dialogs), ui-settings // registers the foot trigger + settings panel. diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts new file mode 100644 index 0000000000..8cf5ac6d7b --- /dev/null +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'session.new': '新会话', + 'session.new.label': '新建会话', + 'toggle.open': '打开侧边栏', + 'toggle.collapse': '收起侧边栏', +} satisfies Record + +/** The sidebar namespace key union. */ +export type SidebarKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'session.new': 'New Session', + 'session.new.label': 'New session', + 'toggle.open': 'Open sidebar', + 'toggle.collapse': 'Collapse sidebar', +} satisfies Record diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index c21cd5a53c..ccd997be76 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -2,6 +2,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -14,6 +15,7 @@ async function bench(declare = true) { ctx.provide('layout', layout) ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) + ctx.provide('locale', new LocaleService(ctx)) const slots = ctx.get('slots') as SlotsService if (declare) { slots.register( @@ -26,7 +28,7 @@ async function bench(declare = true) { describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale']) }) it('registers the shell and declares the browsing-region hole', async () => { @@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + // Copy rides the standard locale seat, not the inject face. + expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) // Both arms delegate to the runtime's shared New Session action. diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 3c8086e4ce..925c18f8f3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' +import { en } from '../src/client/locales.ts' + +// English-dictionary translate stub: the shell renders the same copy the +// assertions below query by accessible name. +const t: SidebarRootComponentProps['t'] = key => (en as Record)[key] ?? key afterEach(() => { cleanup() @@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w { if (key === 'sidebar.settings') { settingsOwner = owner diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx index d9b1bcd473..2145b02f9e 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' afterEach(cleanup) @@ -18,6 +19,12 @@ afterEach(cleanup) async function bench() { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { toggleSidebar: vi.fn() }) + // English locale pins the snapshots to the copy they were recorded with; + // the installed face backs the entry's standard `t` seat. + const locale = new LocaleService(runtime.ctx) + locale.setLocale('en') + runtime.provide('locale', locale) + runtime.slots.installLocale(locale) await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } }) await runtime.mount({ inject: [...inject], apply }) return runtime diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index c48fe2567f..d976720dbd 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-layout" }, + { + "path": "../locale" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 3481eb7d7d..cc9be46bca 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -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/client/ui-skill/README.md -README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7 -README.zh.md: ed582128246a62297f555f8abe09f427cb9d256a +README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea +README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 4838be893c..2cb382f534 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `name` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `name` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index ed58212824..2fc30da5e4 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7b980571ef..e79486bd1b 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -8,7 +8,7 @@ * consumer `declare module` augmentation merges with declarations lexically in * the augmented module, not with re-exports. */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- +/* oxlint-disable typescript/no-redundant-type-constituents -- * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty * in THIS compilation unit (so the intersection reads as `never`), but every * consumer merges keys in and the intersection is what keeps them string-typed. @@ -24,6 +24,67 @@ export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} +/** + * Locale namespace table. Dictionary owners extend via declaration merging + * (exactly like {@link SlotMap}, and declared in this entry module for the + * same lexical-merge reason): the key is the namespace string, the value is + * the union of its dictionary keys. Register sites declare one of these + * namespaces (`locale:`), which puts the typed `t` standard seat on the + * component props. + */ +export interface LocaleNamespaceMap {} + +/** + * Translate a dictionary key with optional `{name}` template params. + * `K` narrows the accepted keys to the owning namespace's dictionary union + * (plus the shared common vocabulary where composed). + */ +export type Translate = + (key: K, params?: Record) => string + +/** + * The shared `common` vocabulary keys as merged by the locale plugin; + * resolves to `never` in programs without the merge (this package's tests), + * keeping the union collapse harmless. + */ +export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never + +/** + * Key domain of a namespace-bound translate: the namespace's own dictionary + * union plus the shared common vocabulary (the lookup chain consults common + * after the namespace misses). + */ +export type LocaleKeysOf = + (LocaleNamespaceMap[N] & string) | CommonKeyOf + +/** + * Namespace-addressed translate — the developer-facing alias over + * {@link Translate}: `TranslateNS<'model'>` is the translate function of the + * `model` namespace (key domain = its dictionary union plus the shared + * common vocabulary), the exact type of the framework-injected `t` seat and + * of the locale service's typed `bind`. + */ +export type TranslateNS = Translate> + +/** + * Dictionary shape for a declared namespace: exactly the keys the namespace + * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed + * registration site is a compile error. + */ +export type LocaleDictOf = + Record + +/** + * Locale share of the composed component props: the framework-injected `t` + * seat, present exactly on entries whose registration declares `locale:`. + */ +export type PropsLocale = N extends keyof LocaleNamespaceMap & string + ? { + /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */ + t: TranslateNS + } + : object + /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' @@ -244,10 +305,11 @@ export type InjectFace = I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I /** - * The four-share component props intersection: runtime share (SlotMap) + + * The composed component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + * the registrant's injected business face (its hooks compartment bound, see - * {@link InjectFace}). Each share derives from its single source of truth; + * {@link InjectFace}) + the locale `t` seat (declared namespace, see + * {@link PropsLocale}). Each share derives from its single source of truth; * components reference this composition, never re-type it. */ export type ComposedProps< @@ -256,7 +318,8 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare + N = undefined, +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -303,13 +366,20 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ children?: D /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */ store?: H + /** + * Dictionary namespace of this entry's copy. Declaring it puts the + * framework-synthesized `t` seat (typed to the namespace's dictionary + * union) on the component props; rendering requires an installed locale + * face — fails loud otherwise. + */ + locale?: N /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string } & KindOptions @@ -330,6 +400,8 @@ export interface StoredEntry { children?: Readonly>> | undefined /** Declared store seat (instance resolution and lifecycle live with the host machinery). */ store?: StoreDecl | undefined + /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */ + locale?: string | undefined /** Diagnostics label of who registered. */ registrant?: string | undefined } @@ -350,7 +422,8 @@ interface ErasedOptions { priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined - /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- + locale?: string | undefined + /* oxlint-disable-next-line typescript/no-explicit-any -- * implementation-signature position only (both public overloads type inject * exactly); `never[]` would fail overload-to-implementation compatibility * against the per-declaration InjectParams tuples. */ @@ -427,16 +500,20 @@ export class SlotCore { * @returns disposer removing the registration and its declarations * (idempotent; stale disposers after a cascade are no-ops). */ + /* jscpd:ignore-start -- the two register overloads are deliberately + * parallel declarations differing only in the inject share; folding them + * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -455,13 +532,15 @@ export class SlotCore { const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void + /* jscpd:ignore-end */ register(options: ErasedOptions, component: unknown): () => void { const rec = this.records.get(options.name) if (!rec?.spec) { @@ -523,6 +602,7 @@ export class SlotCore { ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.locale !== undefined ? { locale: options.locale } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 1e180eff7d..3bcb864a9d 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -1,6 +1,31 @@ /** React-free contracts between the slot host and an installed renderer. */ import type { ReactNode } from 'react' -import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts' +import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts' + +/** + * The locale face the render machinery consumes: namespace binding plus an + * observable revision (getSnapshot/subscribe pair — the same HostObservable + * currency as every other standard-kit source). The revision moves on every + * active-locale or registry change; the renderer re-derives each entry's `t` + * from (namespace, revision), so a locale switch hands out NEW function + * references and memoized components re-render naturally. Implemented by the + * locale plugin, installed through the runtime SlotsService (installLocale). + * Install before the first render that needs the seat: outlets bind their + * revision subscription at mount, and a face appearing later has no channel + * to notify already-mounted outlets (the locale plugin is immediately-tier + * infrastructure, so normal compositions install during boot). + */ +export interface LocaleFace extends HostObservable<{ revision: number }> { + /** + * Bind a namespace to a translate function reading the active locale at + * call time. Identity may be stable per namespace — freshness of rendered + * text is carried by the renderer's (ns, revision) seat derivation, not by + * this binding. + * @param ns - dictionary namespace. + * @returns the namespace-bound translate function. + */ + bind(ns: string): Translate +} /** Minimal observable surface for host-provided standard-kit data sources. */ export interface HostObservable { @@ -128,6 +153,12 @@ export interface SlotRendererHost { /** Workspace list source backing the useWorkspaces standard hook. */ list: HostObservable } + /** + * Installed locale face backing the `t` standard seat (absent until the + * locale plugin installs one; rendering an entry that declared `locale:` + * without it is an assembly failure). + */ + locale?: LocaleFace | undefined } /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */ diff --git a/packages/client/ui-slots/src/store.ts b/packages/client/ui-slots/src/store.ts index 3670f9fc3f..2e5fdae419 100644 --- a/packages/client/ui-slots/src/store.ts +++ b/packages/client/ui-slots/src/store.ts @@ -21,7 +21,7 @@ export type MaybeSnapshotSelectorHook = * declared as the store's complete write set (the audit face — components can * only write through these). */ -/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- +/* oxlint-disable-next-line typescript/no-explicit-any -- * any[] (not unknown[]): each action carries its own parameter list, and * unknown[] would reject every concrete signature under strict parameter * contravariance. Params are re-inferred per action by BakedActions. */ @@ -95,14 +95,14 @@ export interface StoreHandle> { * Exclusive-store registration form: the registrant passes the factory itself * and the framework calls it per entry x scope (no shared identity exists). */ -/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- +/* oxlint-disable-next-line typescript/no-explicit-any -- * erased position accepting every StoreHandle instantiation; T/A are * recovered per use site by conditional inference (HandleOf/BoundActions/ * PropsStore). */ export type StoreFactory = () => StoreHandle /** The register `store` option position: a shared handle or an exclusive factory. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above). +// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above). export type StoreDecl = StoreHandle | StoreFactory /** Normalize a store declaration to its handle type (factories yield their return). */ diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index b4aad1725d..a0e04b67a6 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -9,26 +9,26 @@ import clsx from 'clsx' import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' +import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface AppearanceRowInjected { - /** Translate a `settings.theme` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the theme preference. */ setTheme: (id: ThemePreference) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type AppearanceRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index dd11c98f06..133436c693 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -14,13 +14,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { en, zh, type ThemeKey } from './locales.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemeKey } from './locales.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The Appearance settings row's copy. */ + 'settings.theme': ThemeKey + } +} + /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -228,23 +237,7 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => { - const disposers = [ - ctx.locale.register(SETTINGS_NS, 'zh', { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }), - ctx.locale.register(SETTINGS_NS, 'en', { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined @@ -258,7 +251,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(theme.getTheme()) return { - t: ctx.locale.bind(SETTINGS_NS), setTheme: (id) => { theme.setTheme(id) }, } } @@ -269,6 +261,7 @@ export function apply(ctx: ClientContext): void { id: 'appearance', order: 10, store, + locale: SETTINGS_NS, inject: injected, }, AppearanceRow)) return () => { deferred.dispose() } diff --git a/packages/client/ui-theme/src/client/locales.ts b/packages/client/ui-theme/src/client/locales.ts new file mode 100644 index 0000000000..6df56ceb96 --- /dev/null +++ b/packages/client/ui-theme/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.theme` namespace dictionaries (the Appearance row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} satisfies Record + +/** The settings.theme namespace key union. */ +export type ThemeKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} satisfies Record diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 9852b93e66..ea9da5cfde 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -73,7 +73,8 @@ describe('ui-theme apply', () => { const { instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().preference).toBe('dark') - expect(face.t('appearance.dark')).toBe('深色') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS) face.setTheme('system') expect(theme.getTheme().preference).toBe('system') diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 129f756aa6..ccd811d289 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -41,7 +41,7 @@ describe('tsdown client artifact', () => { // Same execution form the loader uses (inline script eval, window scope) — // the implied-eval ban targets accidental string execution, not this // deliberate bundle-execution fixture. - // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + // oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call new Function(code!)() expect(handoff).toBeDefined() const modules = new Map([ diff --git a/packages/client/ui-workspace/tests/rename-assembly.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx new file mode 100644 index 0000000000..bdbeee845f --- /dev/null +++ b/packages/client/ui-workspace/tests/rename-assembly.spec.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +/** + * The session-rename assembly chain on SlotTestRuntime (real apply, real + * WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog → + * the injected renameSession hop (sessions.binding → ISession.rename) → on + * the accepted unary response the dialog closes and the row re-labels from + * the list state — no push-frame wait. Previously pinned only by the + * assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the + * verb's wire behavior stays with the runtime package + * (session.spec.ts#rename), the dialog's own arms with rows.spec / + * workspace-browser.spec. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' + +const SID = 's1' as SessionId + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +/** Test-owned sidebar shell role: declares and renders the browsing region. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +function SidebarFrame({ renderSlot }: FrameProps) { + return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} +} + +describe('session rename through the assembled browser', () => { + it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => { + const runtime = await SlotTestRuntime.create() + const rename = vi.fn(async title => ({ + ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' }, + session: { rename }, + }) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', + sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + + // The current session's group auto-expands; open the row's action menu. + const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')! + fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题')) + fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true })) + + // The dialog seeds from the current title; submit a padded value. + const input = await view.findByLabelText('Session name') as HTMLInputElement + expect(input.value).toBe('旧标题') + fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } }) + fireEvent.click(view.getByRole('button', { name: 'Rename' })) + + // The injected hop reached the session face with the edge-trimmed draft + // (the dialog trims edges; interior normalization is host-side). + await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') }) + // Acceptance closes the dialog without any push-frame wait. + await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() }) + // The manager lands the unary echo in the list store (its own package + // tests own that hop); the row re-labels from list state alone. + await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' }) + await view.findByText('分叉 实验记录') + expect(view.queryByText('旧标题')).toBeNull() + await runtime.dispose() + }) + + it('a rejected rename keeps the dialog open with the error surfaced', async () => { + const runtime = await SlotTestRuntime.create() + const rename = vi.fn(async () => ({ + ok: false, error: { code: 'internal', message: 'title write failed', details: {} }, + })) + await runtime.sessions.add({ + id: SID, + summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' }, + session: { rename }, + }) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha', + sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + await runtime.flush() + + const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')! + fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题')) + fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true })) + const input = await view.findByLabelText('Session name') + fireEvent.change(input, { target: { value: '新名' } }) + fireEvent.click(view.getByRole('button', { name: 'Rename' })) + + // Failure: the injected hop rethrows the business error; the dialog + // stays open with the alert and the row keeps its title. + const alert = await view.findByRole('alert') + expect(alert.textContent).toContain('title write failed') + expect(view.getByLabelText('Session name')).toBeTruthy() + expect(view.getByText('旧标题')).toBeTruthy() + await runtime.dispose() + }) +}) diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3829480b31..950dfd4a1f 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,9 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, - type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, + type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, + type SlotScope, type StoredEntry, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -159,6 +160,74 @@ function cachedSessionMaybeInject( return props } +/** + * Locale `t` seat bindings, cached per (face, namespace, revision). The + * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW + * function reference per namespace, so `React.memo` components taking `t` + * re-render through ordinary shallow comparison — freshness rides identity, + * no extra invalidation channel. Within one revision the reference is stable + * (memoized children do not churn on unrelated re-renders). + */ +const localeSeatCache = new WeakMap>() + +function localeSeat(face: LocaleFace, ns: string): Translate { + let perNs = localeSeatCache.get(face) + if (!perNs) { + perNs = new Map() + localeSeatCache.set(face, perNs) + } + const revision = face.getSnapshot().revision + const cached = perNs.get(ns) + if (cached && cached.revision === revision) return cached.t + const bound = face.bind(ns) + // Fresh wrapper per revision: bind() itself may return a stable reference. + const t: Translate = (key, params) => bound(key, params) + perNs.set(ns, { revision, t }) + return t +} + +const noopSubscribe = (): (() => void) => () => {} +const zeroRevision = (): number => 0 + +/** + * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the + * face is one global source shared by every outlet, and uSES resubscribes + * whenever the subscribe reference changes — fresh closures per render would + * churn one unsubscribe/resubscribe pair per outlet per render. + */ +const localeSubscriptionCache = new WeakMap void) => () => void + getRevision: () => number +}>() + +function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } { + let cached = localeSubscriptionCache.get(face) + if (!cached) { + cached = { + subscribe: fn => face.subscribe(fn), + getRevision: () => face.getSnapshot().revision, + } + localeSubscriptionCache.set(face, cached) + } + return cached +} + +/** + * Subscribe an outlet to the installed locale face's revision (0 while none + * is installed — exactly one uSES call either way, keeping hook order + * stable). Every outlet re-renders on a locale switch; entry bodies then + * re-derive their `t` seat at the new revision. The face must be installed + * before the first render that needs it — a face appearing later has no + * notification channel to already-mounted outlets. + */ +function useLocaleRevision(face: LocaleFace | undefined): number { + const subscription = face !== undefined ? localeSubscription(face) : undefined + return useSyncExternalStore( + subscription?.subscribe ?? noopSubscribe, + subscription?.getRevision ?? zeroRevision, + ) +} + /** * Entry-identity React keys for chain boundaries. A chain outlet renders ONE * elected entry through an error boundary; without a key, a boundary that @@ -242,6 +311,16 @@ function standardKit( // reader, bound per provide bundle (cached by info identity). kit['useProjection'] = projectionHook(info) } + if (entry.locale !== undefined) { + const face = host.locale + // Loud assembly failure: locale is immediately-tier infrastructure; a + // declared namespace with no installed face is a miswired composition. + if (face === undefined) { + throw new SlotAssemblyError( + `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`) + } + kit['t'] = localeSeat(face, entry.locale) + } const store = scope === 'session-maybe' && info?.sessionId === undefined ? undefined : host.storeOf(entry, info?.sessionId) @@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { fn => host.subscribe(slotKey, fn), () => host.getVersion(slotKey), ) + // Locale revision tick: a locale switch re-renders every outlet, and entry + // bodies re-derive their `t` seat at the new revision (fresh identity). + useLocaleRevision(host.locale) const sessionInfo = useSessionMaybeProvideInfo() const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's @@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { fn => host.subscribe('root', fn), () => host.getVersion('root'), ) + useLocaleRevision(host.locale) const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index aad4b7b2e6..cbe0d0ac55 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -134,7 +134,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS) export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void { // The slot's VALUE is stored for restore and reassigned — never invoked // detached, so the unbound-method concern does not apply. - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const original = stream.write stream.write = (chunk: unknown, ...rest: unknown[]): boolean => { logs.push(typeof chunk === 'string' ? chunk : String(chunk)) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 4d417fb78e..da0ca469a3 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -195,7 +195,7 @@ export class BasicCompactService extends CompactService { // A model-free prune can land before later summary work fails. That // durable reduction is sufficient retry proof; do not discard it just // because the optional second phase threw. Cancellation still wins. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited. if (!signal.aborted && agent.session.surface.replaceGeneration > generation) { ctx.logger.warn( `context-overflow compaction failed after durable surface progress: ${message}; ` @@ -205,14 +205,14 @@ export class BasicCompactService extends CompactService { return { kind: 'retry' } } ctx.logger.warn( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited. `context-overflow compaction failed: ${message}; ${signal.aborted ? 'cancellation prevents retry' : 'preserving the original request error'}`, ) return next() } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while compaction is awaited. if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next() if (result !== null) logResult(result, 'context overflow recovery') diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index d5eb7cfc40..c236eea843 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -49,7 +49,7 @@ export function selectCompactableRange( let accumulated = 0 let keepFromIdx = pricedNodes.length for (let index = pricedNodes.length - 1; index >= 0; index -= 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion accumulated += pricedNodes[index]!.tokens keepFromIdx = index if (accumulated >= retainTokens) break @@ -57,15 +57,15 @@ export function selectCompactableRange( if (keepFromIdx === 0) return null while (keepFromIdx > 0) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break keepFromIdx -= 1 } if (keepFromIdx === 0) return null - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const first = surfaceNodes[0]! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const cutoff = surfaceNodes[keepFromIdx - 1]! return { start: first, end: cutoff } } @@ -98,11 +98,11 @@ export async function compactSurfaceRegion( `compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`, ) } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) { throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) { throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`) } @@ -196,7 +196,7 @@ function buildSummarizationInput( const events = session.events const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion .map(seq => session.deriveEventMessage(events[seq]!)) .filter((message): message is Message => message !== null) return { @@ -213,7 +213,7 @@ function inspectTurnTail( let compactionInProgress = false let compactionStateKnown = false for (let index = events.length - 1; index >= 0; index -= 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! if (!compactionStateKnown) { if (event.type === 'compact/start') { diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index f54e0a0a13..d368c2e1e5 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -110,8 +110,8 @@ export class SessionReferenceService extends Service { */ async listCandidates( agent: Agent, - query = '', - limit = this.config.candidateLimit, + query: string = '', + limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise { if (!Number.isSafeInteger(limit) || limit <= 0) { diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 8be2474092..ed9d80eea5 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/README.i18n.yaml @@ -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/cordis/tool-cordis/README.md -README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97 -README.zh.md: 237b72244be2336a82c8c48cb341d7f9796d08f4 +README.md: eda135d93e2912bbb4e111af40d176409b383b5b +README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 5b58e665da..eda135d93e 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -28,7 +28,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab ## The generated API catalog -`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud. +`src/api-catalog.ts` is generated from the same Typert `FaceModel` projection as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `scripts/gen-cordis-api.ts` is a compatibility entry point for that unified projection, not a second collector. `cordis_inspect` intersects the committed catalog with the live service store at call time; it has no runtime Typert dependency. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud. ## Rendering diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 237b72244b..6eef100861 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -28,7 +28,7 @@ ## 生成的 API 目录 -`src/api-catalog.ts` 由 `scripts/gen-cordis-api.ts` 生成,使用与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 相同的 AST 遍历,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`cordis_inspect` 在调用时把该目录与存活服务 store 取交集。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会明确报错。 +`src/api-catalog.ts` 与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 由同一个 Typert `FaceModel` 投影生成,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`scripts/gen-cordis-api.ts` 是该统一投影的兼容入口,而非第二套收集器。`cordis_inspect` 在调用时把已提交的目录与存活服务 store 取交集;它在运行时不依赖 Typert。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。 ## 渲染 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0bb357ca3a..9f7d6dc28d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -489,7 +489,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */', }, { - signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise', + signature: 'async kill(owner: Agent, id: PtySessionId, reason: string = \'model request\'): Promise', jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */', }, { @@ -679,7 +679,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Exact-read consumer that prepares immutable cross-session message context.', methods: [ { - signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise', + signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise', jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */', }, { @@ -758,15 +758,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'register(skill: SkillRegistration): () => void', - jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', + jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', }, { signature: 'async list(options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */', + jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', }, { signature: 'async snapshot(options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * Observe the current model-invocable catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', + jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', }, { signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', @@ -1002,6 +1002,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'typert', + summary: 'Registry of generated schemas and package reflection.', + methods: [ + { + signature: 'register(contribution: TypertContribution): () => void', + jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */', + }, + { + signature: 'get(key: string): TypertSchemaRecord | undefined', + jsDoc: '/**\n * Look up one schema by `#`.\n * @param key - global schema key.\n * @returns the live schema record, or `undefined` when absent.\n */', + }, + { + signature: 'resolve(key: string): TypertSchemaRecord', + jsDoc: '/**\n * Resolve one required schema.\n * @param key - global schema key.\n * @returns the live schema record.\n * @throws when the key is malformed, the package face is absent, or the schema is not contributed.\n */', + }, + { + signature: 'list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]', + jsDoc: '/**\n * Enumerate live schemas in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching schema records.\n */', + }, + { + signature: 'getPackage(packageName: string, face: TypertFace = \'host\'): TypertPackageRecord | undefined', + jsDoc: '/**\n * Look up generated reflection for one package face.\n * @param packageName - exact npm package name.\n * @param face - face to query; defaults to the host runtime.\n * @returns the live package record, or `undefined` when absent.\n */', + }, + { + signature: 'listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]', + jsDoc: '/**\n * Enumerate generated package reflection in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching package records.\n */', + }, + { + signature: 'toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema', + jsDoc: '/**\n * Project a live Zod schema to JSON Schema without caching the result.\n * @param key - global schema key.\n * @param params - Zod projection parameters.\n * @returns a fresh JSON Schema document.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -1281,34 +1315,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.', }, - { - name: 'slash/input-begin-command', - mode: 'bail', - signature: '\'slash/input-begin-command\'(request: BeginCommandRequest): true | undefined', - jsDoc: '/**\n * Applies one command claim to the scoped Input. Dispatched with the\n * session\'s scope carrier; the owning session\'s input listener returns\n * `true` only after the phase and span CAS checks pass and the machine\n * actually mutated — producers treat anything else as "not applied".\n * @param request - Claim and menu-time span CAS.\n * @mode bail\n */', - summary: 'Applies one command claim to the scoped Input.', - }, - { - name: 'slash/input-consume-token', - mode: 'bail', - signature: '\'slash/input-consume-token\'(request: ConsumeTokenRequest): true | undefined', - jsDoc: '/**\n * Consumes one command token after business success (popup settle /\n * menu-pick execute). Same carrier routing and applied-truth contract.\n * @param request - Exact span or bare-token guard.\n * @mode bail\n */', - summary: 'Consumes one command token after business success (popup settle / menu-pick execute).', - }, - { - name: 'slash/input-insert-reference', - mode: 'bail', - signature: '\'slash/input-insert-reference\'(request: InsertReferenceRequest): true | undefined', - jsDoc: '/**\n * Inserts one reference into the scoped Input (same carrier routing and\n * applied-truth contract as begin-command).\n * @param request - Reference and menu-time span CAS.\n * @mode bail\n */', - summary: 'Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).', - }, - { - name: 'slash/input-insert-text', - mode: 'bail', - signature: '\'slash/input-insert-text\'(request: InsertTextRequest): true | undefined', - jsDoc: '/**\n * Replaces the trigger token span with literal text — the plain-text\n * reference path (decision 21). Same carrier routing and applied-truth\n * contract; the draft gains ordinary characters, no occurrence entry.\n * @param request - Replacement text and menu-time span CAS.\n * @mode bail\n */', - summary: 'Replaces the trigger token span with literal text — the plain-text reference path (decision 21).', - }, { name: 'subagent/end', mode: 'emit', @@ -2358,6 +2364,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillDefinition', declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, + { + name: 'SkillInvocationPolicy', + declaration: 'export interface SkillInvocationPolicy {\n readonly modelInvocable: boolean;\n readonly userInvocable: boolean;\n}', + }, { name: 'SkillLookupOptions', declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}', @@ -2376,7 +2386,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillRegistration', - declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', + declaration: 'export type SkillRegistration = Omit & {\n readonly invocation?: SkillInvocationPolicy;\n readonly provider?: string;\n};', }, { name: 'SkillResourceBase', @@ -2388,7 +2398,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSummary', - declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', + declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, { name: 'SpillLocator', @@ -2694,6 +2704,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TurnTriggerMap', declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}', }, + { + name: 'TypertContribution', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}', + }, + { + name: 'TypertDocTag', + declaration: 'export interface TypertDocTag {\n readonly name: string;\n readonly argument?: string;\n readonly comment?: string;\n readonly text: string;\n}', + }, + { + name: 'TypertDocumentation', + declaration: 'export interface TypertDocumentation {\n readonly description?: string;\n readonly summary?: string;\n readonly tags: readonly TypertDocTag[];\n readonly jsDoc?: string;\n}', + }, + { + name: 'TypertEventModel', + declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}', + }, + { + name: 'TypertMemberModel', + declaration: 'export interface TypertMemberModel {\n readonly kind: \'property\' | \'method\' | \'getter\' | \'setter\' | \'call\' | \'construct\' | \'index\';\n readonly name: string;\n readonly signature: string;\n readonly summary?: string;\n readonly jsDoc?: string;\n}', + }, + { + name: 'TypertObjectModel', + declaration: 'export interface TypertObjectModel extends TypertDocumentation {\n readonly name: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}', + }, + { + name: 'TypertPackageFilter', + declaration: 'export interface TypertPackageFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', + }, + { + name: 'TypertPackageModel', + declaration: 'export interface TypertPackageModel {\n readonly services: readonly TypertServiceModel[];\n readonly events: readonly TypertEventModel[];\n readonly objects: readonly TypertObjectModel[];\n}', + }, + { + name: 'TypertPackageRecord', + declaration: 'export interface TypertPackageRecord {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n readonly model: TypertPackageModel;\n}', + }, + { + name: 'TypertSchema', + declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}', + }, + { + name: 'TypertSchemaFilter', + declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', + }, + { + name: 'TypertSchemaRecord', + declaration: 'export interface TypertSchemaRecord extends TypertSchema {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n}', + }, + { + name: 'TypertServiceModel', + declaration: 'export interface TypertServiceModel extends TypertDocumentation {\n readonly key: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}', + }, + { + name: 'TypertTypeModel', + declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}', + }, { name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 949e9801c4..98d1edb302 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -221,7 +221,7 @@ export class ReactLoopAgent implements Agent { if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const { message } = this.queued.shift()! const inheritedOutboxLength = this.outbox.length @@ -368,7 +368,7 @@ export class ReactLoopAgent implements Agent { outcome.failure, requestFailureHistory, outcome.retryPolicy, signal, () => Promise.resolve(undefined), ) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- signal can abort while recovery is awaited. if (action?.kind === 'retry' && !signal.aborted) { retryFailures = Object.freeze([...requestFailureHistory, outcome.failure]) } @@ -584,7 +584,7 @@ export class ReactLoopAgent implements Agent { const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds + // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds ? persistedConfig! : { ...route, diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index cd57a28f44..068c5c8c21 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -83,7 +83,7 @@ export async function executeToolCalls( let concluded = false while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition const first = planned[next]! const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first] @@ -151,7 +151,7 @@ async function runGroup( const result = slot.needsPost ? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result) : ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) for (const context of result.additionalContexts ?? []) acceptContext(context) concluded ||= result.concludesTurn === true @@ -162,7 +162,7 @@ async function runGroup( const inFlight = new Map>() const startCall = async (index: number): Promise => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index const call = group[index]! callSeqs[index] = appendToolCall(session, turn, step, call.block) started++ @@ -198,7 +198,7 @@ async function runGroup( const fillPool = async (): Promise => { while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { // Re-read later modes after ordered commits so registry changes can create a barrier. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition const nextCall = group[nextToStart]! if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 761791d89f..7640820e81 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -249,7 +249,7 @@ describe('config-driven session id', () => { const failures: unknown[] = [] ctx.on('agent-loop/config-start-failed', () => { throw unrenderable }) // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + // oxlint-disable-next-line typescript/prefer-promise-reject-errors ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 8b222327f2..b28586b6b8 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -108,12 +108,12 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } }, async serial(name, ...rest) { - // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise return await serial(carrier, name, agent, ...rest) }, waterfall(name, ...rest) { - // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never return waterfall(carrier, name, agent, ...rest) }, diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 17b115a40c..563edbc05b 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -328,7 +328,7 @@ export class AgentRegistry extends Service { // caller's composite effect can yield it for in-order teardown; the // loop's constructor effect returns it directly, identity-nesting the // registration under that effect. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -355,7 +355,7 @@ export class AgentRegistry extends Service { // capability and need no Cordis tracker magic. const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) - // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver + // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver return Reflect.apply(target.createAgent, receiver, [ownerCtx, options]) } @@ -370,7 +370,7 @@ export class AgentRegistry extends Service { const ownerCtx = this.ctx const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) - // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver + // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver return Reflect.apply(target.resume, receiver, [ownerCtx, options]) } @@ -397,7 +397,7 @@ export class AgentRegistry extends Service { yield this.enter(agent, this.ctx.agent) this.announce(agent) }.bind(this), 'agents.register()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 28b4ca4c71..829f318699 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -115,7 +115,10 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** + * Public live-agent handle with aliases over the unified delivery primitive. + * @typert object + */ export interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index 53f7e34135..cdb34b50ce 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -241,7 +241,7 @@ export class ScopedLayers { } if (notify) this.onChange() }.bind(this), options.label) - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity + // oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity return dispose } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e42414503b..52c37dc15b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -353,6 +353,7 @@ const attachments = new WeakMap() * * Plain class (not a Service) — create instances via `ctx.sessions.create()`. * Seeding with an existing event log replays/forks a session. + * @typert object */ export class Session { private log: SessionEvent[] = [] @@ -596,7 +597,7 @@ export class Session { for (const seq of nodes.slice(this.derivedNodes)) { // Surface sequences are built from this.log — seq is always a valid // index by construction. The non-null assertion expresses that invariant. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const msg = this.deriveEventMessage(this.log[seq]!) // A surface node is one of the five message-producing types, but an // empty-content assistant/message (a max-tokens step that hosts only @@ -911,7 +912,7 @@ export class SessionStore extends Service { } catch (error: unknown) { // Preserve the listener's exact rejection value; flush is a caller-owned // failure boundary, and Cordis listeners may throw arbitrary values. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + // oxlint-disable-next-line typescript/prefer-promise-reject-errors return Promise.reject(error) } })) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 9677c5ec2c..cce85dbd14 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -340,7 +340,7 @@ export class SurfaceManager implements SessionSurface { /** Fold events appended since the previous access. */ private _processDelta(): void { for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition applySurfaceEvent(this._state, this.log[i]!, i, this.log) this._lastProcessedSeq = i } diff --git a/packages/core/session/tests/chunk-rows.spec.ts b/packages/core/session/tests/chunk-rows.spec.ts index 28611e5e41..4fe14894e5 100644 --- a/packages/core/session/tests/chunk-rows.spec.ts +++ b/packages/core/session/tests/chunk-rows.spec.ts @@ -86,7 +86,7 @@ describe('packChunkRuns', () => { ['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]], ['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)], ])('breaks a run on %s (both halves too short to pack)', (_label, events) => { - expect(packChunkRuns(events as SessionEvent[])).toStrictEqual(events) + expect(packChunkRuns(events)).toStrictEqual(events) }) it('breaks a tool-call run on call-id or name change', () => { diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index c0f5ceb525..f81e76d96a 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -546,19 +546,19 @@ export function defineTool, ): ToolDefinition { // Object-literal methods do not use `this`; retaining references is safe. - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userExecute = options.execute - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userFinalizeContent = options.finalizeContent - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userRender = options.output.render - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userPresentationMeta = options.output.presentationMeta - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userPresentCall = options.presentCall - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userPresentResult = options.presentResult - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const userIsConcurrencySafe = options.isConcurrencySafe if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) diff --git a/packages/core/tools/src/testing.ts b/packages/core/tools/src/testing.ts index ad9fa52d85..2259118f99 100644 --- a/packages/core/tools/src/testing.ts +++ b/packages/core/tools/src/testing.ts @@ -27,7 +27,7 @@ export type ContentToolFixtureOptions = Omit< export function defineContentToolFixture( options: ContentToolFixtureOptions, ): ToolDefinition { - // eslint-disable-next-line @typescript-eslint/unbound-method + // oxlint-disable-next-line typescript/unbound-method const execute = options.execute return defineTool({ ...options, diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 05ec22d940..7dfb4b82ff 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -148,7 +148,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand { throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`) } // Cardinality was checked above, so the fallback index zero exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const task = prompt ?? parsed.positionals[0]! if (task.trim().length === 0) throw new CliArgumentError('task must not be blank') @@ -301,7 +301,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise try { /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ - if (!firstTurnEnded) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + if (!firstTurnEnded) { // oxlint-disable-line typescript/no-unnecessary-condition agent.followup(createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })) } await turnEnded @@ -361,7 +361,7 @@ async function bootInterruptibly( return await Promise.race([booting, interruptedBoot]) } catch (error: unknown) { // The awaited race permits the signal to change after the preflight check. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (signal.aborted) { void booting.then( async (lateContext) => { diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index c38e7494e0..3765a7e512 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -32,6 +32,9 @@ class ObservedStateGate { * the write/edit prior-observation policy. */ private owner(actor: object | undefined): object | undefined { + // tsgolint treats object as assignable to weak FsPolicyExec, while tsc still requires the structural cast for property access. + // See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md. + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type. return (actor as FsPolicyExec | undefined)?.agent?.session } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index ff7d686a0c..7ee364c130 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -103,7 +103,7 @@ export function applyGoalProjection(state: GoalProjection | null, event: Session // Session-log data is a durable boundary: the static type promises the kind, // but a foreign or corrupted change record must degrade to same-reference, // never feed the zod parse in the registry drive. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- durable-boundary guard + // oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard if (change === undefined || change.kind !== 'goal/change') return state if (change.operation === 'clear') return null return { diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 7c10fa95cd..2f5c40735a 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -23,7 +23,7 @@ export class GoalError extends HarnessError { * @param code - stable machine-routable classification. */ // Keep the constructor to narrow HarnessError's string code at this boundary. - // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing + // oxlint-disable-next-line typescript/no-useless-constructor -- type-only narrowing constructor(message: string, code: GoalErrorCode) { super(message, code) } diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index 80f55791c1..165722ec0d 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/hooks/README.md README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 41024a8bd268550aa07401fd8b21c74db0914796 +README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 41024a8bd2..741300a9a3 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent | `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 | | `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 | -Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 +Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 65faa23b75..deed052066 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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/hooks/hook-protocol/README.md -README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397 -README.zh.md: 9862d4f332e0e82fb6479fcadf9376403fb9bef8 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 10cfcdcbf8..8cf4b95c95 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud | Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic | | Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives -- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop. - **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. @@ -42,4 +42,3 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. -- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`). diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 9862d4f332..15a537b676 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 测试 | `matchesMatcher(pattern, query, mode)`:根据 `mode` 使用字面匹配或正则匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则) | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | | 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** | | 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision | | 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) | @@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 ## 原语 -- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。 - **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。 - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。 - **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。 @@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 -Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note(agent 决策记录)。 +Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 ## 模型体验 @@ -42,4 +42,3 @@ Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点( ## 已知限制与暂缓事项 - **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md));当 hook 设置它时,桥接会记录 + 警告。完整契约见 `src/types.ts`。 -- **无效 matcher 正则会静默地不匹配任何内容**:`matchesMatcher` 绝不抛出异常;显示该错误需要返回诊断的变体或解析时验证(`TODO(matcher-diagnostics)`)。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index e342665057..d67746f824 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,7 +13,7 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { matchesMatcher } from './matcher.ts' +export { matcherDiagnostic, matchesMatcher } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 036954a59c..9c5606a975 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -2,7 +2,8 @@ * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ * pipe patterns as literal alternatives and other patterns as regex; Codex * treats every non-empty pattern as an unanchored regex. Missing, empty, and - * `*` match all; invalid regexes silently match nothing. + * `*` match all. Runtime matching contains invalid regexes as non-matches; + * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -16,10 +17,37 @@ function isMatchAll(matcher: string | undefined): boolean { /** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ +/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string): RegExp | undefined { + try { + return new RegExp(pattern) + } catch (_syntaxError) { + // RegExp construction is the try's only operation, so malformed pattern + // syntax is the only expected failure. + return undefined + } +} + +/** + * Validate one matcher before a bridge accepts its config group. + * @param matcher - configured pattern; match-all sentinels are valid. + * @param mode - dialect deciding whether a word-and-pipe pattern is literal. + * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. + */ +export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { + if (isMatchAll(matcher)) return undefined + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined + return compileRegex(pattern) === undefined + ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + : undefined +} + /** * Whether `matcher` selects `query` under the given dialect. Claude literal * patterns exact-match pipe-separated alternatives; all other patterns are - * unanchored regexes. Invalid regexes return `false` rather than throwing. + * unanchored regexes. Invalid regexes return `false` rather than throwing; + * bridge config parsers surface them through {@link matcherDiagnostic} before use. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. @@ -33,13 +61,5 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode: if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { return pattern.split('|').includes(query) } - try { - return new RegExp(pattern).test(query) - } catch { - // Invalid regex: a broken matcher selects nothing rather than throwing into - // the agent loop. This is silent — callers get `false`, indistinguishable - // from a genuine non-match, so a typo'd pattern quietly disables the matcher. - // Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)). - return false - } + return compileRegex(pattern)?.test(query) ?? false } diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 37e2acb137..a1f794aa28 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' describe('matchesMatcher — match-all sentinels (both dialects)', () => { for (const mode of ['claude', 'codex'] as const) { @@ -56,3 +56,19 @@ describe('matchesMatcher — invalid regex is a non-match (never throws)', () => expect(matchesMatcher('[', 'x', 'codex')).toBe(false) }) }) + +describe('matcherDiagnostic — parse-time diagnostics', () => { + it('accepts match-all sentinels, Claude literals, and valid regexes', () => { + expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined() + expect(matcherDiagnostic('', 'codex')).toBeUndefined() + expect(matcherDiagnostic('*', 'codex')).toBeUndefined() + expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() + expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() + expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() + }) + + it('returns a stable diagnostic for invalid regexes in either dialect', () => { + expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("') + expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["') + }) +}) diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index 1eaa331832..ed15dbf7a6 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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/hooks/hooks-claude/README.md -README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd -README.zh.md: 0a7afc20eba02d59d293124936cb81aeba6d3f0f +README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa +README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 24259c24ea..61c2d152da 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -28,7 +28,7 @@ In a `cordis.yml`: projectDir: . ``` -The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher on an event that consumes matchers, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. @@ -86,7 +86,7 @@ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and ## Known Limitations and Deferred Work -- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). +- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is ignored before group parsing, so an unsupported event cannot invalidate or register hooks. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). - **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`. - **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout. - **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 0a7afc20eb..38509ab6e6 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -28,7 +28,7 @@ const config: Config = { projectDir: . ``` -配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 +配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理,其中包括实际消费 matcher 的事件所带的无效 matcher 正则(会报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook;`http`/`mcp_tool`/`prompt`/`agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟,即 CC 默认值)。 hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd`(`session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于用户项目树,而非服务器启动目录。 @@ -86,7 +86,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记 ## 已知限制与暂缓事项 -- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。 +- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup`、`InstructionsLoaded`、`UserPromptExpansion`、`MessageDisplay`、`PermissionRequest`、`PostToolUseFailure`、`PostToolBatch`、`PermissionDenied`、`Notification`、`TaskCreated`、`TaskCompleted`、`StopFailure`、`TeammateIdle`、`ConfigChange`、`CwdChanged`、`FileChanged`、`WorktreeCreate`、`WorktreeRemove`、`PreCompact`、`PostCompact`、`SessionEnd`、`Elicitation` 和 `ElicitationResult`。这些事件的配置会在配置组解析前被忽略,因此不支持的事件既不会使配置失效,也不会注册 hook。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。 - **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage`、`sessionTitle`、`watchPaths`、`reloadSkills` 与 `CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`),payload 会省略 `model`、`agent_type` 和 `session_title` 等当前可选字段。 - **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle` 和 `suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。 - **`PreToolUse` 只支持部分功能:** `deny` 与 `ask` 决策可用;`allow` 不会预审批,不支持 `defer`,`additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 3797d4e56f..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,17 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' + +const CLAUDE_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'SubagentStart', + 'SubagentStop', +] as const /** A parsed CC config: event name → its matcher groups (command hooks only). */ export type ClaudeHookConfig = Record @@ -53,8 +63,11 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri /** * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are - * ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions - * are applied to every surviving command. + * ignored rather than failing boot; unsupported events are ignored before their groups are parsed, + * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving + * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no + * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a + * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -70,7 +83,8 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa const hooksMap = root ? asObject(root.hooks) ?? root : undefined if (!hooksMap) return { config, skipped } - for (const [event, rawGroups] of Object.entries(hooksMap)) { + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] if (!Array.isArray(rawGroups)) continue const groups: MatcherGroup[] = [] for (const rawGroup of rawGroups) { @@ -92,8 +106,13 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'claude') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) groups.push({ - ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, + ...matcher !== undefined ? { matcher } : {}, hooks: commands, }) } diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index eca0cb781b..c23625b392 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -45,17 +45,22 @@ function writeConfig(hooks: unknown, scripts: Record = {}): stri return dir } -async function harness(configDir: string, adapter: MockAdapter): Promise { - return (await harnessWithFiber(configDir, adapter)).ctx +async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { + return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx } /** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */ -async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> { +async function harnessWithFiber( + configDir: string, + adapter: MockAdapter, + beforeHooks?: (ctx: Context) => void, +): Promise<{ ctx: Context; hooks: Fiber }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + beforeHooks?.(ctx) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) return { ctx, hooks } @@ -85,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): describe('hooks-claude bridge — UserPromptSubmit', () => { it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => { - // The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr. + // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks + // with the reason on stderr. const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) dirs.push(dir) const block = join(dir, 'block.sh') writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n') chmodSync(block, 0o755) - writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } })) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } })) const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) @@ -361,6 +367,42 @@ describe('hooks-claude bridge — load resilience', () => { expect(adapter.requests).toHaveLength(1) }) + it('an invalid regex matcher is reported and registers no hooks', async () => { + const dir = writeConfig({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('fine')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid claude regex matcher "(" on event "PreToolUse"', + )) + }) + + it('an invalid matcher on an unsupported event does not disable supported hooks', async () => { + const dir = writeConfig({ + Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('should not run')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(event => event.type === 'turn/start')).toBe(false) + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher')) + }) + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it // would veto the prompt (0 model requests) and log a hook/invoked. Build the diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index f635ef0fd9..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -63,4 +63,33 @@ describe('parseClaudeConfig', () => { const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] }) expect('matcher' in config.Stop![0]!).toBe(false) }) + + it('rejects an invalid regex matcher with its event name', () => { + expect(() => parseClaudeConfig({ + PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], + })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"') + }) + + it('discards matcher fields on events without matcher subjects before validation', () => { + const { config } = parseClaudeConfig({ + UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], + Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }], + }) + + expect(config).toEqual({ + UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }], + Stop: [{ hooks: [{ command: 'stop.sh' }] }], + }) + }) + + it('ignores invalid matchers on unsupported events without dropping supported hooks', () => { + const { config } = parseClaudeConfig({ + Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }], + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }], + }) + + expect(config).toEqual({ + PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }], + }) + }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 74155768be..90e7f7c1dd 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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/hooks/hooks-codex/README.md -README.md: fd57762c6fb91e0ea47ec57c30bf9850bc488a33 -README.zh.md: 58b387e22f0e56770e4ea779f184c6c82a38ff98 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index fd57762c6f..e906810ed5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -34,7 +34,7 @@ In a `cordis.yml`: model: deepseek-v4 ``` -The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse. +The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse. The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 58b387e22f..4940fdb976 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **仅使用正则的 matcher**(没有字面量快速路径;matcher 始终是未锚定正则)。 - **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带**尾随换行符。 - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。 @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容)。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 +配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index e602ddb20c..ae82340ad4 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' /** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const @@ -33,7 +33,10 @@ function asObject(value: unknown): Record | undefined { /** * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather - * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. + * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on + * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A + * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge + * to reject the complete config before listener registration. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ @@ -69,7 +72,12 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } if (commands.length === 0) continue - groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands }) + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + const diagnostic = matcherDiagnostic(matcher, 'codex') + if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) } if (groups.length > 0) config[event] = groups } diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 7eace0c6c3..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -39,12 +39,13 @@ function writeHooks(dir: string, hooks: unknown): void { writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks })) } -async function harness(dir: string, adapter: MockAdapter): Promise { +async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + beforeHooks?.(ctx) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -88,11 +89,11 @@ describe('hooks-codex bridge', () => { it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => { const dir = configDir() - // Block once with a marker; until the loop guard lands, an always-blocking - // hook would never let this test finish. + // Stop ignores its malformed matcher field. Block once with a marker; + // until the loop guard lands, an always-blocking hook would never finish. const marker = join(dir, 'fired') const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`) - writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] }) + writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] }) const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) @@ -151,6 +152,26 @@ describe('hooks-codex bridge', () => { expect(adapter.requests).toHaveLength(1) }) + it('an invalid regex matcher is reported and registers no hooks', async () => { + const dir = configDir() + writeHooks(dir, { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], + }) + const adapter = new MockAdapter([textResponse('ok')]) + const warn = vi.fn() + const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never }) + const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'invalid codex regex matcher "[" on event "PreToolUse"', + )) + }) + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { const dir = configDir() // A leaked listener would let this blocking hook veto the prompt and log an invocation; a diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 09bce12a43..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -65,4 +65,22 @@ describe('parseCodexConfig', () => { const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') }) + + it('rejects an invalid regex matcher with its event name', () => { + expect(() => parseCodexConfig({ + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') + }) + + it('discards matcher fields on events without matcher subjects before validation', () => { + const { config } = parseCodexConfig({ + UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }], + Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }], + }) + + expect(config).toEqual({ + UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }], + Stop: [{ hooks: [{ command: 'stop.sh' }] }], + }) + }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 6f22ce2466..18202ed036 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: b5f80dcb3a077a411db3b721737a9c16b56fcecf -README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae +README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 +README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b5f80dcb3a..1f0daedc54 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 1c3c7486f7..abf5417cdb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4f5336323c..81d16c8919 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1457,7 +1457,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } try { - const skills = await skillRegistry.list({ cwd }) + const skills = (await skillRegistry.list({ cwd })) + .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 99169c6428..33802dd4c0 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -20,6 +20,6 @@ export interface SkillEntry { /** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */ export interface SkillsApi { - /** Lists model-invocable skills for the addressed session's project root. */ + /** Lists skills usable by the browser's user-selected model-reference path. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 46e3703f3a..49441fc722 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -125,7 +125,7 @@ function fullResponse(narrow: RpcResponse): Response { */ // K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own // schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters async function handleUnary( api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, ): Promise { diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5208292fce..ad70e6b26a 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -184,10 +184,28 @@ describe('skill.list', () => { name: 'probe', list: (options) => { seenCwds.push(options.cwd) - return Promise.resolve([{ - name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', - source: 'custom', provider: 'probe', rank: 0, locator: null, - }]) + return Promise.resolve([ + { + name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + { + name: 'user-only', description: 'User-only', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + { + name: 'model-only', description: 'Model-only', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + { + name: 'trusted-only', description: 'Trusted-only', + invocation: { modelInvocable: false, userInvocable: false }, + source: 'custom', provider: 'probe', rank: 0, locator: null, + }, + ]) }, get: () => Promise.resolve(undefined), })) diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 6fa157ea87..e84f51b26a 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -78,14 +78,14 @@ export function boundedInsert(window: ListingCandidate[], candidate: ListingCand // oversized level costs O(1) per candidate past the head instead of a // window scan (100k children against a 1,001 window must not approach // 10^8 comparisons). - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail + // oxlint-disable-next-line typescript/no-non-null-assertion -- a full window (length === keep >= 1) has a tail if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true // Binary insertion keeps a retained candidate at O(log keep) comparisons. let lo = 0 let hi = window.length while (lo < hi) { const mid = (lo + hi) >>> 1 - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid else lo = mid + 1 } diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts index 67ffe1a9d1..a0de5840af 100644 --- a/packages/llm/llm-retry/src/history.ts +++ b/packages/llm/llm-retry/src/history.ts @@ -24,7 +24,7 @@ export function providerForClosedStep( if (stepEndIndex < 0) return undefined for (let index = stepEndIndex; index >= 0; index -= 1) { // The loop bounds prove this indexed read exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! if (event.type === 'request/header') return event.data.header.config.provider } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index c8f5a8b0fc..1fb4443af0 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -531,7 +531,7 @@ export class LlmService extends Service { yield value } } finally { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. if (!completed && !iterationFailed) { const close = iterator.return?.bind(iterator) if (close) await close() diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index f7a5028d14..bf2276a218 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -72,10 +72,10 @@ describe('BlockAssembler', () => { it('mustGet throws when an index is missing from the partials map (invariant violation)', () => { const assembler = new BlockAssembler() // Force the invariant violation: manually corrupt the data structures. - /* eslint-disable */ + /* oxlint-disable */ const hack = assembler as any hack.order.push(99) - /* eslint-enable */ + /* oxlint-enable */ expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated') }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index fcad4a7d7a..1afb3c4b6d 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -756,7 +756,7 @@ describe('LlmService', () => { return { [Symbol.asyncIterator](): AsyncIterator { // Third-party adapters can reject with arbitrary values. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + // oxlint-disable-next-line typescript/prefer-promise-reject-errors return { next: () => Promise.reject('plain provider failure') } }, } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 533ebd2453..644fdd3857 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -171,7 +171,7 @@ export class TokenMeterService extends Service { } while (state.consumedEvents < session.events.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log + // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log const event = session.events[state.consumedEvents]! this._foldEvent(session, state, event) state.consumedEvents += 1 @@ -226,7 +226,7 @@ export class TokenMeterService extends Service { } // assistant/message is surface-mandatory at every append/seed boundary. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const eventTokens = surface!.tokens if (event.data.usage !== undefined && nextHeader !== undefined) { const providerAssistantTokens = this._estimateProviderAssistant( @@ -334,7 +334,7 @@ export class TokenMeterService extends Service { // Session construction validates contiguous seqs, and the explicit // earlier-than-assistant check above therefore guarantees existence. const source = session.events[seq] - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const sourceEvent = source! if (sourceEvent.type !== 'assistant/chunk') { throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`) diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index f4f5ba64e1..8ff4168e1a 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -282,7 +282,7 @@ export class PtyService extends Service { * @param reason - diagnostic cleanup reason. * @returns true for a newly closed session, false when the same close is already in flight. */ - async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise { + async kill(owner: Agent, id: PtySessionId, reason: string = 'model request'): Promise { const record = this.expectOwned(owner, id) if (record.closing !== undefined) { await record.closing diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 714ef937df..475cc55dbf 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -39,7 +39,7 @@ export class SessionQueryError extends HarnessError { declare readonly code: SessionQueryErrorCode // The base stores the value; this signature narrows its open string code. - // eslint-disable-next-line @typescript-eslint/no-useless-constructor + // oxlint-disable-next-line typescript/no-useless-constructor constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) { super(message, code, options) } diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 0fdf723d01..d929317b50 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -91,7 +91,7 @@ export function traceEvent( } // The target check above proves the parallel record exists at this index. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const targetRecord = analysis.records[seq]! const replacedBy = analysis.replacedBy.get(seq) return { @@ -225,7 +225,7 @@ function buildDescendants( const stack = [{ sessionId, descendants }] while (stack.length > 0) { // The length guard proves a frame exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const frame = stack.pop()! const nodes: SessionLineageNode[] = [] for (const child of childrenByParent.get(frame.sessionId) ?? []) { @@ -235,7 +235,7 @@ function buildDescendants( } for (let index = nodes.length - 1; index >= 0; index -= 1) { // The loop bounds prove this indexed node exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const node = nodes[index]! stack.push({ sessionId: node.session.header.id, descendants: node.descendants }) } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index c22bbcd98a..2713de9a72 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -134,7 +134,7 @@ function expectCode(code: SessionQueryErrorCode): Error { function rejectUnknown(reason: unknown): Promise { return new Promise((_resolve, reject) => { // Exercise containment for an implementation that violates the Error rejection convention. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + // oxlint-disable-next-line typescript/prefer-promise-reject-errors reject(reason) }) } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index b6fab0e68e..df79984cef 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -1444,7 +1444,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }, ])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => { const mounted = await mount() - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile unknown rejection is the scenario + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- hostile unknown rejection is the scenario FakeQuery.sessionSearch = () => Promise.reject(failure()) const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts index beed98ec45..ba608314f2 100644 --- a/packages/session-title/session-title-llm/tests/llm.spec.ts +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -36,7 +36,7 @@ class CooperativeAdapter extends LlmAdapter { if (signal === undefined) throw new Error('expected title request signal') await new Promise((_resolve, reject) => { const rejectAbort = (): void => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation reject(signal.reason) } if (signal.aborted) { diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index bb3d7be664..d1fa4602be 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -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/skill/skill-local/README.md -README.md: d4b6253c6667f4786d53ad6c291f9e96f82546c3 -README.zh.md: 0c02080fce49c13c4a128676c39b793f3054fdb8 +README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 +README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index d4b6253c66..2077cf852f 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -50,7 +50,9 @@ The first-party filesystem `write` and `edit` tools also synchronously invalidat ## Skill Format -Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case. +Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case. + +The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation. The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 0c02080fce..ba1c71f1bc 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -50,7 +50,9 @@ ## Skill 格式 -Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 Markdown 文件(`.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。 +Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 Markdown 文件(`.md`)。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。 + +这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。 目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index add2f8792c..a19fa1dde5 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -24,6 +24,7 @@ import { isSkillName, type SkillCandidate, type SkillDefinition, + type SkillInvocationPolicy, type SkillLookupOptions, type SkillProvider, type SkillProviderControl, @@ -100,7 +101,7 @@ interface ParsedSkill { name: string description: string whenToUse?: string - disableModelInvocation?: boolean + invocation: SkillInvocationPolicy metadata?: Record content: string } @@ -197,7 +198,7 @@ export class LocalSkillProvider implements SkillProvider { name: parsed.name, description: parsed.description, ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}, - ...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {}, + invocation: parsed.invocation, source: candidate.source, provider: this.name, resourceBase: { kind: 'directory', path: locator.directory }, @@ -380,7 +381,7 @@ class SkillWatchManager { const current = await resolveRootWatchMode(state.root.path) // A child unlink can publish an empty catalog before root unlinkDir arrives. // Discovery therefore revalidates the retained handle independently. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits + // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return } await this.replaceWatcher(state) @@ -397,7 +398,7 @@ class SkillWatchManager { /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ if (watcher === undefined) return /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (this.closing || state.owners.size === 0) { await this.closeWatcher(watcher) return @@ -406,7 +407,7 @@ class SkillWatchManager { state.watcher = watcher state.unhealthy = false } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (!this.closing) { state.unhealthy = true this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) @@ -709,7 +710,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise, key: string): { [K in typ return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} } -function optionalBoolean(data: Record, key: string): { [K in typeof key]?: boolean } { +function parseInvocationPolicy(data: Record): SkillInvocationPolicy { + rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation') + rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation') + rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable') + const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation') + const userInvocable = frontmatterBoolean(data, 'user-invocable') + return { + modelInvocable: disableModelInvocation !== true, + userInvocable: userInvocable !== false, + } +} + +function rejectLegacyInvocationKey(data: Record, legacy: string, canonical: string): void { + if (Object.hasOwn(data, legacy)) { + throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`) + } +} + +function frontmatterBoolean(data: Record, key: string): boolean | undefined { + if (!Object.hasOwn(data, key)) return undefined const value = data[key] - return typeof value === 'boolean' ? { [key]: value } : {} + if (typeof value === 'boolean') return value + if (value === 1 || value === '1') return true + if (value === 0 || value === '0') return false + if (typeof value === 'string') { + switch (value.toLowerCase()) { + case 'true': + case 'yes': + case 'on': + return true + case 'false': + case 'no': + case 'off': + return false + } + } + throw new TypeError(`frontmatter field "${key}" must be a boolean`) } function optionalMetadata(data: Record): { metadata?: Record } { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index b0d608de6e..ae57df3bca 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -221,7 +221,7 @@ describe('LocalSkillProvider', () => { expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins') }) - it('parses flat skills and filters invalid or model-disabled skills from listing', async () => { + it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => { const home = await tempDir('skill-flat') const root = join(home, '.dsh/skills') await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.') @@ -230,7 +230,8 @@ describe('LocalSkillProvider', () => { 'name: rich-skill', 'description: rich description', 'whenToUse: For richer local parsing', - 'disableModelInvocation: false', + 'disable-model-invocation: off', + 'user-invocable: YES', 'metadata:', ' owner: tests', '---', @@ -246,8 +247,10 @@ describe('LocalSkillProvider', () => { await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---') await writeFile(join(root, 'notes.txt'), 'ignored') await mkdir(join(root, 'not-a-skill'), { recursive: true }) - await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.') - await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n') + await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.') + await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n') + await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.') + await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n') const ctx = await setupLocal(home) const listedBeforeDelete = await ctx.skills.list() @@ -255,17 +258,99 @@ describe('LocalSkillProvider', () => { if (flatSummary === undefined) throw new Error('expected flat-skill') await rm(join(root, 'flat-skill.md')) - expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill']) + expect(listedBeforeDelete.map(skill => skill.name)).toEqual([ + 'flat-skill', + 'model-only-skill', + 'no-trailing-body', + 'rich-skill', + 'user-only-skill', + ]) + expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true }) expect(await ctx.skills.get('flat-skill')).toBeUndefined() - expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.') + expect(await ctx.skills.get('no-trailing-body')).toMatchObject({ + invocation: { modelInvocable: true, userInvocable: true }, + }) + expect(await ctx.skills.get('user-only-skill')).toMatchObject({ + invocation: { modelInvocable: false, userInvocable: true }, + content: 'User-only.', + }) + expect(await ctx.skills.get('model-only-skill')).toMatchObject({ + invocation: { modelInvocable: true, userInvocable: false }, + content: 'Model-only.', + }) expect(await ctx.skills.get('rich-skill')).toMatchObject({ whenToUse: 'For richer local parsing', - disableModelInvocation: false, + invocation: { modelInvocable: true, userInvocable: true }, metadata: { owner: 'tests' }, }) expect(await ctx.skills.get('Bad_Name')).toBeUndefined() }) + it('accepts the documented boolean spellings for invocation frontmatter', async () => { + const home = await tempDir('skill-invocation-booleans') + const root = join(home, '.dsh/skills') + await mkdir(root, { recursive: true }) + const truthy = ['true', 'TRUE', '"true"', 'yes', 'ON', '1', '"1"'] + const falsy = ['false', 'FALSE', '"false"', 'no', 'OFF', '0', '"0"'] + for (const [index, value] of truthy.entries()) { + await writeFile(join(root, `truthy-${index}.md`), [ + '---', + `name: truthy-${index}`, + `description: Truthy ${index}`, + `disable-model-invocation: ${value}`, + '---', + '', + 'Truthy.', + ].join('\n')) + } + for (const [index, value] of falsy.entries()) { + await writeFile(join(root, `falsy-${index}.md`), [ + '---', + `name: falsy-${index}`, + `description: Falsy ${index}`, + `user-invocable: ${value}`, + '---', + '', + 'Falsy.', + ].join('\n')) + } + + const ctx = await setupLocal(home) + + for (const [index] of truthy.entries()) { + expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ + modelInvocable: false, + userInvocable: true, + }) + } + for (const [index] of falsy.entries()) { + expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ + modelInvocable: true, + userInvocable: false, + }) + } + }) + + it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => { + const home = await tempDir('skill-invalid-invocation') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'good-skill', 'Good skill') + const invalid = [ + ['legacy-model', 'disableModelInvocation: true'], + ['legacy-positive-model', 'modelInvocable: false'], + ['legacy-user', 'userInvocable: false'], + ['bad-string', 'disable-model-invocation: maybe'], + ['bad-value', 'user-invocable: null'], + ] as const + for (const [name, field] of invalid) { + await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`) + } + + const ctx = await setupLocal(home) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill']) + }) + it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => { const home = await tempDir('skill-frontmatter-crlf') const root = join(home, '.dsh/skills') diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d39e7a1505..dbb25eb911 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -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/skill/skill/README.md -README.md: 65ff110999ea416648f3d676eb813dd4ceb194f8 -README.zh.md: 79e77d2a2846489125ba0ecaafc138172ba2fd86 +README.md: f538ae668ccff291be86348627d5547150f460df +README.zh.md: 8a44f684ea4d9519a0af7866d272a8e7834aeda6 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 65ff110999..f538ae668c 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,10 +11,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. -- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. -- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. -- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. +- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. +- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. +- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Events @@ -26,6 +26,19 @@ This package owns the `ctx.skills` interface. It does not know whether skills co |---|---|---| | `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. | +### Invocation policy + +`SkillSummary.invocation` is a required typed policy object whose positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently. Providers return this resolved shape on every candidate and definition; only the `SkillRegistration` input may omit it, in which case `register()` supplies `{ modelInvocable: true, userInvocable: true }`. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs. + +| Policy | Model | User | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | included | included | +| `{ modelInvocable: true, userInvocable: false }` | included | excluded | +| `{ modelInvocable: false, userInvocable: true }` | excluded | included | +| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | + +`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. + ## Provider Contract A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. @@ -38,7 +51,7 @@ Definitions remain progressively loaded. `get()` asks the winning provider for t ## Runtime Skills -`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. +`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service materializes one top-level definition to supply omitted invocation and provider defaults. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. ## Consumer boundary diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 79e77d2a28..8a44f684ea 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,10 +11,10 @@ ### 公开 API - `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 -- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 -- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 -- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 +- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 +- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 +- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 ### 事件 @@ -26,6 +26,19 @@ |---|---|---| | `collectCacheMaxEntries` | `128` | 内存中保留的最大已完成 cwd/提供方目录数。 | +### 调用策略 + +`SkillSummary.invocation` 是一个必填的类型化策略对象,其正向布尔字段 `modelInvocable` 和 `userInvocable` 分别描述两个接口。提供方会在每个候选项和定义中返回这一已解析形状;只有 `SkillRegistration` 输入可以省略它,此时 `register()` 会补入 `{ modelInvocable: true, userInvocable: true }`。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。 + +| 策略 | 模型 | 用户 | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | +| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 | +| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | +| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | + +`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 + ## 提供方契约 提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现由提供方可等待的 `list(options)` 调用执行。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 @@ -38,7 +51,7 @@ ## 运行时 skill -`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化提供默认 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。 +`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化补入默认调用策略和 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。 ## 消费方边界 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 2cc37a6683..32f7112542 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -37,16 +37,24 @@ export type SkillResourceBase = | { readonly kind: 'url'; readonly url: string } | { readonly kind: 'opaque'; readonly description: string } -/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ +/** Invocation controls shared by skill discovery consumers. */ +export interface SkillInvocationPolicy { + /** Whether model-facing catalogs and loaders include this skill. */ + readonly modelInvocable: boolean + /** Whether human-facing command catalogs and loaders include this skill. */ + readonly userInvocable: boolean +} + +/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */ export interface SkillSummary { - /** Kebab-case identifier used with the `skill` tool. */ + /** Kebab-case identifier used to address the skill. */ readonly name: string - /** Short routing description shown to the model. */ + /** Short routing description shown by discovery consumers. */ readonly description: string - /** Optional extra routing guidance shown to the model. */ + /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ - readonly disableModelInvocation?: boolean + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -78,7 +86,12 @@ export interface SkillDefinition extends SkillSummary { } /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -export type SkillRegistration = Omit & { readonly provider?: string } +export type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} /** Caller context used for cwd-sensitive and abortable provider work. */ export interface SkillLookupOptions { @@ -88,9 +101,27 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } +/** + * Return whether a skill may be advertised to and loaded by a model. + * @param skill - skill metadata carrying resolved invocation controls. + * @returns whether the policy permits model invocation. + */ +export function isModelInvocable(skill: Pick): boolean { + return skill.invocation.modelInvocable +} + +/** + * Return whether a skill may be advertised to and loaded by a human-facing command. + * @param skill - skill metadata carrying resolved invocation controls. + * @returns whether the policy permits user invocation. + */ +export function isUserInvocable(skill: Pick): boolean { + return skill.invocation.userInvocable +} + /** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries collected in this observation. */ + /** Sorted invocation-neutral summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed without a concurrent catalog revision. */ readonly complete: boolean @@ -172,7 +203,7 @@ interface CollectResult { /** * Registry of skill providers. It merges provider catalogs with stable - * first-wins duplicate handling, exposes sorted model-visible summaries, and + * first-wins duplicate handling, exposes sorted invocation-neutral summaries, and * loads full skill bodies on demand. */ export class SkillService extends Service { @@ -182,7 +213,7 @@ export class SkillService extends Service { private readonly collectCacheMaxEntries: number private readonly providers = new Map() - private readonly runtime = new Map() + private readonly runtime = new Map() private readonly collectCache = new Map() private providerRevision = 0 private nextProviderOrder = 0 @@ -236,7 +267,7 @@ export class SkillService extends Service { invalidateCache() } }, 'skills.registerProvider()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity return dispose } catch (error) { lifecycle.abort(error) @@ -248,7 +279,7 @@ export class SkillService extends Service { * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and * receives a no-op disposer so it cannot remove the winner. - * @param skill - the complete skill definition to expose for discovery. + * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { @@ -258,36 +289,42 @@ export class SkillService extends Service { this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`) return () => {} } + const definition: SkillDefinition = { + ...skill, + invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true }, + provider: skill.provider ?? RUNTIME_PROVIDER, + } const runtime = this.runtime const updateRevision = (): void => { this.runtimeRevision += 1 } const invalidateCache = (): void => { this.invalidateCache() } const dispose = this.ctx.effect(function* () { - runtime.set(skill.name, skill) + runtime.set(definition.name, definition) updateRevision() invalidateCache() yield () => { - runtime.delete(skill.name) + runtime.delete(definition.name) updateRevision() invalidateCache() } }, 'skills.register()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * List model-invocable skill summaries for a workspace. Lookup options and - * provider candidates are readonly same-process values borrowed throughout - * discovery. + * List invocation-neutral skill summaries for a workspace. Consumers apply + * model or user invocation policy at their operational boundary. Lookup + * options and provider candidates are readonly same-process values borrowed + * throughout discovery. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries, excluding skills disabled for model invocation. + * @returns all sorted winning summaries. */ async list(options: SkillLookupOptions = {}): Promise { return (await this.snapshot(options)).skills } /** - * Observe the current model-invocable catalog and whether discovery completed within a stable revision. + * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. @@ -298,7 +335,6 @@ export class SkillService extends Service { return { skills: collected.entries .map(entry => entry.candidate) - .filter(skill => skill.disableModelInvocation !== true) .map(toSummary) .sort(compareSkillSummary), complete: collected.cacheable, @@ -466,19 +502,18 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = { return Promise.resolve([]) }, get(candidate) { - const skill = candidate.locator as SkillRegistration - return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER }) + return Promise.resolve(candidate.locator as SkillDefinition) }, } -function runtimeCandidate(skill: SkillRegistration): SkillCandidate { +function runtimeCandidate(skill: SkillDefinition): SkillCandidate { return { name: skill.name, description: skill.description, ...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {}, - ...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {}, + invocation: skill.invocation, source: skill.source, - provider: skill.provider ?? RUNTIME_PROVIDER, + provider: skill.provider, ...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {}, rank: RUNTIME_RANK, locator: skill, @@ -500,9 +535,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi if (candidate.description.length === 0) { throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`) } - if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') { - throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`) - } + validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`) if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') { throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`) } @@ -526,6 +559,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi function validateRuntimeSkill(skill: SkillRegistration): void { if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`) if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`) + validateInvocation(skill.invocation, `runtime skill "${skill.name}"`) } /** Validate a definition loaded from a provider-controlled parser or remote source. */ @@ -533,7 +567,7 @@ function validateDefinition(skill: SkillDefinition): void { const name = skill.name const description = skill.description const whenToUse = skill.whenToUse - const disableModelInvocation = skill.disableModelInvocation + const invocation = skill.invocation const source = skill.source const provider = skill.provider const content = skill.content @@ -542,9 +576,7 @@ function validateDefinition(skill: SkillDefinition): void { if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`) if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`) if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`) - if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { - throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`) - } + validateInvocation(invocation, `loaded skill "${name}"`) if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`) if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`) if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`) @@ -553,18 +585,32 @@ function validateDefinition(skill: SkillDefinition): void { } function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary { - const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill + const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill return { name, description, ...whenToUse !== undefined ? { whenToUse } : {}, - ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, + invocation, source, provider, ...resourceBase !== undefined ? { resourceBase } : {}, } } +function validateInvocation(invocation: unknown, subject: string): void { + if (invocation === undefined) return + if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) { + throw new TypeError(`${subject} with a non-object invocation policy`) + } + const policy = invocation as Record + if (typeof policy.modelInvocable !== 'boolean') { + throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`) + } + if (typeof policy.userInvocable !== 'boolean') { + throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`) + } +} + function compareSkillSummary(left: SkillSummary, right: SkillSummary): number { return compareCodePoints(left.name, right.name) } diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index ac3df3aa35..39ef384f3c 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,11 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill' +import SkillService, { + isModelInvocable, + isUserInvocable, + type SkillCandidate, + type SkillDefinition, + type SkillInvocationPolicy, + type SkillLookupOptions, + type SkillProvider, + type SkillProviderObservation, +} from '@deepseek-ai/dsh-skill' function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate { return { name, description, + invocation: { modelInvocable: true, userInvocable: true }, provider: 'memory', source: 'memory', rank, @@ -53,6 +63,7 @@ describe('SkillService registry', () => { return [{ name: 'shadowed', description: 'Higher priority', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'override', source: 'override', rank: 5, @@ -78,6 +89,7 @@ describe('SkillService registry', () => { return [{ name: 'same-rank-skill', description: 'Same rank', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'same-rank', source: 'same-rank', rank: 10, @@ -139,6 +151,34 @@ describe('SkillService registry', () => { expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed']) }) + it('returns an invocation-neutral catalog and resolves model and user policy independently', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const registrations = [ + { name: 'both', invocation: undefined }, + { name: 'model-only', invocation: { modelInvocable: true, userInvocable: false } }, + { name: 'user-only', invocation: { modelInvocable: false, userInvocable: true } }, + { name: 'trusted-only', invocation: { modelInvocable: false, userInvocable: false } }, + ] as const + for (const registration of registrations) { + ctx.skills.register({ + name: registration.name, + description: registration.name, + source: 'runtime', + ...registration.invocation === undefined ? {} : { invocation: registration.invocation }, + content: `${registration.name} body.`, + }) + } + + const listed = await ctx.skills.list() + expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only']) + expect(listed.find(skill => skill.name === 'both')?.invocation).toEqual({ modelInvocable: true, userInvocable: true }) + expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only']) + expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only']) + expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' }) + expect((await ctx.skills.get('both'))?.invocation).toEqual({ modelInvocable: true, userInvocable: true }) + }) + it('validates parsed candidate fields', async () => { const ctx = new Context() await ctx.plugin(SkillService) @@ -149,7 +189,7 @@ describe('SkillService registry', () => { ...memorySkill('bad-candidate', 'placeholder', 1), provider: 'bad-candidate', description: badDescription as unknown as string, - disableModelInvocation: 'false' as unknown as boolean, + invocation: { modelInvocable: false, userInvocable: true }, }]), get: () => Promise.resolve(undefined), }) @@ -162,11 +202,11 @@ describe('SkillService registry', () => { list: () => Promise.resolve([{ ...memorySkill('bad-boolean', 'Bad boolean', 1), provider: 'bad-boolean', - disableModelInvocation: 'false' as unknown as boolean, + invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true }, }]), get: () => Promise.resolve(undefined), }) - await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation') + await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable') }) it('rejects malformed provider results and every malformed candidate scalar', async () => { @@ -198,7 +238,7 @@ describe('SkillService registry', () => { name: `candidate-${index}`, description: 'Candidate', whenToUse: 'Use this candidate.', - disableModelInvocation: false, + invocation: { modelInvocable: true, userInvocable: true }, provider: providerName, source: 'test', rank: 1, @@ -225,6 +265,7 @@ describe('SkillService registry', () => { const candidate: SkillCandidate = { name: 'skill-a', description: 'Skill A', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'contextual', source: 'test', rank: 1, @@ -259,6 +300,7 @@ describe('SkillService registry', () => { return [{ name: 'cached-skill', description: 'Cached skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'cached', source: 'test', rank: 1, @@ -296,6 +338,7 @@ describe('SkillService registry', () => { resolve({ name: 'held-skill', description: 'Held skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'held', source: 'test', content: 'Held body.', @@ -308,6 +351,7 @@ describe('SkillService registry', () => { return [{ name: 'held-skill', description: 'Held skill', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'held', source: 'test', rank: 1, @@ -355,11 +399,12 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const locator = { id: 'provider-owned' } + const invocation = { modelInvocable: true, userInvocable: true } const candidate: SkillCandidate = { name: 'stable-skill', description: 'Stable description', whenToUse: 'When stability matters.', - disableModelInvocation: false, + invocation, provider: 'detached', source: 'test', resourceBase: { kind: 'opaque', description: 'candidate resources' }, @@ -372,7 +417,7 @@ describe('SkillService registry', () => { name: 'stable-skill', description: 'Stable description', whenToUse: 'When stability matters.', - disableModelInvocation: false, + invocation, provider: 'detached', source: 'test', resourceBase: { kind: 'opaque', description: 'definition resources' }, @@ -401,6 +446,7 @@ describe('SkillService registry', () => { resourceBase: { kind: 'opaque', description: 'candidate resources' }, })]) expect(listed[0]?.resourceBase).toBe(candidate.resourceBase) + expect(listed[0]?.invocation).toBe(invocation) expect(listCalls).toBe(1) const loaded = await ctx.skills.get('stable-skill') @@ -414,11 +460,12 @@ describe('SkillService registry', () => { await ctx.plugin(SkillService) const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' } const metadata = { owner: 'runtime' } + const invocation = { modelInvocable: true, userInvocable: true } const registration = { name: 'runtime-skill', description: 'Runtime', whenToUse: 'When runtime data is needed.', - disableModelInvocation: false, + invocation, source: 'runtime', resourceBase, metadata, @@ -434,6 +481,7 @@ describe('SkillService registry', () => { const listed = await ctx.skills.list() const loaded = await ctx.skills.get('runtime-skill') expect(listed[0]?.resourceBase).toBe(resourceBase) + expect(listed[0]?.invocation).toBe(invocation) expect(loaded?.resourceBase).toBe(resourceBase) expect(loaded?.metadata).toBe(metadata) expect(loaded?.provider).toBe('runtime') @@ -445,7 +493,23 @@ describe('SkillService registry', () => { { patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' }, { patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' }, { patch: { description: '' }, expected: 'requires a description' }, - { patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' }, + { patch: { invocation: null as never }, expected: 'non-object invocation policy' }, + { + patch: { invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true } }, + expected: 'invocation.modelInvocable', + }, + { + patch: { invocation: { modelInvocable: true, userInvocable: 'true' as unknown as boolean } }, + expected: 'invocation.userInvocable', + }, + { + patch: { invocation: { userInvocable: true } as unknown as SkillInvocationPolicy }, + expected: 'invocation.modelInvocable', + }, + { + patch: { invocation: { modelInvocable: true } as unknown as SkillInvocationPolicy }, + expected: 'invocation.userInvocable', + }, { patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' }, { patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' }, { patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' }, @@ -462,6 +526,7 @@ describe('SkillService registry', () => { list: () => Promise.resolve([{ name: skillName, description: 'Candidate', + invocation: { modelInvocable: true, userInvocable: true }, provider: providerName, source: 'test', rank: 1, @@ -471,7 +536,7 @@ describe('SkillService registry', () => { name: skillName, description: 'Definition', whenToUse: 'Use this definition.', - disableModelInvocation: false, + invocation: { modelInvocable: true, userInvocable: true }, provider: providerName, source: 'test', content: 'Definition body.', @@ -696,7 +761,7 @@ describe('SkillService registry', () => { const warnings: string[] = [] ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') }) - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment + // oxlint-disable-next-line typescript/no-misused-promises -- deliberate rejection proves notification containment const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected'))) let observed = 0 const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) @@ -771,6 +836,7 @@ describe('SkillService registry', () => { skills: [{ name: 'bounded-skill', description: 'Attempt 2', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'self-invalidating', source: 'memory', }], @@ -793,6 +859,7 @@ describe('SkillService registry', () => { return [{ name: 'old-name', description: 'Old name', + invocation: { modelInvocable: true, userInvocable: true }, provider: 'renamed', source: 'test', rank: 1, @@ -840,7 +907,7 @@ describe('SkillService registry', () => { name: 'hostile-failure', list() { // Deliberately violate the provider contract to prove containment is total. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + // oxlint-disable-next-line typescript/prefer-promise-reject-errors return Promise.reject(hostileFailure) }, async get() { @@ -928,6 +995,13 @@ describe('SkillService registry', () => { await ctx.plugin(SkillService) expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name') expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description') + expect(() => ctx.skills.register({ + name: 'bad-invocation', + description: 'Bad invocation', + source: 'runtime', + invocation: [] as never, + content: 'bad', + })).toThrow('non-object invocation policy') expect(await ctx.skills.get('missing-skill')).toBeUndefined() expect(await ctx.skills.get('Bad_Name')).toBeUndefined() diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 0899caca67..9d706de673 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -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/skill/tool-skill/README.md -README.md: 53f1494b8d0a348910ff7fb3965ae1798fc3b3b8 -README.zh.md: f30d47fa1f774c449069278d17e8c6c5c3d1a43d +README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce +README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 53f1494b8d..d8e00bc839 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -26,7 +26,7 @@ Execution uses the calling agent's `session.header.cwd` so workspace-sensitive p Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance. -An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. +An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface. Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index f30d47fa1f..6c0df1d6e3 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -26,7 +26,7 @@ 资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL;脚本、参考资料和资源文件按需加载,结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。 -无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。 +无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。 工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index f6f45b0316..efc352fd3f 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -9,9 +9,14 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' -import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm' +import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' +import { + isModelInvocable, + isSkillName, + type SkillDefinition, + type SkillSummary, +} from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' export const inject = ['agents', 'tools', 'skills'] @@ -92,11 +97,19 @@ export function apply(ctx: Context, config: Config = {}): void { if (!isSkillName(args.name)) { throw new Error(`invalid skill name "${args.name}"`) } - const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal }) + const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal } + const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name) + if (!summary) { + throw new Error(`skill "${args.name}" is unknown or no longer available`) + } + if (!isModelInvocable(summary)) { + throw new Error(`skill "${args.name}" is not available for model invocation`) + } + const skill = await ctx.skills.get(args.name, lookup) if (!skill) { throw new Error(`skill "${args.name}" is unknown or no longer available`) } - if (skill.disableModelInvocation === true) { + if (!isModelInvocable(skill)) { throw new Error(`skill "${args.name}" is not available for model invocation`) } return { @@ -128,13 +141,14 @@ export function apply(ctx: Context, config: Config = {}): void { : { skills: [], complete: true } signal.throwIfAborted() if (!snapshot.complete) return - const digest = catalogDigest(snapshot.skills, catalogDescriptionMaxLength) + const skills = snapshot.skills.filter(isModelInvocable) + const digest = catalogDigest(skills, catalogDescriptionMaxLength) const history = catalogHistory(agent) if (history.visibleDigest === digest) return - if (!history.published && snapshot.skills.length === 0) return + if (!history.published && skills.length === 0) return const catalog = history.published - ? renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength) - : renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength) + ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) + : renderCatalogMessage(skills, catalogDescriptionMaxLength) agent.inject(catalog) }) } @@ -254,7 +268,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool let published = false for (let index = events.length - 1; index >= 0; index -= 1) { // The loop bounds prove the read-only event view contains this index. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 3962b98f28..48e7b942b1 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -187,6 +187,20 @@ describe('dsh-tool-skill', () => { provider: 'runtime', content: 'A body.', }) + ctx.skills.register({ + name: 'model-only-skill', + description: 'Model-only skill.', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + content: 'Model-only body.', + }) + ctx.skills.register({ + name: 'user-only-skill', + description: 'User-only skill.', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'runtime', + content: 'User-only body.', + }) ctx.on('agent/step', (agent) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } })) }) @@ -206,6 +220,7 @@ describe('dsh-tool-skill', () => { '', '', '- `a-skill`: Use {{placeholder}} <safely> & carefully.', + '- `model-only-skill`: Model-only skill.', '- `z-skill`: Long description Long description Long descript...', '', '', @@ -226,14 +241,24 @@ describe('dsh-tool-skill', () => { expect(rendered).not.toContain('secret-source') expect(rendered).not.toContain('/secret/path') expect(rendered).not.toContain('Secret body') + expect(rendered).not.toContain('user-only-skill') expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('') }) - it('does not inject a catalog when no skills are available', async () => { + it('does not inject a catalog when no model-invocable skills are available', async () => { const home = await tempDir('tool-empty-catalog') const ctx = await setup(home) + ctx.skills.register({ + name: 'user-only-skill', + description: 'User-only skill', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'runtime', + content: 'User-only body.', + }) - expect(await composePrefix(ctx, '/workspace')).toEqual([]) + const agent = agentForCwd('/workspace') + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) }) it('omits an incomplete initial catalog and retries on a later request boundary', async () => { @@ -603,18 +628,94 @@ describe('dsh-tool-skill', () => { it('returns isError for unknown, invalid, and model-disabled skills', async () => { const home = await tempDir('tool-errors') await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.') - await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n') + await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n') const ctx = await setup(home) + ctx.skills.register({ + name: 'model-only-skill', + description: 'Model-only skill', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + content: 'Model-only instructions.', + }) const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) + const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } }) expect(unknown.isError).toBe(true) expect(invalid.isError).toBe(true) expect(disabled.isError).toBe(true) + expect(modelOnly.isError).toBe(false) const unknownBlock = unknown.content[0] if (unknownBlock?.type !== 'text') throw new Error('expected text tool result') expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available') }) + + it('checks model policy before provider loading and rechecks the loaded definition', async () => { + const home = await tempDir('tool-policy-before-load') + const ctx = await setup(home) + const getCalls: string[] = [] + ctx.skills.registerProvider(() => ({ + name: 'policy-probe', + async list() { + return [ + { + name: 'denied-skill', + description: 'Denied skill', + invocation: { modelInvocable: false, userInvocable: true }, + provider: 'policy-probe', + source: 'test', + rank: 1, + locator: 'denied-skill', + }, + { + name: 'policy-race-skill', + description: 'Policy race skill', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'policy-probe', + source: 'test', + rank: 1, + locator: 'policy-race-skill', + }, + { + name: 'vanishing-skill', + description: 'Vanishing skill', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'policy-probe', + source: 'test', + rank: 1, + locator: 'vanishing-skill', + }, + ] + }, + async get(candidate) { + getCalls.push(candidate.name) + if (candidate.name === 'vanishing-skill') return undefined + return { + ...candidate, + invocation: { modelInvocable: false, userInvocable: true }, + content: 'Instructions must not be disclosed.', + } + }, + })) + + const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } }) + const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } }) + const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } }) + + expect(denied.isError).toBe(true) + expect(raced.isError).toBe(true) + expect(vanished.isError).toBe(true) + expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill']) + for (const result of [denied, raced]) { + const block = result.content[0] + if (block?.type !== 'text') throw new Error('expected text tool result') + expect(block.text).toContain('is not available for model invocation') + expect(block.text).not.toContain('Instructions must not be disclosed.') + } + const vanishedBlock = vanished.content[0] + if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result') + expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') + }) }) diff --git a/packages/storage/storage-json/src/unit.ts b/packages/storage/storage-json/src/unit.ts index 9591c30573..99b895de05 100644 --- a/packages/storage/storage-json/src/unit.ts +++ b/packages/storage/storage-json/src/unit.ts @@ -56,7 +56,7 @@ class JsonKvUnit implements KvUnit { private readonly onClose: () => void, ) {} - // eslint-disable-next-line @typescript-eslint/require-await -- async keeps the closed guard a rejection, not a synchronous throw + // oxlint-disable-next-line typescript/require-await -- async keeps the closed guard a rejection, not a synchronous throw async loadAll(): Promise<{ tables: Record>; global: unknown }> { this.assertOpen() const tables: Record> = {} diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts index 4a24d88cc5..5312513591 100644 --- a/packages/storage/storage/src/index.ts +++ b/packages/storage/storage/src/index.ts @@ -46,7 +46,7 @@ export interface StorageForms {} */ export class Storage extends Service { /** Named backend table; multiple backends stay mounted side by side. */ - readonly backend = new BackendRegistry() + readonly backend: BackendRegistry = new BackendRegistry() private readonly forms = new Map() diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index c6f9b4059b..8d659ae73b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -145,7 +145,7 @@ export async function startInProcessRun( // Close the narrow handoff race before installing the live-run listener. // Static analysis does not model the abort that may land between the // factory's listener detachment and this continuation. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (request.signal.aborted) { flags.cancelled = true await handle.dispose() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 606b45b564..1267f276ab 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -194,7 +194,7 @@ export class SubagentService extends Service { */ registerProvider(provider: SubagentProvider): () => void { const name = provider.name - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return this.ctx.effect(function* (this: SubagentService) { if (this.providers.has(name)) { throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER') diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 66008a923b..5b9eeaaa0f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -226,7 +226,7 @@ describe('SubagentService', () => { const heard: string[] = [] ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') }) // Runtime listeners may return thenables even though the declaration's observable result is void. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') }) ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } }) ctx.on('subagent/provider-removed', name => void heard.push(name)) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 2e6194b59b..9e5c30ea60 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -192,7 +192,7 @@ export class InvariantService extends Service { } // Cordis attaches setup thenability and async teardown to this callable; // the service seam intentionally exposes only the conventional disposer. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private. + // oxlint-disable-next-line typescript/no-misused-promises -- the extra runtime shape stays private. return registration } } diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml new file mode 100644 index 0000000000..e72d20ed78 --- /dev/null +++ b/packages/typert/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/typert/README.md +README.md: d11fd8f57245379d67d2a1cdcca334f0032db469 +README.zh.md: 97e57f9585efa2e86edc1edf576fef4738b63203 diff --git a/packages/typert/README.md b/packages/typert/README.md new file mode 100644 index 0000000000..d11fd8f572 --- /dev/null +++ b/packages/typert/README.md @@ -0,0 +1,11 @@ +# Typert + +English | [中文](README.zh.md) + +Typert separates source analysis, runtime storage, and Loader discovery into independent packages. + +| Package | Role | Cordis key | +|---|---|---| +| [`registry/`](registry/README.md) | Runtime package reflection and live Zod schema registry | `ctx.typert` | +| [`loader/`](loader/README.md) | Loader-entry discovery and generated host-artifact registration | consumes `ctx.loader`, `ctx.typert` | +| [`generator/`](generator/README.md) | Compiler-independent type analysis and artifact generation | build-time library | diff --git a/packages/typert/README.zh.md b/packages/typert/README.zh.md new file mode 100644 index 0000000000..97e57f9585 --- /dev/null +++ b/packages/typert/README.zh.md @@ -0,0 +1,11 @@ +# Typert + +[English](README.md) | 中文 + +Typert 将源代码分析、运行时存储和 Loader 发现机制拆分为彼此独立的包(package)。 + +| 包 | 职责 | Cordis 键 | +|---|---|---| +| [`registry/`](registry/README.md) | 运行时包反射和实时 Zod schema 注册表 | `ctx.typert` | +| [`loader/`](loader/README.md) | 发现 Loader 条目并注册所生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | +| [`generator/`](generator/README.md) | 与编译器无关的类型分析和产物生成 | 构建时库 | diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml new file mode 100644 index 0000000000..b098728811 --- /dev/null +++ b/packages/typert/generator/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/typert/generator/README.md +README.md: c343fd9475a9407159037f0a10e3a0586a77c3da +README.zh.md: e00abe205e5c5c33e7e0028606df169d447e4006 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md new file mode 100644 index 0000000000..c343fd9475 --- /dev/null +++ b/packages/typert/generator/README.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-typert-generator + +English | [中文](README.zh.md) + +TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects. + +Host and client use independent `ts.Program` instances seeded from `tsconfig.host.json` and `tsconfig.client.json`. Direct project references establish face membership, `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. + +## Analysis Model + +Each face contains package exports, Cordis services and events, explicitly tagged objects and schemas, and a type graph for their reachable declarations. The graph preserves declaration identity, generic parameters and applications, explicit inheritance, conditional and mapped types, import attributes, abstract modifiers, and source JSDoc. Service and `@typert object` surfaces expose public instance members only; constructors, static members, and non-public members are excluded. + +`WorkspaceAnalyzer` defaults to `check` mode and fails on TypeScript syntax or semantic diagnostics, missing reachable public annotations, private cross-package references, and reachable declaration merges that the model cannot retain losslessly. `write` mode inserts checker-derived annotations, rebuilds the program, and returns a clean check-mode model. + +## Emission and Opt-in Publication + +`FaceModelEmitter` consumes only the model. It emits executable JavaScript containing supported Zod schemas and a `TYPERT` contribution, plus a declaration file whose schemas are typed as `z.ZodType` through the package's public export. Unsupported Zod projections fail instead of flattening or weakening the source type. + +`WorkspaceTypertGenerator` discovers contributors by walking package public exports reachable from Cordis `Context` or `Events` augmentations and explicit `@typert` declarations. When invoked for artifact publication, it requires host artifacts at `lib/typert.host.{js,d.ts}` exposed as `package/typert`, and client artifacts at `lib/typert.client.{js,d.ts}` exposed as `package/client/typert`. Generated declarations expose `TYPERT` as `unknown`, so contributing business packages do not depend on the runtime registry. + +Publication is package opt-in. The root build and typecheck do not generate Typert artifacts or require every business package to add Typert exports. Static consumers can call `WorkspaceAnalyzer` directly, select host/client and package subsets, and use bounded package batches without publishing or loading runtime artifacts. + +## Repository-specific Cordis projection + +The root package export includes the model-driven extraction, completeness checks, and deterministic text renderers used by this repository's Cordis catalogs. They accept a `CordisCatalogPolicy`; repository-owned type links, foundation/exemption classifications, and inherited Cordis entries remain in `scripts/gen-cordis-catalog.ts` and are passed in explicitly. The generator package therefore contains projection mechanics, not a hidden copy of this repository's documentation taxonomy. + +## Model Experience + +None, as this package runs at build or test time and never contributes to a model request. + +#### KV Cache effect + +None. + +## Known Limitations and Deferred Work + +- Package export patterns are skipped; contributing packages need concrete export targets. +- Cross-face named and star re-exports produce links; namespace re-exports fail until `TypeTargetModel` can represent a module namespace without flattening it. +- The Zod emitter supports a deliberate subset of the modeled TypeScript graph. Generic schema declarations and computed constructs such as conditional or mapped schema roots fail until a concrete schema-factory policy exists. +- Cross-face links are represented for analysis, but no generated schema currently requires a runtime cross-face Zod import. +- Discovery follows source files reachable from concrete public exports; declarations that are neither exported nor imported by that graph are intentionally outside the package model. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md new file mode 100644 index 0000000000..e00abe205e --- /dev/null +++ b/packages/typert/generator/README.zh.md @@ -0,0 +1,41 @@ +# @deepseek-ai/dsh-typert-generator + +[English](README.md) | 中文 + +TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。 + +宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包(package)所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 + +## 分析模型 + +每个 face 包含包导出、Cordis 服务与事件、显式标记的对象与 schema,以及涵盖其可达声明的类型图。类型图保留声明标识、泛型参数及应用、显式继承、条件类型与映射类型、导入属性、abstract 修饰符和源码 JSDoc。服务和 `@typert object` 对外接口仅暴露公共实例成员;构造函数、静态成员与非公共成员均被排除。 + +`WorkspaceAnalyzer` 默认采用 `check` 模式,遇到 TypeScript 语法或语义诊断、可达公开声明缺少类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时,分析会失败。`write` 模式会插入类型检查器推导出的类型标注,重建该程序,并返回无诊断的检查模式模型。 + +## 产物生成与选择性发布 + +`FaceModelEmitter` 只消费模型。它会生成可执行 JavaScript,其中包含受支持的 Zod schema 和一个 `TYPERT` contribution;同时生成声明文件,通过包的公开导出将其中的 schema 标注为 `z.ZodType`。遇到不支持的 Zod 投影时,生成会失败,不会展平或弱化源类型。 + +`WorkspaceTypertGenerator` 会遍历从 Cordis `Context` 或 `Events` 扩充声明及显式 `@typert` 声明可达的包公开导出,以发现贡献方。发布产物时,它要求宿主侧产物位于 `lib/typert.host.{js,d.ts}` 并以 `package/typert` 暴露,客户端侧产物位于 `lib/typert.client.{js,d.ts}` 并以 `package/client/typert` 暴露。生成的声明将 `TYPERT` 暴露为 `unknown`,因此参与贡献的业务包无需依赖运行时注册表。 + +各包可自行选择是否发布。根目录的构建和类型检查不会生成 Typert 产物,也不要求每个业务包添加 Typert 导出。静态消费方可以直接调用 `WorkspaceAnalyzer`,选择宿主侧/客户端侧及包子集,并在不发布或加载运行时产物的情况下分批处理包,同时限制每批数量。 + +## 本仓库的 Cordis 投影 + +包根导出中包含本仓库 Cordis 目录使用的模型驱动提取逻辑、完整性检查和确定性文本渲染器。它们接受 `CordisCatalogPolicy`;由仓库持有的类型链接、基础类型/豁免类型分类和继承的 Cordis 条目仍位于 `scripts/gen-cordis-catalog.ts`,并由调用方显式传入。因此,生成器包只包含投影机制,不会隐式复制本仓库的文档分类体系。 + +## 模型体验 + +无。该包仅在构建或测试时运行,不会向模型请求添加任何内容。 + +#### KV Cache 影响 + +无。 + +## 已知限制与暂缓工作 + +- 系统会跳过包导出中的模式匹配;参与贡献的包需要具体的导出目标。 +- 跨 face 的具名重新导出和星号重新导出会生成链接;在 `TypeTargetModel` 能够不经展平便表示模块命名空间之前,命名空间重新导出会失败。 +- Zod 产物生成组件仅支持 TypeScript 类型图中有意限定的部分。泛型 schema 声明,以及以条件类型或映射类型为 schema 根的计算构造,都会失败,直到存在明确的 schema 工厂策略。 +- 跨 face 链接会在模型中表示以供分析,但当前生成的 schema 均不需要跨 face 的运行时 Zod 导入。 +- 发现过程会遍历从具体公开导出可达的源文件;既未导出、也未由该图导入的声明会按设计排除在包模型之外。 diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json new file mode 100644 index 0000000000..90fb32e5af --- /dev/null +++ b/packages/typert/generator/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-typert-generator", + "description": "TypeScript project analyzer and model-driven Typert artifact generator", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./tsdown": { + "types": "./lib/types/tsdown-plugin.d.ts", + "default": "./lib/types/tsdown-plugin.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "typescript": "^6.0.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts new file mode 100644 index 0000000000..32340ade20 --- /dev/null +++ b/packages/typert/generator/src/analyzer.ts @@ -0,0 +1,1894 @@ +/** + * TypeScript project analyzer for the compiler-independent Typert model. + * Programs, symbols, and syntax nodes remain extraction-only implementation + * details; callers receive only the model declared in {@link ./model.ts}. + * @module @deepseek-ai/dsh-typert-generator/analyzer + */ + +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, relative, resolve, sep } from 'node:path' +import ts from 'typescript' +import type { + CrossFaceLink, + DocumentationModel, + EventModel, + EnumMemberModel, + ExportModel, + FaceModel, + JsDocTagModel, + KeywordTypeName, + MemberBase, + MemberModel, + MemberVisibility, + ObjectModel, + PackageModel, + ParameterModel, + SchemaModel, + ServiceModel, + SignatureModel, + SourceDeclarationModel, + SourceLocation, + SymbolId, + TypeDeclarationModel, + TypeNodeId, + TypeNodeModel, + TypeOperatorName, + TypeParameterModel, + TypeTargetModel, + TypertFace, + WorkspaceModel, +} from './model.ts' + +type WithoutId = T extends { readonly id: TypeNodeId } ? Omit : never + +type TypeNodeInput = WithoutId + +/** Analysis failure with a source-oriented diagnostic. */ +export class TypertAnalysisError extends Error { + override name = 'TypertAnalysisError' +} + +class SourceEditQueued extends Error {} + +/** Missing-annotation handling at public business boundaries. */ +export type AnalysisMode = 'check' | 'write' + +/** Workspace analysis configuration. */ +export interface WorkspaceAnalyzerOptions { + /** Workspace root containing the face tsconfigs. */ + readonly root: string + /** Host aggregate path, relative to {@link root}; absent files are skipped. */ + readonly hostConfig?: string + /** Client aggregate path, relative to {@link root}; absent files are skipped. */ + readonly clientConfig?: string + /** Optional package-name subset for an incremental generation pass. */ + readonly packages?: readonly string[] + /** Independently compiled faces to materialize; both are analyzed by default. */ + readonly faces?: readonly TypertFace[] + /** Whether to repeat TypeScript project diagnostics before model extraction. */ + readonly checkDiagnostics?: boolean + /** Whether missing annotations fail or are written before a clean re-analysis. */ + readonly mode?: AnalysisMode +} + +/** One package face whose public export graph contains Typert business declarations. */ +export interface DiscoveredTypertPackage { + readonly package: string + readonly root: string + readonly faces: readonly TypertFace[] +} + +interface ParsedConfig { + readonly path: string + readonly parsed: ts.ParsedCommandLine +} + +interface PackageRegistration { + readonly face: TypertFace + readonly name: string + readonly root: string + readonly config: ParsedConfig + readonly manifest: Record + readonly exportSubpaths?: readonly string[] +} + +interface ExportRecord { + readonly model: ExportModel + readonly symbol: ts.Symbol + readonly declaration: ts.Declaration + readonly sourceFile: ts.SourceFile +} + +interface SourceEdit { + readonly file: string + readonly position: number + readonly text: string +} + +interface ModuleIdentity { + readonly package: string + readonly subpath: string +} + +type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode + +const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } + +/** Analyze host and client as independent TypeScript programs. */ +export class WorkspaceAnalyzer { + private readonly options: Required> & Pick + private queuedEdit: SourceEdit | undefined + private readonly crossFaceLinks = new Map() + private readonly checkedProjects = new Set() + private registrations: PackageRegistration[] = [] + + constructor(options: WorkspaceAnalyzerOptions) { + this.options = { + root: resolve(options.root), + hostConfig: options.hostConfig ?? 'tsconfig.host.json', + clientConfig: options.clientConfig ?? 'tsconfig.client.json', + faces: options.faces ?? ['host', 'client'], + checkDiagnostics: options.checkDiagnostics ?? true, + mode: options.mode ?? 'check', + ...(options.packages === undefined ? {} : { packages: options.packages }), + } + } + + /** + * Build the workspace model. Write mode applies inferred annotations and then + * returns a fresh check-mode analysis of the edited projects. + * @returns the independent face models and their explicit cross-face links. + */ + analyze(): WorkspaceModel { + this.registrations = this.loadRegistrations() + const selected = this.options.packages === undefined + ? undefined + : new Set(this.options.packages) + const faces: FaceModel[] = [] + try { + for (const face of this.options.faces) { + const registrations = this.registrations.filter(registration => + registration.face === face && (selected === undefined || selected.has(registration.name))) + if (registrations.length === 0) continue + if (this.options.checkDiagnostics) { + for (const registration of registrations) this.checkProject(registration) + } + const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig) + const aggregate = parseConfig(aggregatePath) + const rootNames = [...new Set(registrations.flatMap(registration => registration.config.parsed.fileNames))] + const program = ts.createProgram({ + rootNames, + options: { + ...aggregate.parsed.options, + composite: false, + incremental: false, + noEmit: true, + }, + }) + faces.push(new FaceAnalyzer({ + root: this.options.root, + face, + program, + registrations, + allRegistrations: this.registrations, + mode: this.options.mode, + queueEdit: (edit) => { this.queueEdit(edit) }, + crossFaceLinks: this.crossFaceLinks, + }).analyze()) + } + } catch (error) { + if (!(error instanceof SourceEditQueued) || this.options.mode !== 'write' || this.queuedEdit === undefined) throw error + } + + if (this.queuedEdit !== undefined) { + this.applyEdit(this.queuedEdit) + return new WorkspaceAnalyzer({ ...this.options, mode: 'write' }).analyze() + } + + if (this.options.mode === 'write') { + return new WorkspaceAnalyzer({ ...this.options, mode: 'check' }).analyze() + } + + return { + faces, + crossFaceLinks: [...this.crossFaceLinks.values()].sort(compareCrossFaceLinks), + } + } + + /** + * Analyze an explicit package selection through bounded compiler programs. + * The resulting model is identical in shape to {@link analyze}; stable graph + * ids let repeated dependency declarations merge without flattening types. + * @param batchSize - maximum selected packages in one face program. + * @returns one merged workspace model. + */ + analyzeInBatches(batchSize = 8): WorkspaceModel { + if (this.options.packages === undefined) { + throw new TypertAnalysisError('typert: batched analysis requires an explicit package selection') + } + if (!Number.isInteger(batchSize) || batchSize < 1) { + throw new TypertAnalysisError(`typert: batch size must be a positive integer, received ${String(batchSize)}`) + } + const batches: WorkspaceModel[] = [] + for (let index = 0; index < this.options.packages.length; index += batchSize) { + batches.push(new WorkspaceAnalyzer({ + ...this.options, + packages: this.options.packages.slice(index, index + batchSize), + }).analyze()) + } + return mergeWorkspaceModels(batches) + } + + /** + * Discover package faces from public-export-reachable Cordis augmentations + * and explicit `@typert` roots without constructing a type-checker program. + * @returns contributors grouped by package with deterministic face order. + */ + discoverPackages(): DiscoveredTypertPackage[] { + const registrations = this.loadRegistrations() + .filter(registration => this.options.faces.includes(registration.face)) + .filter(registration => this.registrationHasSurface(registration)) + const packages = new Map }>() + for (const registration of registrations) { + const current = packages.get(registration.name) ?? { + root: slash(relative(this.options.root, registration.root)), + faces: new Set(), + } + current.faces.add(registration.face) + packages.set(registration.name, current) + } + return [...packages] + .map(([packageName, value]) => ({ + package: packageName, + root: value.root, + faces: [...value.faces].sort(), + })) + .sort((left, right) => left.package.localeCompare(right.package)) + } + + /** + * Index top-level exported type declarations without promoting them to graph + * roots. Consumers use this lexical index for ambiguity checks while all + * semantic traversal continues through {@link TypeGraph}. + * @returns declarations from the selected faces and package projects. + */ + indexSourceDeclarations(): SourceDeclarationModel[] { + const selected = this.options.packages === undefined ? undefined : new Set(this.options.packages) + const declarations: SourceDeclarationModel[] = [] + for (const registration of this.loadRegistrations()) { + if (!this.options.faces.includes(registration.face) + || (selected !== undefined && !selected.has(registration.name))) continue + for (const file of registration.config.parsed.fileNames) { + const relativeFile = slash(relative(this.options.root, file)) + if (!existsSync(file) + || !isWithin(realPath(file), join(registration.root, 'src')) + || !/\.(?:cts|mts|ts)$/.test(file) + ) continue + const sourceFile = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + for (const statement of sourceFile.statements) { + if (!isTypeDeclaration(statement) + || statement.name === undefined + || !hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue + const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)) + declarations.push({ + face: registration.face, + package: registration.name, + name: statement.name.text, + kind: ts.isClassDeclaration(statement) + ? 'class' + : ts.isInterfaceDeclaration(statement) + ? 'interface' + : ts.isTypeAliasDeclaration(statement) + ? 'alias' + : 'enum', + location: { + file: relativeFile, + line: position.line + 1, + column: position.character + 1, + }, + text: declarationText(statement), + }) + } + } + } + return uniqueBy(declarations, declaration => + `${declaration.face}\0${declaration.location.file}\0${String(declaration.location.line)}\0${declaration.name}`) + .sort((left, right) => left.face.localeCompare(right.face) + || left.location.file.localeCompare(right.location.file) + || left.location.line - right.location.line) + } + + private loadRegistrations(): PackageRegistration[] { + const registrations: PackageRegistration[] = [] + for (const face of ['host', 'client'] as const) { + const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig) + if (!existsSync(aggregatePath)) continue + const aggregate = parseConfig(aggregatePath) + for (const reference of aggregate.parsed.projectReferences ?? []) { + const configPath = projectConfigPath(reference.path) + const packageRoot = dirname(configPath) + if (!isWithin(realPath(packageRoot), join(this.options.root, 'packages'))) continue + const manifestPath = join(packageRoot, 'package.json') + if (!existsSync(manifestPath)) continue + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + if (typeof manifest.name !== 'string') continue + const registration: PackageRegistration = { + face, + name: manifest.name, + root: realPath(packageRoot), + config: parseConfig(configPath), + manifest, + } + const packagePath = slash(relative(this.options.root, packageRoot)) + const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/') + if (clientPackage && isDualFacePackage(manifest)) { + registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) + registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) + } else if (clientPackage) { + registrations.push({ ...registration, face: 'client' }) + } else { + registrations.push({ ...registration, face: 'host' }) + } + } + } + return uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`) + .sort((left, right) => + left.face.localeCompare(right.face) || left.name.localeCompare(right.name)) + } + + private entrySourcePaths(registration: PackageRegistration): string[] { + return packageExportTargets(registration.manifest) + .filter(([subpath, target]) => (registration.exportSubpaths === undefined + || registration.exportSubpaths.includes(subpath)) + && !target.includes('*') + && subpath !== './package.json' + && subpath !== './typert' + && subpath !== './client/typert' + && !target.endsWith('.json')) + .map(([, target]) => sourcePathForExport(registration.root, target)) + .filter(existsSync) + } + + private registrationHasSurface(registration: PackageRegistration): boolean { + const seen = new Set() + const queue = this.entrySourcePaths(registration) + while (queue.length > 0) { + const file = realPath(queue.shift() as string) + if (seen.has(file) || !isWithin(file, registration.root)) continue + seen.add(file) + const source = readFileSync(file, 'utf8') + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + if (sourceFileHasSurface(sourceFile)) return true + for (const imported of ts.preProcessFile(source).importedFiles) { + const resolved = ts.resolveModuleName( + imported.fileName, + file, + registration.config.parsed.options, + ts.sys, + ).resolvedModule + if (resolved !== undefined && isWithin(resolved.resolvedFileName, registration.root)) { + queue.push(resolved.resolvedFileName) + } + } + } + return false + } + + private checkProject(registration: PackageRegistration): void { + if (this.checkedProjects.has(registration.config.path)) return + this.checkedProjects.add(registration.config.path) + const program = ts.createProgram({ + rootNames: registration.config.parsed.fileNames, + options: { + ...registration.config.parsed.options, + composite: false, + incremental: false, + noEmit: true, + // Source-plane workspace aliases resolve referenced packages to source. + // Widen only this diagnostic program's root so those imports do not + // produce an artificial TS6059 before Typert checks the public edge. + rootDir: this.options.root, + }, + }) + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ].filter((diagnostic): diagnostic is ts.DiagnosticWithLocation => diagnostic.file !== undefined + && diagnostic.start !== undefined + && isWithin(diagnostic.file.fileName, registration.root)) + if (diagnostics.length === 0) return + throw new TypertAnalysisError( + diagnostics + .map(diagnostic => formatProgramDiagnostic(this.options.root, registration.face, diagnostic)) + .join('\n'), + ) + } + + private queueEdit(edit: SourceEdit): void { + this.queuedEdit = edit + } + + private applyEdit(edit: SourceEdit): void { + const source = readFileSync(edit.file, 'utf8') + writeFileSync(edit.file, source.slice(0, edit.position) + edit.text + source.slice(edit.position)) + } +} + +interface FaceAnalyzerOptions { + readonly root: string + readonly face: TypertFace + readonly program: ts.Program + readonly registrations: readonly PackageRegistration[] + readonly allRegistrations: readonly PackageRegistration[] + readonly mode: AnalysisMode + readonly queueEdit: (edit: SourceEdit) => void + readonly crossFaceLinks: Map +} + +class FaceAnalyzer { + private readonly root: string + private readonly face: TypertFace + private readonly program: ts.Program + private readonly checker: ts.TypeChecker + private readonly registrations: readonly PackageRegistration[] + private readonly allRegistrations: readonly PackageRegistration[] + private readonly mode: AnalysisMode + private readonly queueEdit: (edit: SourceEdit) => void + private readonly crossFaceLinks: Map + private readonly sourceFiles = new Map() + private readonly declarations = new Map() + private readonly declarationStates = new Set() + private readonly nodes = new Map() + private readonly exportsByPackage = new Map() + private readonly nodeOrdinals = new Map() + + constructor(options: FaceAnalyzerOptions) { + this.root = options.root + this.face = options.face + this.program = options.program + this.checker = options.program.getTypeChecker() + this.registrations = options.registrations + this.allRegistrations = options.allRegistrations + this.mode = options.mode + this.queueEdit = options.queueEdit + this.crossFaceLinks = options.crossFaceLinks + for (const sourceFile of this.program.getSourceFiles()) { + this.sourceFiles.set(realPath(sourceFile.fileName), sourceFile) + } + } + + analyze(): FaceModel { + for (const registration of this.registrations) { + this.exportsByPackage.set(registration.name, this.collectExports(registration)) + } + const packages = this.registrations + .map(registration => this.analyzePackage(registration)) + .filter(hasPackageSurface) + return { + face: this.face, + packages, + graph: { + declarations: [...this.declarations.values()].sort((left, right) => left.id.localeCompare(right.id)), + nodes: [...this.nodes.values()].sort((left, right) => left.id.localeCompare(right.id)), + }, + } + } + + private analyzePackage(registration: PackageRegistration): PackageModel { + const records = this.exportsByPackage.get(registration.name) as ExportRecord[] + const reachable = this.reachableFiles(registration, records.map(record => record.sourceFile)) + const services: ServiceModel[] = [] + const events: EventModel[] = [] + + for (const sourceFile of reachable) { + for (const statement of sourceFile.statements) { + if (!ts.isModuleDeclaration(statement) + || !ts.isStringLiteral(statement.name) + || statement.name.text !== 'cordis' + || statement.body === undefined + || !ts.isModuleBlock(statement.body)) continue + for (const member of statement.body.statements) { + if (!ts.isInterfaceDeclaration(member)) continue + if (member.name.text === 'Context') { + services.push(...this.collectServices(member, records)) + } else if (member.name.text === 'Events') { + events.push(...this.collectEvents(member)) + } + } + } + } + + const objects: ObjectModel[] = [] + const schemas: SchemaModel[] = [] + const seenBusinessSymbols = new Set() + for (const record of records) { + const declaration = record.declaration + if (!isTypeDeclaration(declaration)) continue + if (this.registrationForFile(declaration.getSourceFile().fileName) === undefined) continue + const symbol = this.resolveSymbol(record.symbol) + const symbolId = this.symbolId(symbol) + if (seenBusinessSymbols.has(symbolId)) continue + const mode = typertMode(declaration) + if (mode !== 'object' && mode !== 'schema') continue + seenBusinessSymbols.add(symbolId) + this.ensureDeclaration(symbol, declaration) + const documentation = documentationOf(declaration) + if (mode === 'object') { + objects.push({ + ...documentation, + export: record.model, + symbol: symbolId, + passing: 'reference', + }) + } else { + schemas.push({ + ...documentation, + export: record.model, + symbol: symbolId, + type: this.referenceNode(symbol, declaration), + }) + } + } + + return { + name: registration.name, + root: slash(relative(this.root, registration.root)), + exports: records.map(record => record.model) + .sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)), + services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)), + events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)), + objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)), + schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)), + } + } + + private collectExports(registration: PackageRegistration): ExportRecord[] { + const targets = packageExportTargets(registration.manifest) + .filter(([subpath]) => registration.exportSubpaths === undefined + || registration.exportSubpaths.includes(subpath)) + const records: ExportRecord[] = [] + for (const [subpath, target] of targets) { + if (target.includes('*') || subpath === './package.json' + || subpath === './typert' || subpath === './client/typert' || target.endsWith('.json')) continue + const sourcePath = sourcePathForExport(registration.root, target) + const sourceFile = this.sourceFiles.get(realPath(sourcePath)) + if (sourceFile === undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${registration.name} export ${subpath} resolves to missing source ${sourcePath}`, + ) + } + const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile) + if (moduleSymbol === undefined) continue + for (const exported of this.checker.getExportsOfModule(moduleSymbol)) { + const symbol = this.resolveSymbol(exported) + const declaration = preferredDeclaration(symbol) as ts.Declaration + const aliases = exported === symbol || exported.name === symbol.name + ? [exported.name] + : [exported.name, symbol.name] + records.push({ + model: { + subpath, + name: exported.name, + symbol: this.symbolId(symbol), + aliases, + }, + symbol, + declaration, + sourceFile, + }) + } + } + const unique = uniqueBy(records, record => `${record.model.subpath}\0${record.model.name}`) + this.collectCrossFaceReExports(registration, unique) + return unique + } + + private collectCrossFaceReExports( + registration: PackageRegistration, + records: readonly ExportRecord[], + ): void { + const publicSymbols = new Set(records.map(record => record.symbol)) + const entryFiles = uniqueBy(records, record => record.sourceFile.fileName).map(record => record.sourceFile) + for (const sourceFile of this.reachableFiles(registration, entryFiles)) { + for (const statement of sourceFile.statements) { + if (!ts.isExportDeclaration(statement) + || statement.moduleSpecifier === undefined + || !ts.isStringLiteral(statement.moduleSpecifier)) continue + const module = moduleIdentity(statement.moduleSpecifier.text) + if (module === undefined) continue + const toFace = this.allRegistrations + .find(candidate => candidate.name === module.package && candidate.face !== this.face)?.face + if (toFace === undefined) continue + + if (statement.exportClause !== undefined && ts.isNamespaceExport(statement.exportClause)) { + const namespace = this.resolveSymbol( + this.checker.getSymbolAtLocation(statement.exportClause.name) as ts.Symbol, + ) + if (publicSymbols.has(namespace)) { + this.fail(statement.exportClause, 'cross-face namespace re-exports are not supported') + } + continue + } + + const exports = statement.exportClause === undefined + ? this.moduleExports(statement.moduleSpecifier) + .map(symbol => ({ symbol: this.resolveSymbol(symbol), requestedName: symbol.name, site: statement })) + : statement.exportClause.elements.map(element => ({ + symbol: this.resolveSymbol(this.checker.getSymbolAtLocation(element.name) as ts.Symbol), + requestedName: element.propertyName?.text ?? element.name.text, + site: element, + })) + for (const exported of exports) { + if (!publicSymbols.has(exported.symbol)) continue + const name = this.packageExportName(module, exported.symbol, toFace, exported.requestedName) + if (name === undefined) { + this.fail( + exported.site, + `cross-face re-export ${exported.requestedName} is not exported by ${module.package} at ${module.subpath}`, + ) + } + this.recordCrossFaceLink(registration.name, toFace, module, name) + } + } + } + } + + private moduleExports(moduleSpecifier: ts.StringLiteral): ts.Symbol[] { + /* v8 ignore next -- a semantically valid export declaration from a resolved module always has a module symbol. */ + const moduleSymbol = this.checker.getSymbolAtLocation(moduleSpecifier) as ts.Symbol + return this.checker.getExportsOfModule(moduleSymbol) + } + + private reachableFiles( + registration: PackageRegistration, + entryFiles: readonly ts.SourceFile[], + ): ts.SourceFile[] { + const reachable = new Map() + const queue = [...entryFiles] + while (queue.length > 0) { + const sourceFile = queue.shift() as ts.SourceFile + const fileName = realPath(sourceFile.fileName) + if (reachable.has(fileName) || !isWithin(fileName, registration.root)) continue + reachable.set(fileName, sourceFile) + for (const statement of sourceFile.statements) { + if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) + || statement.moduleSpecifier === undefined + || !ts.isStringLiteral(statement.moduleSpecifier)) continue + const resolved = ts.resolveModuleName( + statement.moduleSpecifier.text, + sourceFile.fileName, + this.program.getCompilerOptions(), + ts.sys, + ).resolvedModule + if (resolved === undefined) continue + const resolvedPath = realPath(resolved.resolvedFileName) + if (!isWithin(resolvedPath, registration.root)) continue + queue.push(this.sourceFiles.get(resolvedPath) as ts.SourceFile) + } + } + return [...reachable.values()].sort((left, right) => left.fileName.localeCompare(right.fileName)) + } + + private collectServices( + context: ts.InterfaceDeclaration, + records: readonly ExportRecord[], + ): ServiceModel[] { + const bySymbol = new Map() + for (const record of records) { + const id = this.symbolId(record.symbol) + const matches = bySymbol.get(id) ?? [] + matches.push(record) + bySymbol.set(id, matches) + } + const result: ServiceModel[] = [] + for (const member of context.members) { + if (!ts.isPropertySignature(member) || member.type === undefined) continue + const symbol = this.symbolAtType(member.type) + if (symbol === undefined) continue + const symbolId = this.symbolId(symbol) + const exported = bySymbol.get(symbolId)?.find(record => record.model.name === symbol.name) + ?? bySymbol.get(symbolId)?.find(record => record.model.name !== 'default') + ?? bySymbol.get(symbolId)?.[0] + if (exported === undefined) continue + const declaration = preferredDeclaration(symbol) + if (declaration === undefined || (!ts.isClassDeclaration(declaration) && !ts.isInterfaceDeclaration(declaration))) { + this.fail(member, `service ${memberName(member.name)} does not resolve to an exported class or interface`) + } + const model = this.ensureDeclaration(symbol, declaration) + const exposed = model.members + .filter(exposableMember) + .map(publicMember => publicMember.id) + result.push({ + ...documentationOf(declaration), + key: memberName(member.name), + symbol: symbolId, + export: exported.model, + members: exposed, + location: this.location(member), + }) + } + return result + } + + private collectEvents(events: ts.InterfaceDeclaration): EventModel[] { + const result: EventModel[] = [] + for (const member of events.members) { + const documentation = documentationOf(member) + const mode = documentation.tags.find(tag => tag.name === 'mode')?.comment?.trim() + if (ts.isMethodSignature(member)) { + const signature = this.signature(member, member.type) + result.push({ + ...documentation, + name: memberName(member.name), + signature: this.addNode(member, { kind: 'function', signature }), + text: memberText(member), + ...(mode === undefined ? {} : { mode }), + location: this.location(member), + }) + } else if (ts.isPropertySignature(member) && member.type !== undefined) { + result.push({ + ...documentation, + name: memberName(member.name), + signature: this.convertType(member.type), + text: memberText(member), + ...(mode === undefined ? {} : { mode }), + location: this.location(member), + }) + } + } + return result + } + + private ensureDeclaration( + symbol: ts.Symbol, + selected: ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration, + ): TypeDeclarationModel { + const resolved = this.resolveSymbol(symbol) + const id = this.symbolId(resolved) + const existing = this.declarations.get(id) + if (existing !== undefined) return existing + const declarationParts = (resolved.declarations as ts.Declaration[]).filter(isTypeDeclaration) + if (declarationParts.length > 1 && !declarationParts.every(ts.isInterfaceDeclaration)) { + this.fail( + selected, + `merged ${ts.SyntaxKind[selected.kind]} declaration ${resolved.name} is not supported`, + ) + } + if (selected.name === undefined) { + this.fail(selected, `anonymous ${ts.SyntaxKind[selected.kind]} cannot be represented as a named type declaration`) + } + const owner = this.registrationForFile(selected.getSourceFile().fileName) as PackageRegistration + + this.declarationStates.add(id) + if (declarationParts.length > 1) { + const analyzedParts = declarationParts.map((declarationPart) => { + const part = declarationPart as ts.InterfaceDeclaration + const partOwner = this.registrationForFile(part.getSourceFile().fileName) + if (partOwner === undefined) { + this.fail(part, `merged interface ${resolved.name} contains a declaration outside this face`) + } + const typeParameters = this.typeParameters(part.typeParameters) + const heritage = this.heritage(part) + const members = this.members(part.members, id) + return { + typeParameters, + heritage, + members, + model: { + ...documentationOf(part), + package: partOwner.name, + location: this.location(part), + typeParameters, + extends: heritage.extends, + members: members.map(member => member.id), + }, + } + }) + const parameters = this.mergeTypeParameters(analyzedParts.map(part => part.typeParameters), selected, resolved.name) + const model: TypeDeclarationModel = { + ...documentationOf(selected), + id, + package: owner.name, + name: declarationName(selected), + kind: 'interface', + abstract: false, + exported: hasModifier(selected, ts.SyntaxKind.ExportKeyword), + location: this.location(selected), + text: declarationText(selected), + typeParameters: parameters, + extends: analyzedParts.flatMap(part => part.heritage.extends), + implements: [], + members: analyzedParts.flatMap(part => part.members), + parts: analyzedParts.map(part => part.model), + } + this.declarations.set(id, model) + this.declarationStates.delete(id) + return model + } + const parameters = ts.isEnumDeclaration(selected) ? [] : this.typeParameters(selected.typeParameters) + const heritage = ts.isTypeAliasDeclaration(selected) || ts.isEnumDeclaration(selected) + ? { extends: [] as TypeNodeId[], implements: [] as TypeNodeId[] } + : this.heritage(selected) + const kind = ts.isClassDeclaration(selected) + ? 'class' + : ts.isInterfaceDeclaration(selected) + ? 'interface' + : ts.isTypeAliasDeclaration(selected) + ? 'alias' + : 'enum' + const model: TypeDeclarationModel = { + ...documentationOf(selected), + id, + package: owner.name, + name: declarationName(selected), + kind, + abstract: hasModifier(selected, ts.SyntaxKind.AbstractKeyword), + exported: hasModifier(selected, ts.SyntaxKind.ExportKeyword), + location: this.location(selected), + text: declarationText(selected), + typeParameters: parameters, + extends: heritage.extends, + implements: heritage.implements, + members: ts.isTypeAliasDeclaration(selected) || ts.isEnumDeclaration(selected) + ? [] + : this.members(selected.members, id), + ...(ts.isTypeAliasDeclaration(selected) ? { type: this.convertType(selected.type) } : {}), + ...(ts.isEnumDeclaration(selected) ? { enumMembers: this.enumMembers(selected) } : {}), + } + this.declarations.set(id, model) + this.declarationStates.delete(id) + return model + } + + private enumMembers(declaration: ts.EnumDeclaration): EnumMemberModel[] { + return declaration.members.map(member => ({ + ...documentationOf(member), + name: memberName(member.name), + ...(member.initializer === undefined ? {} : { initializer: member.initializer.getText() }), + location: this.location(member), + })) + } + + private heritage( + declaration: ts.ClassDeclaration | ts.InterfaceDeclaration, + ): { extends: TypeNodeId[]; implements: TypeNodeId[] } { + const result = { extends: [] as TypeNodeId[], implements: [] as TypeNodeId[] } + for (const clause of declaration.heritageClauses ?? []) { + const target = clause.token === ts.SyntaxKind.ExtendsKeyword ? result.extends : result.implements + for (const type of clause.types) target.push(this.convertHeritage(type)) + } + return result + } + + private convertHeritage(node: ts.ExpressionWithTypeArguments): TypeNodeId { + const symbol = this.checker.getSymbolAtLocation(node.expression) as ts.Symbol + return this.addNode(node, { + kind: 'reference', + name: node.expression.getText(), + target: this.targetForReference(this.resolveSymbol(symbol), node), + arguments: node.typeArguments?.map(argument => this.convertType(argument)) ?? [], + }) + } + + private members( + members: ts.NodeArray, + ownerId: string, + ): MemberModel[] { + const result: MemberModel[] = [] + for (const member of members) { + const visibility = visibilityOf(member) + const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) + if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue + const base = this.memberBase(member, ownerId, visibility, isStatic) + if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { + const type = this.requiredType(member, member.type, 'property') + result.push({ ...base, kind: 'property', type: this.convertType(type) }) + } else if (ts.isMethodSignature(member) || ts.isMethodDeclaration(member)) { + result.push({ ...base, kind: 'method', signature: this.signature(member, member.type) }) + } else if (ts.isGetAccessorDeclaration(member)) { + result.push({ ...base, kind: 'getter', signature: this.signature(member, member.type) }) + } else if (ts.isSetAccessorDeclaration(member)) { + result.push({ ...base, kind: 'setter', signature: this.signature(member, member.type) }) + } else if (ts.isCallSignatureDeclaration(member)) { + result.push({ ...base, kind: 'call', signature: this.signature(member, member.type) }) + } else if (ts.isConstructSignatureDeclaration(member)) { + result.push({ ...base, kind: 'construct', signature: this.signature(member, member.type) }) + } else if (ts.isIndexSignatureDeclaration(member)) { + result.push({ ...base, kind: 'index', signature: this.signature(member, member.type) }) + } + } + return result + } + + private memberBase( + member: ts.TypeElement | ts.ClassElement, + ownerId: string, + visibility: MemberVisibility, + isStatic: boolean, + ): MemberBase { + const name = member.name !== undefined + ? memberName(member.name) + : ts.isCallSignatureDeclaration(member) + ? '(call)' + : ts.isConstructSignatureDeclaration(member) + ? '(construct)' + : '(index)' + return { + ...documentationOf(member), + id: `${ownerId}#${name}@${String(member.getStart())}`, + name, + optional: 'questionToken' in member && member.questionToken !== undefined, + readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), + async: hasModifier(member, ts.SyntaxKind.AsyncKeyword), + abstract: hasModifier(member, ts.SyntaxKind.AbstractKeyword), + static: isStatic, + visibility, + location: this.location(member), + text: memberText(member), + } + } + + private signature( + node: ts.SignatureDeclarationBase, + explicitReturn: ts.TypeNode | undefined, + ): SignatureModel { + const parameters: ParameterModel[] = node.parameters.map(parameter => ({ + name: memberName(parameter.name), + binding: ts.isIdentifier(parameter.name) + ? 'identifier' + : ts.isObjectBindingPattern(parameter.name) + ? 'object' + : 'array', + type: this.convertType(this.requiredType(parameter, parameter.type, 'parameter')), + optional: parameter.questionToken !== undefined || parameter.initializer !== undefined, + rest: parameter.dotDotDotToken !== undefined, + receiver: ts.isIdentifier(parameter.name) && parameter.name.text === 'this', + ...(parameter.initializer === undefined ? {} : { initializer: parameter.initializer.getText() }), + })) + return { + typeParameters: this.typeParameters(node.typeParameters), + parameters, + returns: ts.isSetAccessorDeclaration(node) + ? this.addNode(node, { kind: 'keyword', name: 'void' }) + : this.convertType(this.requiredType(node, explicitReturn, 'return')), + } + } + + private typeParameters( + parameters: ts.NodeArray | undefined, + ): TypeParameterModel[] { + return parameters?.map(parameter => ({ + id: `${this.locationKey(parameter)}#${parameter.name.text}`, + name: parameter.name.text, + const: hasModifier(parameter, ts.SyntaxKind.ConstKeyword), + ...(parameter.constraint === undefined ? {} : { constraint: this.convertType(parameter.constraint) }), + ...(parameter.default === undefined ? {} : { default: this.convertType(parameter.default) }), + ...(hasModifier(parameter, ts.SyntaxKind.InKeyword) && hasModifier(parameter, ts.SyntaxKind.OutKeyword) + ? { variance: 'in-out' as const } + : hasModifier(parameter, ts.SyntaxKind.InKeyword) + ? { variance: 'in' as const } + : hasModifier(parameter, ts.SyntaxKind.OutKeyword) + ? { variance: 'out' as const } + : {}), + })) ?? [] + } + + private mergeTypeParameters( + parts: readonly (readonly TypeParameterModel[])[], + site: ts.Node, + declarationName: string, + ): TypeParameterModel[] { + const first = parts[0] as readonly TypeParameterModel[] + return first.map((parameter, index) => { + const peers = parts.map(part => part[index] as TypeParameterModel) + const constraint = peers.find(peer => peer.constraint !== undefined)?.constraint + const fallback = peers.find(peer => peer.default !== undefined)?.default + const variances = [...new Set(peers.flatMap(peer => peer.variance === undefined ? [] : [peer.variance]))] + if (variances.length > 1) { + this.fail(site, `merged interface ${declarationName} has incompatible variance modifiers`) + } + return { + id: parameter.id, + name: parameter.name, + const: peers.some(peer => peer.const), + ...(constraint === undefined ? {} : { constraint }), + ...(fallback === undefined ? {} : { default: fallback }), + ...(variances[0] === undefined ? {} : { variance: variances[0] }), + } + }) + } + + private requiredType( + owner: ts.Node, + type: ts.TypeNode | undefined, + purpose: 'property' | 'parameter' | 'return', + ): ts.TypeNode { + if (type !== undefined) return type + if (this.mode === 'check') { + this.fail(owner, `public ${purpose} is missing an explicit type annotation`) + } + const inferred = this.inferType(owner, purpose) + const rendered = ts.createPrinter().printNode(ts.EmitHint.Unspecified, inferred, owner.getSourceFile()) + const position = annotationPosition(owner, purpose) + this.queueEdit({ file: realPath(owner.getSourceFile().fileName), position, text: `: ${rendered}` }) + throw new SourceEditQueued() + } + + private inferType( + owner: ts.Node, + purpose: 'property' | 'parameter' | 'return', + ): ts.TypeNode { + let type: ts.Type + if (purpose === 'return') { + const signature = this.checker.getSignatureFromDeclaration(owner as ts.SignatureDeclaration) as ts.Signature + type = this.checker.getReturnTypeOfSignature(signature) + } else { + type = this.checker.getTypeAtLocation(owner) + } + return this.checker.typeToTypeNode( + type, + owner, + ts.NodeBuilderFlags.NoTruncation | ts.NodeBuilderFlags.UseAliasDefinedOutsideCurrentScope, + ) as ts.TypeNode + } + + private convertType(node: ts.TypeNode): TypeNodeId { + const id = this.allocateNodeId(node) + const add = (model: TypeNodeInput): TypeNodeId => { + this.nodes.set(id, { id, ...model }) + return id + } + + const keyword = keywordName(node.kind) + if (keyword !== undefined) return add({ kind: 'keyword', name: keyword }) + if (ts.isParenthesizedTypeNode(node)) { + return add({ kind: 'parenthesized', type: this.convertType(node.type) }) + } + if (ts.isLiteralTypeNode(node)) return add(literalModel(node)) + if (ts.isTypeReferenceNode(node)) { + const symbol = this.checker.getSymbolAtLocation(node.typeName) as ts.Symbol + return add({ + kind: 'reference', + name: node.typeName.getText(), + target: this.targetForReference(this.resolveSymbol(symbol), node), + arguments: node.typeArguments?.map(argument => this.convertType(argument)) ?? [], + }) + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + return add({ + kind: ts.isUnionTypeNode(node) ? 'union' : 'intersection', + types: node.types.map(type => this.convertType(type)), + }) + } + if (ts.isArrayTypeNode(node)) return add({ kind: 'array', element: this.convertType(node.elementType) }) + if (ts.isTupleTypeNode(node)) { + return add({ + kind: 'tuple', + elements: node.elements.map((element) => { + const named = ts.isNamedTupleMember(element) ? element : undefined + const raw = named?.type ?? element + const optional = named?.questionToken !== undefined || ts.isOptionalTypeNode(raw) + const rest = named?.dotDotDotToken !== undefined || ts.isRestTypeNode(raw) + const type = ts.isOptionalTypeNode(raw) || ts.isRestTypeNode(raw) ? raw.type : raw + return { + ...(named === undefined ? {} : { name: named.name.text }), + type: this.convertType(type), + optional, + rest, + } + }), + }) + } + if (ts.isTypeLiteralNode(node)) return add({ kind: 'object', members: this.members(node.members, id) }) + if (ts.isFunctionTypeNode(node)) { + return add({ kind: 'function', signature: this.signature(node, node.type) }) + } + if (ts.isConstructorTypeNode(node)) { + return add({ + kind: 'constructor', + abstract: hasModifier(node, ts.SyntaxKind.AbstractKeyword), + signature: this.signature(node, node.type), + }) + } + if (ts.isIndexedAccessTypeNode(node)) { + return add({ + kind: 'indexed-access', + object: this.convertType(node.objectType), + index: this.convertType(node.indexType), + }) + } + if (ts.isTypeOperatorNode(node)) { + return add({ + kind: 'operator', + operator: ts.tokenToString(node.operator) as TypeOperatorName, + type: this.convertType(node.type), + }) + } + if (ts.isConditionalTypeNode(node)) { + return add({ + kind: 'conditional', + check: this.convertType(node.checkType), + extends: this.convertType(node.extendsType), + whenTrue: this.convertType(node.trueType), + whenFalse: this.convertType(node.falseType), + }) + } + if (ts.isInferTypeNode(node)) { + return add({ kind: 'infer', parameter: this.typeParameters(ts.factory.createNodeArray([node.typeParameter]))[0] as TypeParameterModel }) + } + if (ts.isMappedTypeNode(node)) { + const parameter = this.typeParameters(ts.factory.createNodeArray([node.typeParameter]))[0] as TypeParameterModel + return add({ + kind: 'mapped', + parameter, + ...(node.nameType === undefined ? {} : { nameType: this.convertType(node.nameType) }), + ...(node.type === undefined ? {} : { value: this.convertType(node.type) }), + readonly: modifierMode(node.readonlyToken), + optional: modifierMode(node.questionToken), + }) + } + if (ts.isTemplateLiteralTypeNode(node)) { + return add({ + kind: 'template-literal', + head: node.head.text, + spans: node.templateSpans.map(span => ({ type: this.convertType(span.type), text: span.literal.text })), + }) + } + if (ts.isTypeQueryNode(node)) { + return add({ + kind: 'type-query', + expression: node.exprName.getText(), + arguments: node.typeArguments?.map(argument => this.convertType(argument)) ?? [], + }) + } + if (ts.isImportTypeNode(node)) { + const argument = node.argument as ts.LiteralTypeNode & { readonly literal: ts.StringLiteral } + const symbol = node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + return add({ + kind: 'import-type', + module: argument.literal.text, + ...(node.qualifier === undefined ? {} : { qualifier: node.qualifier.getText() }), + arguments: node.typeArguments?.map(argument => this.convertType(argument)) ?? [], + typeof: node.isTypeOf, + ...(node.attributes === undefined ? {} : { attributes: importTypeAttributesText(node) }), + ...(symbol === undefined ? {} : { target: this.targetForReference(this.resolveSymbol(symbol), node) }), + }) + } + if (ts.isTypePredicateNode(node)) { + return add({ + kind: 'predicate', + asserts: node.assertsModifier !== undefined, + parameter: node.parameterName.getText(), + ...(node.type === undefined ? {} : { type: this.convertType(node.type) }), + }) + } + /* v8 ignore else -- every source TypeNode kind accepted by TypeScript is handled above; this arm keeps + * future compiler kinds fail-loud. */ + if (ts.isThisTypeNode(node)) return add({ kind: 'this' }) + /* v8 ignore next -- paired with the exhaustive TypeNode guard above. */ + this.fail(node, `unsupported TypeScript type node ${ts.SyntaxKind[node.kind]}`) + } + + private addNode(site: ts.Node, model: TypeNodeInput): TypeNodeId { + const id = this.allocateNodeId(site) + this.nodes.set(id, { id, ...model }) + return id + } + + private referenceNode(symbol: ts.Symbol, site: ts.Node): TypeNodeId { + return this.addNode(site, { + kind: 'reference', + name: symbol.name, + target: { kind: 'declaration', symbol: this.symbolId(symbol) }, + arguments: [], + }) + } + + private targetForReference(symbol: ts.Symbol, site: ReferenceSite): TypeTargetModel { + const declaration = preferredDeclaration(symbol) + /* v8 ignore next -- a symbol from a semantically valid source type reference always has a declaration. */ + if (declaration === undefined) this.fail(site, `type symbol ${symbol.name} has no declaration`) + if (ts.isTypeParameterDeclaration(declaration)) { + return { + kind: 'type-parameter', + parameter: `${this.locationKey(declaration)}#${declaration.name.text}`, + } + } + if (isStandardLibraryFile(declaration.getSourceFile().fileName)) { + return { kind: 'standard', name: symbol.name } + } + + const moduleSpecifier = moduleSpecifierOf(site) + const module = moduleSpecifier === undefined ? undefined : moduleIdentity(moduleSpecifier) + const from = this.registrationForFile(site.getSourceFile().fileName) as PackageRegistration + const owner = this.registrationForFile(declaration.getSourceFile().fileName) + if (owner !== undefined) { + if (owner.name !== from.name) { + if (module === undefined) { + this.fail(site, `reference to ${symbol.name} crosses a package without an explicit package import`) + } + const exportName = authoredExportName(site, moduleSpecifier as string) + if (this.packageExportName(module, symbol, owner.face, exportName) === undefined) { + this.fail(site, `package reference ${exportName} is not exported by ${module.package} at ${module.subpath}`) + } + } + const typeDeclaration = declaration as ts.ClassDeclaration | ts.InterfaceDeclaration + | ts.TypeAliasDeclaration | ts.EnumDeclaration + if (!this.declarationStates.has(this.symbolId(symbol))) this.ensureDeclaration(symbol, typeDeclaration) + return { kind: 'declaration', symbol: this.symbolId(symbol) } + } + + const packageFaces = module === undefined + ? [] + : [...new Set(this.allRegistrations.filter(candidate => candidate.name === module.package).map(candidate => candidate.face))] + const otherFace = packageFaces.find(face => face !== this.face) + if (otherFace !== undefined && module !== undefined) { + const requestedName = authoredExportName(site, moduleSpecifier as string) + const exportName = this.packageExportName(module, symbol, otherFace, requestedName) + if (exportName === undefined) { + this.fail(site, `cross-face reference ${requestedName} is not exported by ${module.package} at ${module.subpath}`) + } + this.recordCrossFaceLink(from.name, otherFace, module, exportName) + return { + kind: 'cross-face', + face: otherFace, + package: module.package, + subpath: module.subpath, + name: exportName, + } + } + + if (module !== undefined) { + return { + kind: 'external', + module: module.package, + subpath: module.subpath, + name: symbol.name, + } + } + + const external = externalModuleIdentityForFile(declaration.getSourceFile().fileName) + if (external !== undefined) { + return { + kind: 'external', + module: external.package, + subpath: external.subpath, + name: symbol.name, + } + } + + this.fail(site, `reference to ${symbol.name} crosses a package or face without an explicit import`) + } + + private recordCrossFaceLink( + fromPackage: string, + toFace: TypertFace, + module: ModuleIdentity, + name: string, + ): void { + const link: CrossFaceLink = { + fromFace: this.face, + fromPackage, + toFace, + toPackage: module.package, + subpath: module.subpath, + name, + } + const key = [ + link.fromFace, + link.fromPackage, + link.toFace, + link.toPackage, + link.subpath, + link.name, + ].join('\0') + this.crossFaceLinks.set(key, link) + } + + private packageExportName( + module: ModuleIdentity, + symbol: ts.Symbol, + face: TypertFace, + requestedName: string, + ): string | undefined { + const registration = this.allRegistrations.find(candidate => + candidate.face === face && candidate.name === module.package) as PackageRegistration + const target = packageExportTargets(registration.manifest) + .find(([subpath]) => subpath === module.subpath)?.[1] + if (target === undefined) return undefined + const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target))) as ts.SourceFile + const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile) as ts.Symbol + const exported = this.checker.getExportsOfModule(moduleSymbol) + .find(candidate => candidate.name === requestedName && this.resolveSymbol(candidate) === symbol) + return exported?.name + } + + private symbolAtType(node: ts.TypeNode): ts.Symbol | undefined { + if (ts.isTypeReferenceNode(node)) { + return this.resolveSymbol(this.checker.getSymbolAtLocation(node.typeName) as ts.Symbol) + } + const type = this.checker.getTypeAtLocation(node) + const symbol = type.aliasSymbol ?? type.getSymbol() + return symbol === undefined ? undefined : this.resolveSymbol(symbol) + } + + private resolveSymbol(symbol: ts.Symbol): ts.Symbol { + return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : this.checker.getAliasedSymbol(symbol) + } + + private symbolId(symbol: ts.Symbol): SymbolId { + const declaration = preferredDeclaration(symbol) + if (declaration === undefined) return `symbol:${symbol.name}` + const location = this.location(declaration) + return `${this.packageNameForFile(declaration.getSourceFile().fileName)}:${location.file}#${symbol.name}` + } + + private registrationForFile(file: string): PackageRegistration | undefined { + const path = realPath(file) + return this.allRegistrations + .find(registration => registration.face === this.face && isWithin(path, registration.root)) + } + + private packageNameForFile(file: string): string { + const path = realPath(file) + return this.allRegistrations.find(registration => isWithin(path, registration.root))?.name ?? '' + } + + private allocateNodeId(site: ts.Node): TypeNodeId { + const location = this.locationKey(site) + const ordinal = (this.nodeOrdinals.get(location) ?? 0) + 1 + this.nodeOrdinals.set(location, ordinal) + return `type:${location}#${String(ordinal)}` + } + + private locationKey(node: ts.Node): string { + const location = this.location(node) + return `${location.file}:${String(location.line)}:${String(location.column)}` + } + + private location(node: ts.Node): SourceLocation { + const sourceFile = node.getSourceFile() + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + return { + file: slash(relative(this.root, sourceFile.fileName)), + line: position.line + 1, + column: position.character + 1, + } + } + + private fail(node: ts.Node, message: string): never { + const location = this.location(node) + throw new TypertAnalysisError( + `typert(${this.face}): ${location.file}:${String(location.line)}:${String(location.column)}: ${message}`, + ) + } +} + +function mergeWorkspaceModels(models: readonly WorkspaceModel[]): WorkspaceModel { + const faces = new Map + declarations: Map + nodes: Map + }>() + const links = new Map() + for (const model of models) { + for (const face of model.faces) { + const merged = faces.get(face.face) ?? { + packages: new Map(), + declarations: new Map(), + nodes: new Map(), + } + for (const packageModel of face.packages) merged.packages.set(packageModel.name, packageModel) + for (const declaration of face.graph.declarations) { + if (!merged.declarations.has(declaration.id)) merged.declarations.set(declaration.id, declaration) + } + for (const node of face.graph.nodes) { + if (!merged.nodes.has(node.id)) merged.nodes.set(node.id, node) + } + faces.set(face.face, merged) + } + for (const link of model.crossFaceLinks) { + links.set([ + link.fromFace, + link.fromPackage, + link.toFace, + link.toPackage, + link.subpath, + link.name, + ].join('\0'), link) + } + } + return { + faces: [...faces].sort(([left], [right]) => + (left === 'host' ? 0 : 1) - (right === 'host' ? 0 : 1)).map(([face, model]) => ({ + face, + packages: [...model.packages.values()].sort((left, right) => left.name.localeCompare(right.name)), + graph: { + declarations: [...model.declarations.values()].sort((left, right) => left.id.localeCompare(right.id)), + nodes: [...model.nodes.values()].sort((left, right) => left.id.localeCompare(right.id)), + }, + })), + crossFaceLinks: [...links.values()].sort(compareCrossFaceLinks), + } +} + +function parseConfig(path: string): ParsedConfig { + const read = ts.readConfigFile(path, file => ts.sys.readFile(file)) + if (read.error !== undefined) throw new TypertAnalysisError(formatDiagnostic(read.error)) + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path) + if (parsed.errors.length > 0) throw new TypertAnalysisError(parsed.errors.map(formatDiagnostic).join('\n')) + return { path, parsed } +} + +function projectConfigPath(path: string): string { + if (extname(path) === '.json') return path + return join(path, 'tsconfig.json') +} + +function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { + for (const statement of sourceFile.statements) { + if ((ts.isClassDeclaration(statement) + || ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) + || ts.isEnumDeclaration(statement)) + && typertMode(statement) !== undefined) return true + if (!ts.isModuleDeclaration(statement) + || !ts.isStringLiteral(statement.name) + || statement.name.text !== 'cordis' + || statement.body === undefined + || !ts.isModuleBlock(statement.body)) continue + if (statement.body.statements.some(member => ts.isInterfaceDeclaration(member) + && (member.name.text === 'Context' || member.name.text === 'Events') + && member.members.length > 0)) return true + } + return false +} + +function hasPackageSurface(model: PackageModel): boolean { + return model.services.length > 0 + || model.events.length > 0 + || model.objects.length > 0 + || model.schemas.length > 0 +} + +function isDualFacePackage(manifest: Record): boolean { + return manifest.dshClient !== null + && typeof manifest.dshClient === 'object' + && clientExportSubpaths(manifest).length > 0 +} + +function hostExportSubpaths(manifest: Record): string[] { + return packageExportTargets(manifest) + .map(([subpath]) => subpath) + .filter(subpath => subpath !== './client' && !subpath.startsWith('./client/')) +} + +function clientExportSubpaths(manifest: Record): string[] { + return packageExportTargets(manifest) + .map(([subpath]) => subpath) + .filter(subpath => subpath === './client' || subpath.startsWith('./client/')) +} + +function packageExportTargets(manifest: Record): [string, string][] { + const exportsField = manifest.exports + if (typeof exportsField === 'string') return [['.', exportsField]] + if (exportsField === null || typeof exportsField !== 'object') { + const types = manifest.types + return typeof types === 'string' ? [['.', types]] : [] + } + if (Array.isArray(exportsField) + || !Object.keys(exportsField).some(key => key.startsWith('.'))) { + const target = exportTarget(exportsField) + return target === undefined ? [] : [['.', target]] + } + const result: [string, string][] = [] + for (const [subpath, value] of Object.entries(exportsField as Record)) { + if (!subpath.startsWith('.')) continue + const target = exportTarget(value) + if (target !== undefined) result.push([subpath, target]) + } + return result.sort(([left], [right]) => left.localeCompare(right)) +} + +function exportTarget(value: unknown): string | undefined { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + for (const candidate of value) { + const target = exportTarget(candidate) + if (target !== undefined) return target + } + return undefined + } + if (value === null || typeof value !== 'object') return undefined + const conditions = value as Record + for (const key of ['types', 'import', 'default']) { + const target = exportTarget(conditions[key]) + if (target !== undefined) return target + } + for (const candidate of Object.values(conditions)) { + const target = exportTarget(candidate) + if (target !== undefined) return target + } + return undefined +} + +function sourcePathForExport(packageRoot: string, target: string): string { + const normalized = target.replace(/^\.\//, '') + if (normalized.startsWith('lib/types/')) { + return resolve(packageRoot, 'src', normalized.slice('lib/types/'.length).replace(/\.d\.(?:mts|cts|ts)$/, '.ts')) + } + if (normalized.startsWith('lib/')) { + return resolve(packageRoot, 'src', normalized.slice('lib/'.length).replace(/\.(?:mjs|cjs|js|d\.ts)$/, '.ts')) + } + return resolve(packageRoot, normalized) +} + +function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined { + return symbol.declarations?.find(isTypeDeclaration) + ?? symbol.valueDeclaration + ?? symbol.declarations?.[0] +} + +function isTypeDeclaration( + node: ts.Node, +): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration { + return ts.isClassDeclaration(node) + || ts.isInterfaceDeclaration(node) + || ts.isTypeAliasDeclaration(node) + || ts.isEnumDeclaration(node) +} + +function declarationName( + declaration: ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration, +): string { + return (declaration.name as ts.Identifier).text +} + +function memberText(member: ts.TypeElement | ts.ClassElement): string { + const sourceFile = member.getSourceFile() + const full = member.getText(sourceFile) + const body = (member as { body?: ts.Node }).body + const signature = body === undefined ? full : full.slice(0, full.length - body.getText(sourceFile).length) + return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() +} + +function declarationText( + declaration: ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration, +): string { + const printer = ts.createPrinter({ removeComments: true }) + const projected = ts.isClassDeclaration(declaration) ? classShape(declaration) : declaration + return printer.printNode(ts.EmitHint.Unspecified, projected, declaration.getSourceFile()).replace(/\r/g, '') +} + +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const nonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(modifier => + modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + if (nonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] + if (ts.isMethodDeclaration(member)) { + return [ts.factory.updateMethodDeclaration( + member, + member.modifiers, + member.asteriskToken, + member.name, + member.questionToken, + member.typeParameters, + member.parameters, + member.type, + undefined, + )] + } + if (ts.isConstructorDeclaration(member)) { + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, + member.modifiers, + member.name, + member.parameters, + member.type, + undefined, + )] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, + member.modifiers, + member.name, + member.parameters, + undefined, + )] + } + if (ts.isPropertyDeclaration(member)) { + return [ts.factory.updatePropertyDeclaration( + member, + member.modifiers, + member.name, + member.questionToken ?? member.exclamationToken, + member.type, + undefined, + )] + } + return [member] + }) + return ts.factory.updateClassDeclaration( + node, + node.modifiers, + node.name, + node.typeParameters, + node.heritageClauses, + members, + ) +} + +function documentationOf(node: ts.Node): DocumentationModel { + const blocks = ts.getJSDocCommentsAndTags(node).filter(ts.isJSDoc) + const block = blocks.at(-1) + if (block === undefined) return EMPTY_DOCUMENTATION + const description = normalizedDocText(ts.getTextOfJSDocComment(block.comment)) + const tags: JsDocTagModel[] = ts.getJSDocTags(node).map((tag) => { + const named = tag as ts.JSDocTag & { name?: ts.Node } + const comment = normalizedDocText(ts.getTextOfJSDocComment(tag.comment)) + return { + name: tag.tagName.text, + ...(named.name === undefined ? {} : { argument: named.name.getText() }), + ...(comment === undefined ? {} : { comment }), + text: tag.getText(tag.getSourceFile()).trim(), + } + }) + return { + ...(description === undefined ? {} : { + description, + summary: firstSentence(description), + }), + tags, + jsDoc: rawJsDoc(node), + } +} + +function normalizedDocText(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const normalized = value.replace(/\s+/g, ' ').trim() + /* v8 ignore next -- TypeScript represents whitespace-only JSDoc as undefined before this helper is called. */ + return normalized.length === 0 ? undefined : normalized +} + +function firstSentence(value: string): string { + return (/^(.*?[.!?])(?:\s|$)/.exec(value)?.[1] ?? value).trim() +} + +function rawJsDoc(node: ts.Node): string { + const sourceFile = node.getSourceFile() + const source = sourceFile.getFullText() + const ranges = ts.getLeadingCommentRanges(source, node.getFullStart()) as ts.CommentRange[] + const range = ranges.filter(candidate => source.slice(candidate.pos, candidate.pos + 3) === '/**').at(-1) as ts.CommentRange + const raw = source.slice(range.pos, range.end) + const { line } = sourceFile.getLineAndCharacterOfPosition(range.pos) + const lineStart = sourceFile.getPositionOfLineAndCharacter(line, 0) + const indent = source.slice(lineStart, range.pos) + return raw.split('\n') + .map((text, index) => index > 0 && text.startsWith(indent) ? text.slice(indent.length) : text) + .join('\n') +} + +function typertMode(node: ts.Node): 'object' | 'schema' | undefined { + for (const tag of ts.getJSDocTags(node)) { + if (tag.tagName.text !== 'typert') continue + const mode = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] + if (mode === 'object') return 'object' + if (mode === '' || mode === 'schema' || mode === 'type') return 'schema' + } + return undefined +} + +function memberName(name: ts.PropertyName | ts.BindingName): string { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) + || ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text + if (ts.isComputedPropertyName(name)) return `[${name.expression.getText()}]` + return name.getText() +} + +function visibilityOf(node: ts.Node): MemberVisibility { + if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private' + if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private' + if (hasModifier(node, ts.SyntaxKind.ProtectedKeyword)) return 'protected' + return 'public' +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)?.some(modifier => modifier.kind === kind) ?? false +} + +function exposableMember(member: MemberModel): boolean { + return member.visibility === 'public' && !member.static +} + +function keywordName(kind: ts.SyntaxKind): KeywordTypeName | undefined { + switch (kind) { + case ts.SyntaxKind.AnyKeyword: return 'any' + case ts.SyntaxKind.BigIntKeyword: return 'bigint' + case ts.SyntaxKind.BooleanKeyword: return 'boolean' + case ts.SyntaxKind.NeverKeyword: return 'never' + case ts.SyntaxKind.NumberKeyword: return 'number' + case ts.SyntaxKind.ObjectKeyword: return 'object' + case ts.SyntaxKind.StringKeyword: return 'string' + case ts.SyntaxKind.SymbolKeyword: return 'symbol' + case ts.SyntaxKind.UndefinedKeyword: return 'undefined' + case ts.SyntaxKind.UnknownKeyword: return 'unknown' + case ts.SyntaxKind.VoidKeyword: return 'void' + default: return undefined + } +} + +function literalModel(node: ts.LiteralTypeNode): Omit, 'id'> { + const literal = node.literal + if (ts.isStringLiteral(literal)) return { kind: 'literal', value: literal.text, text: literal.getText() } + if (ts.isNoSubstitutionTemplateLiteral(literal)) { + return { kind: 'literal', value: literal.text, text: literal.getText() } + } + if (ts.isNumericLiteral(literal)) return { kind: 'literal', value: Number(literal.text), text: literal.getText() } + if (ts.isBigIntLiteral(literal)) return { kind: 'literal', value: BigInt(literal.text.slice(0, -1)), text: literal.getText() } + if (literal.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'literal', value: true, text: 'true' } + if (literal.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'literal', value: false, text: 'false' } + if (literal.kind === ts.SyntaxKind.NullKeyword) return { kind: 'literal', value: null, text: 'null' } + /* v8 ignore else -- all remaining LiteralTypeNode syntax is a signed numeric or bigint literal. */ + if (ts.isPrefixUnaryExpression(literal) + && (ts.isNumericLiteral(literal.operand) || ts.isBigIntLiteral(literal.operand))) { + return { + kind: 'literal', + value: ts.isBigIntLiteral(literal.operand) + ? BigInt(literal.getText().slice(0, -1)) + : Number(literal.getText()), + text: literal.getText(), + } + } + /* v8 ignore next -- TypeScript's LiteralTypeNode grammar is exhausted above; this contains future compiler syntax. */ + throw new TypertAnalysisError(`typert: unsupported literal type ${literal.getText()}`) +} + +function modifierMode(token: ts.ReadonlyKeyword | ts.PlusToken | ts.MinusToken | ts.QuestionToken | undefined): + 'add' | 'remove' | 'preserve' { + if (token?.kind === ts.SyntaxKind.PlusToken) return 'add' + if (token?.kind === ts.SyntaxKind.MinusToken) return 'remove' + return token === undefined ? 'preserve' : 'add' +} + +function annotationPosition( + node: ts.Node, + purpose: 'property' | 'parameter' | 'return', +): number { + if (purpose === 'return') return (node as ts.SignatureDeclarationBase).parameters.end + 1 + return (node as ts.ParameterDeclaration | ts.PropertyDeclaration | ts.PropertySignature).name.end +} + +function moduleSpecifierOf(node: ReferenceSite): string | undefined { + if (ts.isImportTypeNode(node)) { + const argument = node.argument as ts.LiteralTypeNode & { readonly literal: ts.StringLiteral } + return argument.literal.text + } + const symbol = ts.isTypeReferenceNode(node) + ? node.typeName + : node.expression + const sourceFile = node.getSourceFile() + const first = ts.isIdentifier(symbol) ? symbol.text : symbol.getFirstToken(sourceFile)?.getText(sourceFile) + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || statement.importClause === undefined + || !ts.isStringLiteral(statement.moduleSpecifier)) continue + if (statement.importClause.name?.text === first) return statement.moduleSpecifier.text + const bindings = statement.importClause.namedBindings + if (bindings !== undefined && ts.isNamespaceImport(bindings) && bindings.name.text === first) { + return statement.moduleSpecifier.text + } + if (bindings !== undefined && ts.isNamedImports(bindings) + && bindings.elements.some(element => element.name.text === first)) return statement.moduleSpecifier.text + } + return undefined +} + +function authoredExportName(node: ReferenceSite, moduleSpecifier: string): string { + if (ts.isImportTypeNode(node)) return (node.qualifier as ts.EntityName).getText().split('.')[0] as string + + const referenced = ts.isTypeReferenceNode(node) + ? node.typeName.getText().split('.') + : node.expression.getText().split('.') + const localName = referenced[0] as string + for (const statement of node.getSourceFile().statements) { + if (!ts.isImportDeclaration(statement) + || statement.importClause === undefined + || !ts.isStringLiteral(statement.moduleSpecifier) + || statement.moduleSpecifier.text !== moduleSpecifier) continue + if (statement.importClause.name?.text === localName) return 'default' + const bindings = statement.importClause.namedBindings + if (bindings !== undefined && ts.isNamedImports(bindings)) { + const imported = bindings.elements.find(element => element.name.text === localName) + if (imported !== undefined) return imported.propertyName?.text ?? imported.name.text + } + if (bindings !== undefined && ts.isNamespaceImport(bindings) && bindings.name.text === localName) { + return referenced[1] as string + } + } + /* v8 ignore next -- moduleSpecifierOf returns only the matching import inspected by this loop. */ + throw new TypertAnalysisError(`typert: cannot recover export name for ${localName} from ${moduleSpecifier}`) +} + +function importTypeAttributesText(node: ts.ImportTypeNode): string { + const sourceFile = node.getSourceFile() + const children = node.getChildren(sourceFile) + const comma = children.find(child => child.kind === ts.SyntaxKind.CommaToken) as ts.Node + const close = children.find(child => child.kind === ts.SyntaxKind.CloseParenToken) as ts.Node + return sourceFile.text.slice(comma.end, close.pos).trim() +} + +function moduleIdentity(specifier: string): ModuleIdentity | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/')) return undefined + const parts = specifier.split('/') + const packageLength = specifier.startsWith('@') ? 2 : 1 + const packageName = parts.slice(0, packageLength).join('/') + const rest = parts.slice(packageLength).join('/') + return { + package: packageName, + subpath: rest.length === 0 ? '.' : `./${rest}`, + } +} + +function externalModuleIdentityForFile(file: string): ModuleIdentity | undefined { + const normalized = slash(file) + const marker = '/node_modules/' + const index = normalized.lastIndexOf(marker) + if (index < 0) return undefined + const parts = normalized.slice(index + marker.length).split('/') + const packageLength = (parts[0] as string).startsWith('@') ? 2 : 1 + const packageName = parts.slice(0, packageLength).join('/') + return { package: packageName, subpath: '.' } +} + +function isStandardLibraryFile(file: string): boolean { + const base = file.replaceAll('\\', '/') + return /\/typescript\/lib\/lib\.[^/]+\.d\.ts$/.test(base) +} + +function formatDiagnostic(diagnostic: ts.Diagnostic): string { + return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') +} + +function formatProgramDiagnostic(root: string, face: TypertFace, diagnostic: ts.DiagnosticWithLocation): string { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + const file = slash(relative(root, diagnostic.file.fileName)) + return `typert(${face}): ${file}:${String(position.line + 1)}:${String(position.character + 1)}: TypeScript TS${String(diagnostic.code)}: ${message}` +} + +function realPath(path: string): string { + const absolute = resolve(path) + return existsSync(absolute) ? realpathSync(absolute) : absolute +} + +function isWithin(path: string, root: string): boolean { + const absolute = realPath(path) + const parent = realPath(root) + return absolute === parent || absolute.startsWith(parent + sep) +} + +function slash(value: string): string { + return value.replaceAll('\\', '/') +} + +function uniqueBy(values: readonly T[], key: (value: T) => string): T[] { + const result = new Map() + for (const value of values) if (!result.has(key(value))) result.set(key(value), value) + return [...result.values()] +} + +function compareCrossFaceLinks(left: CrossFaceLink, right: CrossFaceLink): number { + return left.fromFace.localeCompare(right.fromFace) + || left.fromPackage.localeCompare(right.fromPackage) + || left.toFace.localeCompare(right.toFace) + || left.toPackage.localeCompare(right.toPackage) + || left.subpath.localeCompare(right.subpath) + || left.name.localeCompare(right.name) +} diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts new file mode 100644 index 0000000000..89c8449b1b --- /dev/null +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -0,0 +1,814 @@ +/** + * Cordis catalog-specific projection over the compiler-independent Typert + * model. This module owns Cordis validation and text projection mechanics; + * callers supply repository-specific type classifications and inherited data. + * @module @deepseek-ai/dsh-typert-generator + */ + +import { WorkspaceAnalyzer } from './analyzer.ts' +import { childTypeNodeIds } from './model.ts' +import { TypeGraphRenderer } from './renderer.ts' +import type { + FaceModel, + MemberModel, + ParameterModel, + SignatureModel, + SourceDeclarationModel, + SourceLocation, + TypeNodeId, +} from './model.ts' + +type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' + +/** The fenced-block info string for generated signature blocks (skipped by + * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ +const FENCE = 'ts cordis-catalog' + +/** Append fail-closed signature type-link violations from the retained type tree. */ +function checkTypeLinks( + where: string, + names: readonly string[], + policy: CordisCatalogPolicy, + violations: string[], +): void { + for (const name of names) { + if (Object.hasOwn(policy.linkedTypePages, name) + || policy.foundationTypeNames.has(name) + || Object.hasOwn(policy.typeLinkExemptions, name)) continue + violations.push( + `${where} references unclassified type '${name}'. Add it to linkedTypePages with its documentation page, ` + + 'to foundationTypeNames if TypeScript or the framework owns it, or to typeLinkExemptions with ' + + 'the non-catalog documentation owner.', + ) + } +} + +/** Throw one aggregated diagnostic for every unclassified signature type. */ +function reportTypeLinkViolations(gate: string, violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `${gate}: ${violations.length} signature type-link coverage violation(s):\n` + + violations.map(violation => ` ${violation}`).join('\n'), + ) +} + +/** One harness event, extracted from an `interface Events` block. */ +export interface EventEntry { + /** Scoped name, e.g. `agent/request`. */ + name: string + /** The scope prefix, e.g. `agent` (everything before the first `/`). */ + scope: string + /** Full signature text (the method-signature member, JSDoc stripped). */ + signature: string + /** Original declaration JSDoc, dedented from its containing interface. */ + jsDoc: string + /** Dispatch mode from the `@mode` tag. */ + mode: Mode + /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ + doc: string + /** Source pointer `packages/…/file.ts:line` of the declaration. */ + source: string +} + +/** One public service method and the source contract attached to it. */ +export interface ServiceMethodEntry { + /** Public method signature (body stripped). */ + signature: string + /** Original method JSDoc, dedented from its containing class. */ + jsDoc: string +} + +/** One harness service, extracted from an `interface Context` block. */ +export interface ServiceEntry { + /** The `ctx.` name, e.g. `llm`. */ + key: string + /** The service class/interface name, e.g. `LlmService`. */ + type: string + /** Whether the service class is abstract (a seam interface). */ + abstract: boolean + /** Class-level JSDoc prose, one line per paragraph. */ + doc: string + /** Public methods (bodies stripped), in source order. */ + methods: ServiceMethodEntry[] + /** Source pointer of the class declaration. */ + source: string +} + +/** A terse inherited-tier entry supplied by the catalog policy. */ +export interface InheritedEntry { + /** Display name of the inherited event or context member group. */ + name: string + /** One-line description rendered into the catalog. */ + summary: string + /** Source pointer such as `vendor/…:line`. */ + source: string +} + +/** Repository policy consumed by the Cordis catalog parsing and rendering logic. */ +export interface CordisCatalogPolicy { + /** Type names linked from signatures to their documentation pages. */ + readonly linkedTypePages: Readonly> + /** TypeScript or framework types that need no repository documentation link. */ + readonly foundationTypeNames: ReadonlySet + /** Repository types deliberately documented outside the linked data catalog. */ + readonly typeLinkExemptions: Readonly> + /** Manually curated framework events inherited by every plugin. */ + readonly inheritedEvents: readonly InheritedEntry[] + /** Manually curated framework context members inherited by every plugin. */ + readonly inheritedServices: readonly InheritedEntry[] +} + +/** Complete model-level Cordis projection used by every text renderer. */ +export interface CordisCatalogModel { + readonly events: readonly EventEntry[] + readonly services: readonly ServiceEntry[] +} + +/** Repository-specific Cordis validation and projection over one Typert face. */ +export class CordisCatalogProjector { + private readonly renderer: TypeGraphRenderer + + /** + * @param face - analyzed host face containing package business semantics. + * @param sourceDeclarations - exported declarations available to the runtime type closure. + * @param policy - caller-owned type classifications and inherited Cordis data. + */ + constructor( + private readonly face: FaceModel, + private readonly sourceDeclarations: readonly SourceDeclarationModel[], + private readonly policy: CordisCatalogPolicy, + ) { + if (face.face !== 'host') throw new Error(`cordis catalog requires the host face, received ${face.face}`) + this.renderer = new TypeGraphRenderer(face.graph) + } + + /** + * Validate and project the host model's Cordis surface. + * @returns every validated service and event projected from the host model. + */ + project(): CordisCatalogModel { + return { + events: this.collectEvents(), + services: this.collectServices(), + } + } + + /** + * Render the model-facing static API consumed by `tool-cordis`. + * @param model - validated Cordis catalog projection from this projector. + * @returns the model-facing TypeScript catalog source. + */ + renderRuntimeApi(model: CordisCatalogModel): string { + return renderRuntimeApi( + model.services, + model.events, + this.runtimeTypes(model.services), + this.policy.inheritedServices, + ) + } + + private collectEvents(): EventEntry[] { + const entries: EventEntry[] = [] + const violations: string[] = [] + const typeLinkViolations: string[] = [] + for (const packageModel of this.face.packages) { + for (const event of packageModel.events) { + const source = pointer(event.location) + const where = `event '${event.name}' (${source})` + const node = this.renderer.node(event.signature) + if (node.kind !== 'function') { + violations.push(`${where} is not represented by a callable type.`) + continue + } + checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations) + const parsed = parseJsDoc(event.jsDoc ?? '') + const mode = event.mode + if (!isMode(mode)) { + violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) + } + const last = node.signature.parameters.at(-1) + const hasNext = last?.name === 'next' + if (isMode(mode) && hasNext && mode !== 'waterfall') { + violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) + } + if (isMode(mode) && !hasNext && mode === 'waterfall') { + violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) + } + if (parsed.doc === '') { + violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) + } + checkParams( + where, + 'event', + node.signature.parameters, + parsed.params, + parameter => parameter.receiver || (hasNext && parameter === last), + violations, + ) + if (isMode(mode)) { + entries.push({ + name: event.name, + scope: event.name.split('/')[0] ?? event.name, + signature: event.text, + jsDoc: event.jsDoc ?? '', + mode, + doc: parsed.doc, + source, + }) + } + } + } + reportViolations('gen-cordis-catalog', violations) + reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations) + return entries + } + + private collectServices(): ServiceEntry[] { + const entries: ServiceEntry[] = [] + const violations: string[] = [] + const typeLinkViolations: string[] = [] + for (const packageModel of this.face.packages) { + for (const service of packageModel.services) { + const declaration = this.renderer.declaration(service.symbol) + if (declaration.kind !== 'class' + || !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file) + || declaration.location.file !== service.location.file) continue + const doc = parseJsDoc(declaration.jsDoc ?? '').doc + const source = pointer(declaration.location) + if (doc === '') { + violations.push(`service ctx.${service.key} (${source}): class ${declaration.name} has no JSDoc.`) + } + const methods: ServiceMethodEntry[] = [] + for (const memberId of service.members) { + const member = this.renderer.member(memberId) + if (member.kind !== 'method' || member.name.startsWith('[')) continue + const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})` + checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations) + methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' }) + if (member.jsDoc === undefined) { + violations.push(`${where} has no JSDoc.`) + continue + } + const parsed = parseJsDoc(member.jsDoc) + if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`) + checkParams(where, 'service', member.signature.parameters, parsed.params, + parameter => parameter.receiver, violations) + checkReturns(where, member.signature, parsed.returns, this.renderer, violations) + } + entries.push({ + key: service.key, + type: declaration.name, + abstract: declaration.abstract, + doc, + methods, + source, + }) + } + } + reportViolations('gen-cordis-catalog', violations) + reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations) + return entries.sort((left, right) => left.key.localeCompare(right.key)) + } + + private runtimeTypes(services: readonly ServiceEntry[]): { name: string; declaration: string }[] { + const declarations = new Map() + const ambiguous = new Set() + for (const declaration of this.sourceDeclarations) { + if (declaration.face !== 'host' || declaration.kind === 'enum' + || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue + if (declarations.has(declaration.name)) { + ambiguous.add(declaration.name) + continue + } + declarations.set( + declaration.name, + declaration.text.length > MAX_DECL_CHARS + ? `${declaration.text.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` + : declaration.text, + ) + } + for (const name of ambiguous) declarations.delete(name) + return referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), declarations) + } +} + +/** + * Analyze the host project once and return both the model and its projection. + * @param scanRoot - workspace root containing `tsconfig.host.json`. + * @param policy - caller-owned type classifications and inherited Cordis data. + * @returns the configured projector and its validated catalog model. + */ +export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy): { + readonly projector: CordisCatalogProjector + readonly model: CordisCatalogModel +} { + const discovery = new WorkspaceAnalyzer({ + root: scanRoot, + faces: ['host'], + checkDiagnostics: false, + }).discoverPackages() + const packages = discovery.filter(candidate => candidate.faces.includes('host')) + .map(candidate => candidate.package) + const workspace = new WorkspaceAnalyzer({ + root: scanRoot, + faces: ['host'], + packages, + checkDiagnostics: false, + }).analyzeInBatches() + const face = workspace.faces.find(candidate => candidate.face === 'host') + if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face') + const sourceDeclarations = new WorkspaceAnalyzer({ + root: scanRoot, + faces: ['host'], + checkDiagnostics: false, + }).indexSourceDeclarations() + const projector = new CordisCatalogProjector(face, sourceDeclarations, policy) + return { projector, model: projector.project() } +} + +/** + * Collect all modeled events for relationship-document consumers. + * @param scanRoot - workspace root containing `tsconfig.host.json`. + * @param policy - caller-owned Cordis catalog policy. + * @returns all validated event entries. + */ +export function collectEvents(scanRoot: string, policy: CordisCatalogPolicy): EventEntry[] { + return [...projectCordisCatalog(scanRoot, policy).model.events] +} + +/** + * Collect all modeled services for relationship-document consumers. + * @param scanRoot - workspace root containing `tsconfig.host.json`. + * @param policy - caller-owned Cordis catalog policy. + * @returns all validated service entries. + */ +export function collectServices(scanRoot: string, policy: CordisCatalogPolicy): ServiceEntry[] { + return [...projectCordisCatalog(scanRoot, policy).model.services] +} + +interface ParsedJsDoc { + readonly doc: string + readonly params: ReadonlyMap + readonly returns: string | null +} + +function parseJsDoc(raw: string): ParsedJsDoc { + const lines = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(line => line.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const blocks: string[] = [] + let paragraph: string[] = [] + let list: string[] = [] + let item: string[] = [] + let inTags = false + const join = (parts: readonly string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim() + const flushItem = (): void => { + if (item.length > 0) list.push(join(item)) + item = [] + } + const flushList = (): void => { + flushItem() + if (list.length > 0) blocks.push(list.join('\n')) + list = [] + } + const flushParagraph = (): void => { + flushList() + if (paragraph.length > 0) blocks.push(join(paragraph)) + paragraph = [] + } + for (const line of lines) { + const tagLine = line.trimStart() + if (tagLine.startsWith('@')) { + flushParagraph() + inTags = true + continue + } + if (inTags) continue + if (line.trim() === '') { + flushParagraph() + continue + } + if (/^-\s+/.test(line)) { + flushItem() + if (paragraph.length > 0) { + blocks.push(join(paragraph)) + paragraph = [] + } + item.push(line) + continue + } + if (item.length > 0) item.push(line) + else paragraph.push(line) + } + flushParagraph() + + const params = new Map() + let returns: string | null = null + let sink: ((text: string) => void) | undefined + for (const line of lines) { + const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) + if (param !== null) { + const name = (param[1] ?? '').replace(/^\[|\]$/g, '') + let value = param[2] ?? '' + params.set(name, value) + sink = (text) => { + value = value === '' ? text : `${value} ${text}` + params.set(name, value) + } + continue + } + const returnsTag = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) + if (returnsTag !== null) { + let value = returnsTag[1] ?? '' + returns = value + sink = (text) => { + value = value === '' ? text : `${value} ${text}` + returns = value + } + continue + } + if (line.startsWith('@') || line.trim() === '') sink = undefined + else sink?.(line.trim()) + } + return { + doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(), + params, + returns, + } +} + +function checkParams( + where: string, + surface: string, + parameters: readonly ParameterModel[], + tags: ReadonlyMap, + isExempt: (parameter: ParameterModel) => boolean, + violations: string[], +): void { + for (const parameter of parameters) { + if (parameter.binding !== 'identifier') { + violations.push(`${where}: parameter '${parameter.name}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`) + continue + } + if (isExempt(parameter)) continue + const description = tags.get(parameter.name) + if (description === undefined) violations.push(`${where} is missing @param ${parameter.name}.`) + else if (description.trim() === '') violations.push(`${where}: @param ${parameter.name} has an empty description.`) + } + for (const tag of tags.keys()) { + if (!parameters.some(parameter => parameter.binding === 'identifier' && parameter.name === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } +} + +function checkReturns( + where: string, + signature: SignatureModel, + returns: string | null, + renderer: TypeGraphRenderer, + violations: string[], +): void { + const type = renderer.renderType(signature.returns) + if (type === 'void' || type === 'Promise') return + if (returns === null) violations.push(`${where} is missing @returns (return type: ${type}).`) + else if (returns.trim() === '') violations.push(`${where}: @returns has an empty description.`) +} + +function reportViolations(gate: string, violations: readonly string[]): void { + if (violations.length === 0) return + throw new Error( + `${gate}: ${String(violations.length)} JSDoc completeness violation(s) (see AGENTS.md):\n` + + violations.map(violation => ` ${violation}`).join('\n'), + ) +} + +function pointer(location: SourceLocation): string { + return `${location.file}:${String(location.line)}` +} + +function isMode(mode: string | undefined): mode is Mode { + return mode === 'emit' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial' +} + +function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] { + const names = new Set() + const visited = new Set() + const visitSignature = (current: SignatureModel): void => { + for (const parameter of current.typeParameters) { + if (parameter.constraint !== undefined) visit(parameter.constraint) + if (parameter.default !== undefined) visit(parameter.default) + } + for (const parameter of current.parameters) visit(parameter.type) + visit(current.returns) + } + const visitMember = (member: MemberModel): void => { + if (member.kind === 'property') visit(member.type) + else visitSignature(member.signature) + } + const visit = (id: TypeNodeId): void => { + if (visited.has(id)) return + visited.add(id) + const node = renderer.node(id) + if (node.kind === 'reference' && node.target.kind !== 'type-parameter') names.add(node.name) + if (node.kind === 'type-query') names.add(node.expression) + for (const child of childTypeNodeIds(node)) visit(child) + if (node.kind === 'object') for (const member of node.members) visitMember(member) + if (node.kind === 'function' || node.kind === 'constructor') visitSignature(node.signature) + } + visitSignature(signature) + return [...names].sort() +} + +/** Declarations longer than this render as a truncated stub. */ +const MAX_DECL_CHARS = 1500 + +/** Render one value as a single-quoted TypeScript literal. */ +function quote(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'` +} + +/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */ +function referencedTypes( + seeds: readonly string[], + declarations: ReadonlyMap, +): { name: string; declaration: string }[] { + const included = new Map() + let frontier = [...seeds] + while (frontier.length > 0) { + const next: string[] = [] + for (const [name, declaration] of declarations) { + if (included.has(name)) continue + const pattern = new RegExp(`\\b${name}\\b`) + if (frontier.some(text => pattern.test(text))) { + included.set(name, declaration) + next.push(declaration) + } + } + frontier = next + } + return [...included] + .map(([name, declaration]) => ({ name, declaration })) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function firstSentence(doc: string): string { + const line = doc.split('\n', 1)[0] ?? '' + const match = /^(.*?[.!?])(?:\s|$)/.exec(line) + return (match?.[1] ?? line).trim() +} + +/** Render the byte-compatible model-facing API catalog. */ +function renderRuntimeApi( + services: readonly ServiceEntry[], + events: readonly EventEntry[], + types: readonly { name: string; declaration: string }[], + inheritedServices: readonly InheritedEntry[], +): string { + const lines: string[] = [ + '/**', + ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run', + ' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by', + ' * `pnpm run verify-cordis-api` in doc-sync).', + ' *', + ' * The machine-readable cordis API catalog `cordis_inspect` serves to the', + ' * model: harness services (summary + public method signatures/JSDoc),', + ' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by', + ' * the same AST walk as docs/cordis-catalog, so this data and the rendered', + ' * docs cannot diverge.', + ' *', + ' * @module @deepseek-ai/dsh-tool-cordis/api-catalog', + ' */', + '', + '/** One public service method and its source-owned contract. */', + 'export interface ServiceApiMethod {', + ' /** Public method signature with its body stripped. */', + ' signature: string', + ' /** Original method JSDoc, with only container indentation removed. */', + ' jsDoc: string', + '}', + '', + '/** One harness `ctx.` service: its one-line summary and public methods. */', + 'export interface ServiceApiEntry {', + ' /** The `ctx.` name, e.g. `tools`. */', + ' key: string', + ' /** First sentence of the service class JSDoc. */', + ' summary: string', + ' /** Public methods, bodies stripped, in source order. */', + ' methods: readonly ServiceApiMethod[]', + '}', + '', + '/** One harness event: its dispatch mode, exact signature, and one-line summary. */', + 'export interface EventApiEntry {', + ' /** The scoped event name, e.g. `agent/status`. */', + ' name: string', + ' /** The dispatch mode from the declaration\'s `@mode` tag. */', + ' mode: string', + ' /** The exact listener signature, whitespace-normalized. */', + ' signature: string', + ' /** Original event JSDoc, with only container indentation removed. */', + ' jsDoc: string', + ' /** First sentence of the event JSDoc. */', + ' summary: string', + '}', + '', + '/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */', + 'export interface InheritedApiEntry {', + ' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */', + ' name: string', + ' /** One-line summary of what the member does. */', + ' summary: string', + '}', + '', + '/** One named type shape the service signatures reference. */', + 'export interface TypeApiEntry {', + ' /** The exported type/interface name, e.g. `BashRunResult`. */', + ' name: string', + ' /** The full declaration text, comments stripped. */', + ' declaration: string', + '}', + '', + '/** Every harness `ctx.` service, sorted by key. */', + 'export const SERVICE_API: readonly ServiceApiEntry[] = [', + ] + for (const service of services) { + lines.push(' {') + lines.push(` key: ${quote(service.key)},`) + lines.push(` summary: ${quote(firstSentence(service.doc))},`) + if (service.methods.length === 0) { + lines.push(' methods: [],') + } else { + lines.push(' methods: [') + for (const method of service.methods) { + lines.push(' {') + lines.push(` signature: ${quote(method.signature)},`) + lines.push(` jsDoc: ${quote(method.jsDoc)},`) + lines.push(' },') + } + lines.push(' ],') + } + lines.push(' },') + } + lines.push( + ']', + '', + '/** Every harness event, sorted by name. */', + 'export const EVENT_API: readonly EventApiEntry[] = [', + ) + for (const event of [...events].sort((left, right) => left.name.localeCompare(right.name))) { + lines.push(' {') + lines.push(` name: ${quote(event.name)},`) + lines.push(` mode: ${quote(event.mode)},`) + lines.push(` signature: ${quote(event.signature)},`) + lines.push(` jsDoc: ${quote(event.jsDoc)},`) + lines.push(` summary: ${quote(firstSentence(event.doc))},`) + lines.push(' },') + } + lines.push( + ']', + '', + '/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */', + 'export const TYPE_API: readonly TypeApiEntry[] = [', + ) + for (const type of types) { + lines.push(' {') + lines.push(` name: ${quote(type.name)},`) + lines.push(` declaration: ${quote(type.declaration)},`) + lines.push(' },') + } + lines.push( + ']', + '', + '/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */', + 'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [', + ) + for (const inherited of inheritedServices) { + lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`) + } + lines.push(']', '') + return lines.join('\n') +} +/** Render the cross-link "Types:" line for a signature, or '' if none apply. */ +function typeLinks(signature: string, linkedTypePages: Readonly>): string { + const seen = new Set() + for (const name of Object.keys(linkedTypePages)) { + if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name) + } + if (seen.size === 0) return '' + const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${linkedTypePages[n]})`) + return `Types: ${links.join(' · ')}` +} + +/** Render one harness event entry. */ +function renderEvent(e: EventEntry, linkedTypePages: Readonly>): string[] { + const out = [`### \`${e.name}\` — ${e.mode}`, ''] + if (e.doc) out.push(e.doc, '') + out.push('```' + FENCE, e.jsDoc, e.signature, '```', '') + const links = typeLinks(e.signature, linkedTypePages) + if (links) out.push(links, '') + out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') + return out +} + +/** Render one harness service entry. */ +function renderService(s: ServiceEntry, linkedTypePages: Readonly>): string[] { + const kind = s.abstract ? ' (abstract seam)' : '' + const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] + if (s.doc) out.push(s.doc, '') + if (s.methods.length) { + const declarations = s.methods.flatMap((method, index) => [ + ...(index > 0 ? [''] : []), + method.jsDoc, + method.signature, + ]) + out.push('```' + FENCE, ...declarations, '```', '') + const links = typeLinks(s.methods.map(method => method.signature).join('\n'), linkedTypePages) + if (links) out.push(links, '') + } + out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') + return out +} + +/** The shared generated-file banner comment. */ +const BANNER = [ + '', + '', +] + +/** The shared GENERATED + freshness-gate + fence notice paragraph. */ +const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.' + +/** + * Render the events catalog deterministically. + * @param events - validated event entries to render. + * @param policy - type links and inherited events supplied by the caller. + * @returns the complete generated Markdown document. + */ +export function renderEvents(events: EventEntry[], policy: CordisCatalogPolicy): string { + const lines: string[] = [ + ...BANNER, + '# Cordis Events Catalog', + '', + 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', + '', + GATE_NOTICE, + '', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).', + '', + 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', + '', + ] + const scopes = [...new Set(events.map(e => e.scope))].sort() + for (const scope of scopes) { + lines.push(`## \`${scope}/*\``, '') + for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(...renderEvent(e, policy.linkedTypePages)) + } + } + lines.push( + '## Inherited events (cordis core + loader/hmr/timer)', + '', + 'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.', + '', + ) + for (const e of policy.inheritedEvents) { + lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`) + } + lines.push('') + return lines.join('\n') +} + +/** + * Render the services catalog deterministically. + * @param services - validated service entries to render. + * @param policy - type links and inherited services supplied by the caller. + * @returns the complete generated Markdown document. + */ +export function renderServices(services: ServiceEntry[], policy: CordisCatalogPolicy): string { + const lines: string[] = [ + ...BANNER, + '# Cordis Services Catalog', + '', + 'Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', + '', + GATE_NOTICE, + '', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).', + '', + ] + for (const s of services) lines.push(...renderService(s, policy.linkedTypePages)) + lines.push( + '## Inherited `ctx` members (cordis core + loader/hmr/timer)', + '', + 'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.', + '', + ) + for (const s of policy.inheritedServices) { + lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`) + } + lines.push('') + return lines.join('\n') +} diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts new file mode 100644 index 0000000000..4a09eaad68 --- /dev/null +++ b/packages/typert/generator/src/emitter.ts @@ -0,0 +1,451 @@ +/** + * Model-driven Typert artifact emitter. It consumes only FaceModel and + * TypeGraph data; TypeScript compiler nodes are not part of this boundary. + * @module @deepseek-ai/dsh-typert-generator/emitter + */ + +import type { + DocumentationModel, + FaceModel, + MemberModel, + PackageModel, + SchemaModel, + SymbolId, + TypeDeclarationModel, + TypeNodeId, + TypeNodeModel, +} from './model.ts' +import { TypeGraphRenderer } from './renderer.ts' + +/** Failure to project a modeled construct into an emitted artifact. */ +export class TypertEmitError extends Error { + override name = 'TypertEmitError' +} + +/** JavaScript and declaration artifacts for one package on one face. */ +export interface ModelEmitResult { + readonly package: string + readonly face: FaceModel['face'] + readonly exports: readonly string[] + readonly js: string + readonly dts: string +} + +interface RuntimeMemberModel { + readonly kind: MemberModel['kind'] + readonly name: string + readonly signature: string + readonly summary?: string + readonly jsDoc?: string +} + +interface RuntimeTypeModel { + readonly name: string + readonly declaration: string +} + +interface RuntimeServiceModel extends DocumentationModel { + readonly key: string + readonly exportName: string + readonly members: readonly RuntimeMemberModel[] + readonly types: readonly RuntimeTypeModel[] +} + +interface RuntimeEventModel extends DocumentationModel { + readonly name: string + readonly mode?: string + readonly signature: string +} + +interface RuntimeObjectModel extends DocumentationModel { + readonly name: string + readonly exportName: string + readonly members: readonly RuntimeMemberModel[] + readonly types: readonly RuntimeTypeModel[] +} + +interface RuntimePackageModel { + readonly services: readonly RuntimeServiceModel[] + readonly events: readonly RuntimeEventModel[] + readonly objects: readonly RuntimeObjectModel[] +} + +/** Emit generated runtime and type artifacts from one independently analyzed face. */ +export class FaceModelEmitter { + private readonly renderer: TypeGraphRenderer + + /** + * Create an emitter for one face graph. + * @param face - independently analyzed face. + */ + constructor(private readonly face: FaceModel) { + this.renderer = new TypeGraphRenderer(face.graph) + } + + /** + * Emit one modeled package. + * @param packageName - exact package name in the face model. + * @returns executable JavaScript and its precise declaration file. + */ + emit(packageName: string): ModelEmitResult { + const packageModel = this.face.packages.find(candidate => candidate.name === packageName) + if (packageModel === undefined) { + throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) + } + const schemas = new SchemaEmitter(this.renderer, packageModel.schemas) + const schemaArtifact = schemas.emit() + const runtimeModel = this.runtimeModel(packageModel) + const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) + const dts = this.renderDts(packageModel, schemaArtifact) + return { + package: packageName, + face: this.face.face, + exports: packageModel.schemas.map(schema => schema.export.name), + js, + dts, + } + } + + private runtimeModel(packageModel: PackageModel): RuntimePackageModel { + const services = packageModel.services.map((service): RuntimeServiceModel => { + const members = service.members.map(id => this.runtimeMember(this.renderer.member(id))) + return { + ...documentationLiteral(service), + key: service.key, + exportName: service.export.name, + members, + types: this.runtimeTypes(this.renderer.declarationClosureForMembers(service.members), service.symbol), + } + }) + const events = packageModel.events.map((event): RuntimeEventModel => { + const node = this.renderer.node(event.signature) + if (node.kind !== 'function') { + throw new TypertEmitError(`typert emitter(${this.face.face}): event ${event.name} is not a function type`) + } + return { + ...documentationLiteral(event), + name: event.name, + ...(event.mode === undefined ? {} : { mode: event.mode }), + signature: `${quote(event.name)}${this.renderer.renderSignature(node.signature)}`, + } + }) + const objects = packageModel.objects.map((object): RuntimeObjectModel => { + const declaration = this.renderer.declaration(object.symbol) + return { + ...documentationLiteral(object), + name: declaration.name, + exportName: object.export.name, + members: declaration.members.map(member => this.runtimeMember(member)), + types: this.runtimeTypes(this.renderer.declarationClosureForMembers(declaration.members.map(member => member.id)), declaration.id), + } + }) + return { services, events, objects } + } + + private runtimeMember(member: MemberModel): RuntimeMemberModel { + return { + kind: member.kind, + name: member.name, + signature: this.renderer.renderMember(member, true), + ...(member.summary === undefined ? {} : { summary: member.summary }), + ...(member.jsDoc === undefined ? {} : { jsDoc: member.jsDoc }), + } + } + + private runtimeTypes(declarations: readonly TypeDeclarationModel[], root: SymbolId): RuntimeTypeModel[] { + return declarations + .filter(declaration => declaration.id !== root) + .map(declaration => ({ + name: declaration.name, + declaration: this.renderer.renderDeclaration(declaration.id), + })) + .sort((left, right) => left.name.localeCompare(right.name)) + } + + private renderJs( + packageModel: PackageModel, + schemas: SchemaArtifact, + runtimeModel: RuntimePackageModel, + ): string { + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */', + ] + if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + lines.push(...schemas.definitions) + if (schemas.definitions.length > 0) lines.push('') + for (const schema of schemas.exports) lines.push(`export const ${schema.exportName} = ${schema.internalName}`) + if (schemas.exports.length > 0) lines.push('') + const model = JSON.stringify(runtimeModel, null, 2) + lines.push('export const TYPERT = {') + lines.push(` package: ${quote(packageModel.name)},`) + lines.push(` face: ${quote(this.face.face)},`) + lines.push(' schemas: [') + for (const schema of schemas.exports) { + lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`) + } + lines.push(' ],') + lines.push(` model: ${indent(model, 2).trimStart()},`) + lines.push('}') + return `${lines.join('\n')}\n` + } + + private renderDts(packageModel: PackageModel, schemas: SchemaArtifact): string { + const imports = new Map() + for (const schema of schemas.exports) { + const specifier = packageExportSpecifier(packageModel.name, schema.model.export.subpath) + const names = imports.get(specifier) ?? [] + names.push(`${schema.model.export.name} as ${schema.exportName}$source`) + imports.set(specifier, names) + } + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */', + ] + if (schemas.exports.length > 0) lines.splice(1, 0, 'import type { z } from \'zod\'') + for (const [specifier, names] of [...imports].sort(([left], [right]) => left.localeCompare(right))) { + lines.push(`import type { ${names.sort().join(', ')} } from ${quote(specifier)}`) + } + lines.push('') + for (const schema of schemas.exports) { + lines.push(`export declare const ${schema.exportName}: z.ZodType<${schema.exportName}$source>`) + } + if (schemas.exports.length > 0) lines.push('') + // The Loader validates and narrows this generated module boundary before + // registration. Keeping the public declaration unknown prevents every + // contributing business package from depending on the runtime registry. + lines.push('export declare const TYPERT: unknown') + return `${lines.join('\n')}\n` + } +} + +interface SchemaExport { + readonly model: SchemaModel + readonly exportName: string + readonly internalName: string +} + +interface SchemaArtifact { + readonly definitions: readonly string[] + readonly exports: readonly SchemaExport[] +} + +class SchemaEmitter { + private readonly names = new Map() + private readonly declarations: TypeDeclarationModel[] + + constructor( + private readonly renderer: TypeGraphRenderer, + private readonly schemas: readonly SchemaModel[], + ) { + const declarations = new Map() + for (const schema of schemas) { + for (const declaration of renderer.declarationClosureForTypes([schema.type])) { + declarations.set(declaration.id, declaration) + } + } + this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) + const identifiers = new Set() + for (const declaration of this.declarations) { + const base = `${safeIdentifier(declaration.name)}$schema` + let name = base + let suffix = 2 + while (identifiers.has(name)) name = `${base}${String(suffix++)}` + identifiers.add(name) + this.names.set(declaration.id, name) + } + } + + emit(): SchemaArtifact { + const definitions = this.declarations.map((declaration) => { + if (declaration.typeParameters.length > 0) { + this.fail(declaration.name, 'generic declarations require a schema-factory projection') + } + return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}` + }) + const exports = this.schemas.map((model): SchemaExport => ({ + model, + exportName: safeIdentifier(model.export.name), + internalName: this.schemaName(model.symbol), + })) + return { definitions, exports } + } + + private declarationSchema(declaration: TypeDeclarationModel): string { + if (declaration.kind === 'enum') { + this.fail(declaration.name, 'enum declarations have no Zod projection') + } + if (declaration.kind === 'alias') { + if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type') + return this.describe(this.typeSchema(declaration.type), declaration) + } + const own = this.objectSchema(declaration.members, declaration.name) + let result = own + for (const heritage of declaration.extends) { + result = `z.intersection(${this.typeSchema(heritage)}, ${result})` + } + return this.describe(result, declaration) + } + + private typeSchema(id: TypeNodeId): string { + const node = this.renderer.node(id) + switch (node.kind) { + case 'keyword': return this.keywordSchema(node.name) + case 'literal': return `z.literal(${node.text})` + case 'parenthesized': return this.typeSchema(node.type) + case 'reference': return this.referenceSchema(node) + case 'union': { + if (node.types.length === 0) return 'z.never()' + if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId) + return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])` + } + case 'intersection': { + const [head, ...tail] = node.types + if (head === undefined) return 'z.unknown()' + return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head)) + } + case 'array': return `z.array(${this.typeSchema(node.element)})` + case 'tuple': { + const fixed = node.elements.filter(element => !element.rest) + const rest = node.elements.find(element => element.rest) + let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])` + if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})` + return schema + } + case 'object': return this.objectSchema(node.members, id) + case 'operator': + case 'indexed-access': + case 'conditional': + case 'infer': + case 'mapped': + case 'template-literal': + case 'type-query': + case 'import-type': + case 'predicate': + case 'function': + case 'constructor': + case 'this': return this.unsupported(node) + } + } + + private referenceSchema(node: Extract): string { + if (node.target.kind === 'declaration') { + return `z.lazy(() => ${this.schemaName(node.target.symbol)})` + } + if (node.target.kind === 'standard') { + switch (node.target.name) { + case 'Array': + case 'ReadonlyArray': { + const element = node.arguments[0] + if (element === undefined) this.fail(node.name, 'array reference has no element type') + return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray') + } + case 'Record': { + const key = node.arguments[0] + const value = node.arguments[1] + if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types') + return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})` + } + case 'Date': return 'z.date()' + default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) + } + } + this.fail(node.name, `${node.target.kind} reference has no Zod projection`) + } + + private tupleRestSchema(id: TypeNodeId): string { + const node = this.renderer.node(id) + if (node.kind === 'array') return this.typeSchema(node.element) + if (node.kind === 'reference' + && node.target.kind === 'standard' + && (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) { + const element = node.arguments[0] + if (element === undefined) this.fail(node.name, 'tuple rest array has no element type') + return this.typeSchema(element) + } + this.fail(id, 'tuple rest element must retain an array type') + } + + private objectSchema(members: readonly MemberModel[], subject: string): string { + const properties: string[] = [] + for (const member of members) { + if (member.static || member.visibility !== 'public') continue + if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) + const property = this.describe( + this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional), + member, + ) + properties.push(`${quote(member.name)}: ${property}`) + } + return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + } + + private keywordSchema(name: string): string { + switch (name) { + case 'any': return 'z.any()' + case 'unknown': return 'z.unknown()' + case 'never': return 'z.never()' + case 'string': return 'z.string()' + case 'number': return 'z.number()' + case 'bigint': return 'z.bigint()' + case 'boolean': return 'z.boolean()' + case 'symbol': return 'z.symbol()' + case 'undefined': return 'z.undefined()' + case 'void': return 'z.void()' + case 'object': return "z.custom((value) => (typeof value === 'object' && value !== null) || typeof value === 'function')" + default: this.fail(name, `keyword ${name} has no Zod projection`) + } + } + + private schemaName(symbol: SymbolId): string { + const name = this.names.get(symbol) + if (name === undefined) this.fail(symbol, 'referenced declaration is outside the selected schema closure') + return name + } + + private describe(schema: string, documentation: DocumentationModel): string { + return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})` + } + + private optional(schema: string, optional: boolean): string { + return optional ? `${schema}.optional()` : schema + } + + private readonly(schema: string, readonly: boolean): string { + return readonly ? `${schema}.readonly()` : schema + } + + private unsupported(node: TypeNodeModel): never { + this.fail(node.id, `type node ${node.kind} has no Zod projection`) + } + + private fail(subject: string, message: string): never { + throw new TypertEmitError(`typert Zod emitter: ${subject}: ${message}`) + } +} + +function documentationLiteral(documentation: DocumentationModel): DocumentationModel { + return { + ...(documentation.description === undefined ? {} : { description: documentation.description }), + ...(documentation.summary === undefined ? {} : { summary: documentation.summary }), + tags: documentation.tags, + ...(documentation.jsDoc === undefined ? {} : { jsDoc: documentation.jsDoc }), + } +} + +function packageExportSpecifier(packageName: string, subpath: string): string { + return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` +} + +function safeIdentifier(name: string): string { + const normalized = name.replace(/[^$\w]/gu, '_') + if (/^[$A-Z_a-z]/u.test(normalized)) return normalized + return `_${normalized}` +} + +function quote(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'` +} + +function indent(value: string, spaces: number): string { + const prefix = ' '.repeat(spaces) + return value.split('\n').map(line => `${prefix}${line}`).join('\n') +} diff --git a/packages/typert/generator/src/index.ts b/packages/typert/generator/src/index.ts new file mode 100644 index 0000000000..77d85e9e87 --- /dev/null +++ b/packages/typert/generator/src/index.ts @@ -0,0 +1,16 @@ +/** + * Public surface of the Typert analyzer, compiler-independent model, and + * model-driven artifact emitters. Build wiring lives in the `./tsdown` + * subpath. + * @module @deepseek-ai/dsh-typert-generator + */ + +export { WorkspaceAnalyzer, TypertAnalysisError } from './analyzer.ts' +export type { AnalysisMode, DiscoveredTypertPackage, WorkspaceAnalyzerOptions } from './analyzer.ts' +export { FaceModelEmitter, TypertEmitError } from './emitter.ts' +export type { ModelEmitResult } from './emitter.ts' +export * from './cordis-catalog.ts' +export { TypeGraphRenderer, TypeGraphRenderError } from './renderer.ts' +export { WorkspaceTypertGenerator } from './workspace.ts' +export type { WorkspaceEmitResult } from './workspace.ts' +export type * from './model.ts' diff --git a/packages/typert/generator/src/invariant.ts b/packages/typert/generator/src/invariant.ts new file mode 100644 index 0000000000..e4da20785f --- /dev/null +++ b/packages/typert/generator/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-typert-generator`. + * @module @deepseek-ai/dsh-typert-generator/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-typert-generator' + +/** Cordis companion plugin name. */ +export const name = 'typert-generator-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this source-project analyzer and build-time emitter + * runs outside any cordis runtime; model snapshots, executable artifacts, and + * consuming-package typechecks enforce its output contract. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts new file mode 100644 index 0000000000..c6b7ffbc87 --- /dev/null +++ b/packages/typert/generator/src/model.ts @@ -0,0 +1,375 @@ +/** + * Compiler-independent Typert analysis model. TypeScript nodes and checker + * objects are extraction inputs only; emitters consume this graph. + * @module @deepseek-ai/dsh-typert-generator/model + */ + +/** One independently compiled side of the workspace. */ +export type TypertFace = 'host' | 'client' + +/** Stable graph-local identifier of a type expression. */ +export type TypeNodeId = string + +/** Stable workspace identifier of a declared symbol. */ +export type SymbolId = string + +/** Keyword types accepted in ordinary TypeScript source declarations. */ +export type KeywordTypeName = + | 'any' + | 'bigint' + | 'boolean' + | 'never' + | 'number' + | 'object' + | 'string' + | 'symbol' + | 'undefined' + | 'unknown' + | 'void' + +/** Prefix operators accepted on TypeScript type nodes. */ +export type TypeOperatorName = 'keyof' | 'readonly' | 'unique' + +/** Source position retained for diagnostics and source-edit mode. */ +export interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +/** One public package export and the declaration it resolves to. */ +export interface ExportModel { + readonly subpath: string + readonly name: string + readonly symbol: SymbolId + readonly aliases: readonly string[] +} + +/** One structured JSDoc tag, retaining its original text for unknown tags. */ +export interface JsDocTagModel { + readonly name: string + readonly argument?: string + readonly comment?: string + readonly text: string +} + +/** JSDoc retained as a standard part of every documented model element. */ +export interface DocumentationModel { + readonly description?: string + readonly summary?: string + readonly tags: readonly JsDocTagModel[] + readonly jsDoc?: string +} + +/** One Cordis Context contribution. */ +export interface ServiceModel extends DocumentationModel { + readonly key: string + readonly symbol: SymbolId + readonly export: ExportModel + readonly members: readonly string[] + readonly location: SourceLocation +} + +/** One Cordis Events contribution. */ +export interface EventModel extends DocumentationModel { + readonly name: string + readonly signature: TypeNodeId + /** Body-free declaration text retained for byte-stable source projections. */ + readonly text: string + readonly mode?: string + readonly location: SourceLocation +} + +/** One explicitly exported reference-passed object. */ +export interface ObjectModel extends DocumentationModel { + readonly export: ExportModel + readonly symbol: SymbolId + readonly passing: 'reference' +} + +/** One explicitly selected value type for schema generation. */ +export interface SchemaModel extends DocumentationModel { + readonly export: ExportModel + readonly symbol: SymbolId + readonly type: TypeNodeId +} + +/** Business semantics discovered in one package on one face. */ +export interface PackageModel { + readonly name: string + readonly root: string + readonly exports: readonly ExportModel[] + readonly services: readonly ServiceModel[] + readonly events: readonly EventModel[] + readonly objects: readonly ObjectModel[] + readonly schemas: readonly SchemaModel[] +} + +/** One explicit import/re-export edge between independently compiled faces. */ +export interface CrossFaceLink { + readonly fromFace: TypertFace + readonly fromPackage: string + readonly toFace: TypertFace + readonly toPackage: string + readonly subpath: string + readonly name: string +} + +/** Complete analysis result for an independently compiled face. */ +export interface FaceModel { + readonly face: TypertFace + readonly packages: readonly PackageModel[] + readonly graph: TypeGraph +} + +/** Complete host/client analysis result. */ +export interface WorkspaceModel { + readonly faces: readonly FaceModel[] + readonly crossFaceLinks: readonly CrossFaceLink[] +} + +/** One top-level authored type declaration indexed without making it a graph root. */ +export interface SourceDeclarationModel { + readonly face: TypertFace + readonly package: string + readonly name: string + readonly kind: 'interface' | 'class' | 'alias' | 'enum' + readonly location: SourceLocation + readonly text: string +} + +/** Visibility recorded on class members. */ +export type MemberVisibility = 'public' | 'protected' | 'private' + +/** One generic type parameter, preserving its pre-evaluation constraint/default. */ +export interface TypeParameterModel { + readonly id: string + readonly name: string + readonly const: boolean + readonly constraint?: TypeNodeId + readonly default?: TypeNodeId + readonly variance?: 'in' | 'out' | 'in-out' +} + +/** One function-like parameter. */ +export interface ParameterModel { + readonly name: string + readonly binding: 'identifier' | 'object' | 'array' + readonly type: TypeNodeId + readonly optional: boolean + readonly rest: boolean + readonly receiver: boolean + readonly initializer?: string +} + +/** A function/call/construct signature. */ +export interface SignatureModel { + readonly typeParameters: readonly TypeParameterModel[] + readonly parameters: readonly ParameterModel[] + readonly returns: TypeNodeId +} + +/** Shared flags of a class/interface/type-literal member. */ +export interface MemberBase extends DocumentationModel { + readonly id: string + readonly name: string + readonly optional: boolean + readonly readonly: boolean + readonly async: boolean + readonly abstract: boolean + readonly static: boolean + readonly visibility: MemberVisibility + readonly location: SourceLocation + /** Body-free declaration text retained for byte-stable source projections. */ + readonly text: string +} + +/** A property member. */ +export interface PropertyMemberModel extends MemberBase { + readonly kind: 'property' + readonly type: TypeNodeId +} + +/** A method member. */ +export interface MethodMemberModel extends MemberBase { + readonly kind: 'method' + readonly signature: SignatureModel +} + +/** A getter or setter member. */ +export interface AccessorMemberModel extends MemberBase { + readonly kind: 'getter' | 'setter' + readonly signature: SignatureModel +} + +/** A call/construct/index signature in an interface or type literal. */ +export interface SignatureMemberModel extends MemberBase { + readonly kind: 'call' | 'construct' | 'index' + readonly signature: SignatureModel +} + +/** One declaration or object-literal member. */ +export type MemberModel = + | PropertyMemberModel + | MethodMemberModel + | AccessorMemberModel + | SignatureMemberModel + +/** One enum member, retaining its developer-authored initializer. */ +export interface EnumMemberModel extends DocumentationModel { + readonly name: string + readonly initializer?: string + readonly location: SourceLocation +} + +/** One authored part of a merged interface declaration. */ +export interface TypeDeclarationPartModel extends DocumentationModel { + readonly package: string + readonly location: SourceLocation + readonly typeParameters: readonly TypeParameterModel[] + readonly extends: readonly TypeNodeId[] + readonly members: readonly string[] +} + +/** A declared interface, class, or alias. */ +export interface TypeDeclarationModel extends DocumentationModel { + readonly id: SymbolId + readonly package: string + readonly name: string + readonly kind: 'interface' | 'class' | 'alias' | 'enum' + readonly abstract: boolean + readonly exported: boolean + readonly location: SourceLocation + /** Canonical body-free declaration text retained alongside the type tree. */ + readonly text: string + readonly typeParameters: readonly TypeParameterModel[] + readonly extends: readonly TypeNodeId[] + readonly implements: readonly TypeNodeId[] + readonly members: readonly MemberModel[] + readonly parts?: readonly TypeDeclarationPartModel[] + readonly type?: TypeNodeId + readonly enumMembers?: readonly EnumMemberModel[] +} + +/** Target of a named type reference. */ +export type TypeTargetModel = + | { readonly kind: 'declaration'; readonly symbol: SymbolId } + | { readonly kind: 'type-parameter'; readonly parameter: string } + | { + readonly kind: 'cross-face' + readonly face: TypertFace + readonly package: string + readonly subpath: string + readonly name: string + } + | { + readonly kind: 'external' + readonly module: string + readonly subpath: string + readonly name: string + } + | { readonly kind: 'standard'; readonly name: string } + +/** One tuple element, retaining labels and optional/rest modifiers. */ +export interface TupleElementModel { + readonly name?: string + readonly type: TypeNodeId + readonly optional: boolean + readonly rest: boolean +} + +/** One template-literal interpolation. */ +export interface TemplateSpanModel { + readonly type: TypeNodeId + readonly text: string +} + +/** Compiler-independent TypeScript type expression. */ +export type TypeNodeModel = + | { readonly id: TypeNodeId; readonly kind: 'keyword'; readonly name: KeywordTypeName } + | { readonly id: TypeNodeId; readonly kind: 'literal'; readonly value: string | number | bigint | boolean | null; readonly text: string } + | { readonly id: TypeNodeId; readonly kind: 'parenthesized'; readonly type: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'reference'; readonly name: string; readonly target: TypeTargetModel; readonly arguments: readonly TypeNodeId[] } + | { readonly id: TypeNodeId; readonly kind: 'union' | 'intersection'; readonly types: readonly TypeNodeId[] } + | { readonly id: TypeNodeId; readonly kind: 'array'; readonly element: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'tuple'; readonly elements: readonly TupleElementModel[] } + | { readonly id: TypeNodeId; readonly kind: 'object'; readonly members: readonly MemberModel[] } + | { readonly id: TypeNodeId; readonly kind: 'function'; readonly signature: SignatureModel } + | { readonly id: TypeNodeId; readonly kind: 'constructor'; readonly abstract: boolean; readonly signature: SignatureModel } + | { readonly id: TypeNodeId; readonly kind: 'indexed-access'; readonly object: TypeNodeId; readonly index: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'operator'; readonly operator: TypeOperatorName; readonly type: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'conditional'; readonly check: TypeNodeId; readonly extends: TypeNodeId; readonly whenTrue: TypeNodeId; readonly whenFalse: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'infer'; readonly parameter: TypeParameterModel } + | { + readonly id: TypeNodeId + readonly kind: 'mapped' + readonly parameter: TypeParameterModel + readonly nameType?: TypeNodeId + readonly value?: TypeNodeId + readonly readonly: 'add' | 'remove' | 'preserve' + readonly optional: 'add' | 'remove' | 'preserve' + } + | { readonly id: TypeNodeId; readonly kind: 'template-literal'; readonly head: string; readonly spans: readonly TemplateSpanModel[] } + | { readonly id: TypeNodeId; readonly kind: 'type-query'; readonly expression: string; readonly arguments: readonly TypeNodeId[] } + | { + readonly id: TypeNodeId + readonly kind: 'import-type' + readonly module: string + readonly qualifier?: string + readonly arguments: readonly TypeNodeId[] + readonly typeof: boolean + readonly attributes?: string + readonly target?: TypeTargetModel + } + | { readonly id: TypeNodeId; readonly kind: 'predicate'; readonly asserts: boolean; readonly parameter: string; readonly type?: TypeNodeId } + | { readonly id: TypeNodeId; readonly kind: 'this' } + +/** + * Return the direct type-expression edges owned by one node. + * @param node - compiler-independent type node to inspect. + * @returns graph-local ids of its direct child type nodes. + */ +export function childTypeNodeIds(node: TypeNodeModel): TypeNodeId[] { + switch (node.kind) { + case 'parenthesized': + case 'operator': return [node.type] + case 'reference': return [...node.arguments] + case 'union': + case 'intersection': return [...node.types] + case 'array': return [node.element] + case 'tuple': return node.elements.map(element => element.type) + case 'indexed-access': return [node.object, node.index] + case 'conditional': return [node.check, node.extends, node.whenTrue, node.whenFalse] + case 'mapped': return [ + ...(node.parameter.constraint === undefined ? [] : [node.parameter.constraint]), + ...(node.parameter.default === undefined ? [] : [node.parameter.default]), + ...(node.nameType === undefined ? [] : [node.nameType]), + ...(node.value === undefined ? [] : [node.value]), + ] + case 'template-literal': return node.spans.map(span => span.type) + case 'type-query': + case 'import-type': return [...node.arguments] + case 'predicate': return node.type === undefined ? [] : [node.type] + case 'infer': return [ + ...(node.parameter.constraint === undefined ? [] : [node.parameter.constraint]), + ...(node.parameter.default === undefined ? [] : [node.parameter.default]), + ] + case 'keyword': + case 'literal': + case 'object': + case 'function': + case 'constructor': + case 'this': return [] + default: return assertNever(node) + } +} + +/** Type declarations and expressions owned by one face. */ +export interface TypeGraph { + readonly declarations: readonly TypeDeclarationModel[] + readonly nodes: readonly TypeNodeModel[] +} + +function assertNever(value: never): never { + throw new Error(`unsupported model variant ${JSON.stringify(value)}`) +} diff --git a/packages/typert/generator/src/renderer.ts b/packages/typert/generator/src/renderer.ts new file mode 100644 index 0000000000..8d9a3c4954 --- /dev/null +++ b/packages/typert/generator/src/renderer.ts @@ -0,0 +1,356 @@ +/** + * Rendering and traversal over the compiler-independent TypeGraph. Emitters + * use this module instead of reaching back into TypeScript AST nodes. + * @module @deepseek-ai/dsh-typert-generator/renderer + */ + +import { childTypeNodeIds } from './model.ts' +import type { + MemberModel, + ParameterModel, + SignatureModel, + SymbolId, + TypeDeclarationModel, + TypeGraph, + TypeNodeId, + TypeNodeModel, + TypeParameterModel, +} from './model.ts' + +/** Failure to render or traverse an internally inconsistent TypeGraph. */ +export class TypeGraphRenderError extends Error { + override name = 'TypeGraphRenderError' +} + +/** Read and render one TypeGraph without compiler objects. */ +export class TypeGraphRenderer { + private readonly nodes: ReadonlyMap + private readonly declarations: ReadonlyMap + private readonly members: ReadonlyMap + private readonly parameterNames = new Map() + + /** + * Index one complete graph. + * @param graph - compiler-independent graph to render. + */ + constructor(readonly graph: TypeGraph) { + this.nodes = new Map(graph.nodes.map(node => [node.id, node])) + this.declarations = new Map(graph.declarations.map(declaration => [declaration.id, declaration])) + this.members = new Map(graph.declarations.flatMap(declaration => declaration.members.map(member => [member.id, member] as const))) + for (const declaration of graph.declarations) { + this.indexParameters(declaration.typeParameters) + for (const member of declaration.members) { + if ('signature' in member) this.indexParameters(member.signature.typeParameters) + } + } + } + + /** + * Resolve a node id or fail with the broken edge. + * @param id - graph-local type node id. + * @returns the referenced node. + */ + node(id: TypeNodeId): TypeNodeModel { + const node = this.nodes.get(id) + if (node === undefined) throw new TypeGraphRenderError(`type graph references missing node ${id}`) + return node + } + + /** + * Resolve a declaration id or fail with the broken edge. + * @param id - workspace symbol id. + * @returns the referenced declaration. + */ + declaration(id: SymbolId): TypeDeclarationModel { + const declaration = this.declarations.get(id) + if (declaration === undefined) throw new TypeGraphRenderError(`type graph references missing declaration ${id}`) + return declaration + } + + /** + * Resolve a public member id. + * @param id - declaration member id. + * @returns the referenced member. + */ + member(id: string): MemberModel { + const member = this.members.get(id) + if (member === undefined) throw new TypeGraphRenderError(`type graph references missing member ${id}`) + return member + } + + /** + * Render one type expression from the retained source structure. + * @param id - type node id. + * @returns TypeScript type text. + */ + renderType(id: TypeNodeId): string { + const node = this.node(id) + switch (node.kind) { + case 'keyword': return node.name + case 'literal': return node.text + case 'parenthesized': return `(${this.renderType(node.type)})` + case 'reference': { + const name = node.target.kind === 'type-parameter' + ? this.parameterNames.get(node.target.parameter) ?? node.name + : node.name + return node.arguments.length === 0 + ? name + : `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + } + case 'union': return node.types.map(type => this.renderType(type)).join(' | ') + case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ') + case 'array': { + const element = this.renderType(node.element) + const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element + return `${wrapped}[]` + } + case 'tuple': { + const elements = node.elements.map((element) => { + const type = this.renderType(element.type) + if (element.name !== undefined) { + return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}` + } + return `${element.rest ? '...' : ''}${type}${element.optional ? '?' : ''}` + }) + return `[${elements.join(', ')}]` + } + case 'object': return this.renderObject(node.members) + case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` + case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` + case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]` + case 'operator': return `${node.operator} ${this.renderType(node.type)}` + case 'conditional': { + return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}` + } + case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}` + case 'mapped': { + const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly ' + const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?' + if (node.parameter.constraint === undefined) { + throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`) + } + const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}` + const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}` + const value = node.value === undefined ? 'unknown' : this.renderType(node.value) + return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }` + } + case 'template-literal': { + const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('') + return `\`${escapeTemplate(node.head)}${spans}\`` + } + case 'type-query': { + const argumentsText = node.arguments.length === 0 + ? '' + : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + return `typeof ${node.expression}${argumentsText}` + } + case 'import-type': { + const attributes = node.attributes === undefined ? '' : `, ${node.attributes}` + const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}` + const argumentsText = node.arguments.length === 0 + ? '' + : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}` + } + case 'predicate': { + const assertion = node.asserts ? 'asserts ' : '' + return node.type === undefined + ? `${assertion}${node.parameter}` + : `${assertion}${node.parameter} is ${this.renderType(node.type)}` + } + case 'this': return 'this' + default: return assertNever(node) + } + } + + /** + * Render a callable signature without a member name. + * @param signature - modeled signature. + * @returns parameter list and return type. + */ + renderSignature(signature: SignatureModel): string { + return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}` + } + + /** + * Render one class/interface member as a body-free declaration. + * @param member - modeled member. + * @param sourceModifiers - retain source-only modifiers for reflection text. + * @returns one-line TypeScript member text. + */ + renderMember(member: MemberModel, sourceModifiers = false): string { + if (sourceModifiers) return member.text + const name = renderPropertyName(member.name) + const optional = member.optional ? '?' : '' + const readonly = member.readonly ? 'readonly ' : '' + const abstract = member.abstract ? 'abstract ' : '' + switch (member.kind) { + case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}` + case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}` + case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}` + case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}` + case 'call': return this.renderSignature(member.signature) + case 'construct': return `new ${this.renderSignature(member.signature)}` + case 'index': { + const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ') + return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}` + } + default: return assertNever(member) + } + } + + /** + * Render a named declaration without JSDoc. + * @param id - declaration symbol id. + * @returns exported TypeScript declaration text. + */ + renderDeclaration(id: SymbolId): string { + const declaration = this.declaration(id) + const parameters = this.renderTypeParameters(declaration.typeParameters) + if (declaration.kind === 'enum') { + const members = declaration.enumMembers?.map(member => + ` ${renderPropertyName(member.name)}${member.initializer === undefined ? '' : ` = ${member.initializer}`},`) ?? [] + return [`export enum ${declaration.name} {`, ...members, '}'].join('\n') + } + if (declaration.kind === 'alias') { + if (declaration.type === undefined) throw new TypeGraphRenderError(`alias ${id} has no type node`) + return `export type ${declaration.name}${parameters} = ${this.renderType(declaration.type)};` + } + const extendsTypes = declaration.extends.map(type => this.renderType(type)) + const implementsTypes = declaration.implements.map(type => this.renderType(type)) + const heritage = [ + extendsTypes.length === 0 ? '' : ` extends ${extendsTypes.join(', ')}`, + implementsTypes.length === 0 ? '' : ` implements ${implementsTypes.join(', ')}`, + ].join('') + const prefix = declaration.kind === 'class' && declaration.abstract ? 'abstract ' : '' + const members = declaration.members.map(member => ` ${this.renderMember(member)};`) + return [`export ${prefix}${declaration.kind} ${declaration.name}${parameters}${heritage} {`, ...members, '}'].join('\n') + } + + /** + * Find the transitive declaration closure referenced by members. + * @param memberIds - business-surface member ids. + * @returns declarations in graph order, excluding no roots implicitly. + */ + declarationClosureForMembers(memberIds: readonly string[]): TypeDeclarationModel[] { + return this.declarationClosure(memberIds, []) + } + + /** + * Find the transitive declaration closure referenced by type roots. + * @param typeIds - graph type roots. + * @returns declarations in graph order. + */ + declarationClosureForTypes(typeIds: readonly TypeNodeId[]): TypeDeclarationModel[] { + return this.declarationClosure([], typeIds) + } + + private declarationClosure( + memberIds: readonly string[], + typeIds: readonly TypeNodeId[], + ): TypeDeclarationModel[] { + const found = new Set() + const visiting = new Set() + const visitNode = (id: TypeNodeId): void => { + const node = this.node(id) + if (node.kind === 'reference' && node.target.kind === 'declaration') visitDeclaration(node.target.symbol) + if (node.kind === 'import-type' && node.target?.kind === 'declaration') visitDeclaration(node.target.symbol) + for (const child of childTypeNodeIds(node)) visitNode(child) + for (const signature of nodeSignatures(node)) visitSignature(signature) + if (node.kind === 'object') for (const member of node.members) visitMember(member) + } + const visitSignature = (signature: SignatureModel): void => { + for (const parameter of signature.typeParameters) { + if (parameter.constraint !== undefined) visitNode(parameter.constraint) + if (parameter.default !== undefined) visitNode(parameter.default) + } + for (const parameter of signature.parameters) visitNode(parameter.type) + visitNode(signature.returns) + } + const visitMember = (member: MemberModel): void => { + if (member.kind === 'property') visitNode(member.type) + else visitSignature(member.signature) + } + const visitDeclaration = (id: SymbolId): void => { + if (found.has(id) || visiting.has(id)) return + visiting.add(id) + const declaration = this.declaration(id) + for (const parameter of declaration.typeParameters) { + if (parameter.constraint !== undefined) visitNode(parameter.constraint) + if (parameter.default !== undefined) visitNode(parameter.default) + } + for (const type of [...declaration.extends, ...declaration.implements]) visitNode(type) + if (declaration.type !== undefined) visitNode(declaration.type) + for (const member of declaration.members) visitMember(member) + visiting.delete(id) + found.add(id) + } + for (const id of memberIds) visitMember(this.member(id)) + for (const id of typeIds) visitNode(id) + return this.graph.declarations.filter(declaration => found.has(declaration.id)) + } + + private renderSignatureHead(signature: SignatureModel): string { + return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})` + } + + private renderReturn(signature: SignatureModel): string { + return `: ${this.renderType(signature.returns)}` + } + + private renderParameter(parameter: ParameterModel): string { + const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name + const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : '' + const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}` + return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}` + } + + private renderTypeParameters(parameters: readonly TypeParameterModel[]): string { + return parameters.length === 0 + ? '' + : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>` + } + + private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string { + const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} ` + const constModifier = parameter.const ? 'const ' : '' + const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}` + const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}` + return `${constModifier}${variance}${parameter.name}${constraint}${fallback}` + } + + private renderObject(members: readonly MemberModel[]): string { + if (members.length === 0) return '{}' + return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }` + } + + private indexParameters(parameters: readonly TypeParameterModel[]): void { + for (const parameter of parameters) this.parameterNames.set(parameter.id, parameter.name) + } +} + +function nodeSignatures(node: TypeNodeModel): SignatureModel[] { + return node.kind === 'function' || node.kind === 'constructor' ? [node.signature] : [] +} + +function needsArrayParentheses(node: TypeNodeModel): boolean { + return node.kind === 'union' || node.kind === 'intersection' || node.kind === 'function' || node.kind === 'constructor' || node.kind === 'conditional' +} + +function renderPropertyName(name: string): string { + if (name.startsWith('[') && name.endsWith(']')) return name + if (/^(?:[$A-Z_a-z][$\w]*|\d+)$/u.test(name)) return name + return quote(name) +} + +function quote(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'` +} + +function escapeTemplate(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${') +} + +function assertNever(value: never): never { + throw new TypeGraphRenderError(`unsupported model variant ${JSON.stringify(value)}`) +} diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts new file mode 100644 index 0000000000..9254eeb16d --- /dev/null +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -0,0 +1,78 @@ +/** + * Optional tsdown (rolldown) plugin face of the typert generator. When added + * to a workspace tsdown config, it runs after each opted-in package bundle is + * written and re-emits its model-driven face artifact at the package output + * root. Packages without a Typert export are skipped. + * @module @deepseek-ai/dsh-typert-generator/tsdown + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { WorkspaceTypertGenerator } from './workspace.ts' +import type { WorkspaceEmitResult } from './workspace.ts' + +/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ +interface TypertPlugin { + name: string + writeBundle: (options: { dir?: string }) => void +} + +/** + * Create the typert generation plugin for the root tsdown config. + * @returns a rolldown-compatible plugin that emits `lib/typert..js` and `.d.ts` for contributing packages. + */ +export function typertPlugin(): TypertPlugin { + const artifactsByRoot = new Map() + return { + name: 'dsh-typert-generator', + writeBundle(options) { + // options.dir is the package's absolute outDir (/lib); its + // nearest package.json owns the bundle even when a custom config writes + // a nested output such as /lib/dev. + if (options.dir === undefined) return + const root = workspaceRoot(options.dir) + const packageDir = packageRoot(options.dir, root) + if (packageDir === undefined) return + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + name?: string + exports?: unknown + } + if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return + let artifacts = artifactsByRoot.get(root) + if (artifacts === undefined) { + artifacts = new WorkspaceTypertGenerator(root).generate() + artifactsByRoot.set(root, artifacts) + } + const output = join(packageDir, 'lib') + mkdirSync(output, { recursive: true }) + for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) { + writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) + writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) + } + }, + } +} + +function hasTypertExport(exportsField: unknown): boolean { + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false + return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert') +} + +function packageRoot(start: string, workspace: string): string | undefined { + let current = resolve(start) + while (current !== workspace) { + if (existsSync(join(current, 'package.json'))) return current + current = dirname(current) + } + return undefined +} + +function workspaceRoot(start: string): string { + let current = resolve(start) + while (!existsSync(join(current, 'tsconfig.host.json'))) { + const parent = dirname(current) + if (parent === current) throw new Error(`typert-generator: cannot find workspace root above ${start}`) + current = parent + } + return current +} diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts new file mode 100644 index 0000000000..6153a0241a --- /dev/null +++ b/packages/typert/generator/src/workspace.ts @@ -0,0 +1,90 @@ +/** + * Workspace-level discovery and model-driven Typert generation. + * @module @deepseek-ai/dsh-typert-generator/workspace + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts' +import type { DiscoveredTypertPackage } from './analyzer.ts' +import { FaceModelEmitter } from './emitter.ts' +import type { ModelEmitResult } from './emitter.ts' + +/** One emitted artifact paired with its source package root. */ +export interface WorkspaceEmitResult extends ModelEmitResult { + readonly packageRoot: string +} + +/** Discover, analyze, and emit package reflection from independent faces. */ +export class WorkspaceTypertGenerator { + /** + * Bind generation to one workspace root. + * @param root - directory containing face aggregate tsconfigs. + */ + constructor(private readonly root: string) {} + + /** + * Find public package faces that contribute Cordis services/events or + * explicitly tagged Typert roots. + * @returns discovered packages in stable package-name order. + */ + discover(): DiscoveredTypertPackage[] { + return new WorkspaceAnalyzer({ root: this.root }).discoverPackages() + } + + /** + * Generate all discovered contributors, or an explicit package subset. + * @param packages - optional exact package names for a focused pass. + * @returns one artifact per package face. + */ + generate(packages?: readonly string[]): WorkspaceEmitResult[] { + const selected = packages ?? this.discover().map(candidate => candidate.package) + const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze() + const artifacts: WorkspaceEmitResult[] = [] + for (const face of workspace.faces) { + const emitter = new FaceModelEmitter(face) + for (const packageModel of face.packages) { + const artifact = { + ...emitter.emit(packageModel.name), + packageRoot: packageModel.root, + } + this.validateExport(artifact) + artifacts.push(artifact) + } + } + return artifacts + } + + private validateExport(artifact: WorkspaceEmitResult): void { + const manifestPath = resolve(this.root, artifact.packageRoot, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + exports?: unknown + files?: unknown + } + const subpath = artifact.face === 'host' ? './typert' : './client/typert' + const expected = { + types: `./lib/typert.${artifact.face}.d.ts`, + default: `./lib/typert.${artifact.face}.js`, + } + const actual = manifest.exports !== null && typeof manifest.exports === 'object' + ? (manifest.exports as Record)[subpath] + : undefined + if (!sameExport(actual, expected)) { + throw new TypertAnalysisError( + `typert(${artifact.face}): ${artifact.package} must export ${subpath} as ${JSON.stringify(expected)}`, + ) + } + const files = Array.isArray(manifest.files) ? manifest.files : [] + for (const file of [`lib/typert.${artifact.face}.js`, `lib/typert.${artifact.face}.d.ts`]) { + if (!files.includes(file)) { + throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) + } + } + } +} + +function sameExport(actual: unknown, expected: { types: string; default: string }): boolean { + if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false + const value = actual as Record + return value.types === expected.types && value.default === expected.default +} diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap new file mode 100644 index 0000000000..aad86b0102 --- /dev/null +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -0,0 +1,6487 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FaceModelEmitter > emits runnable Zod JavaScript, precise declarations, and runtime package metadata 1`] = ` +"/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import { z } from 'zod' + +const Payload$schema = z.object({ + 'name': z.string(), + 'count': z.number().optional(), +}).describe('Runtime-validating data root.') + +export const Payload = Payload$schema + +export const TYPERT = { + package: '@fixture/host', + face: 'host', + schemas: [ + { name: 'Payload', schema: Payload }, + ], + model: { + "services": [ + { + "description": "Service exported only through a non-default alias.", + "summary": "Service exported only through a non-default alias.", + "tags": [], + "jsDoc": "/** Service exported only through a non-default alias. */", + "key": "aliased", + "exportName": "PublicAliasedService", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Service exported only through the package default.", + "summary": "Service exported only through the package default.", + "tags": [], + "jsDoc": "/** Service exported only through the package default. */", + "key": "defaultOnly", + "exportName": "default", + "members": [ + { + "kind": "method", + "name": "ready", + "signature": "ready(): boolean", + "summary": "Report readiness.", + "jsDoc": "/** Report readiness. */" + } + ], + "types": [] + }, + { + "description": "Fixture service with generic, mapped, and truly external boundary types.", + "summary": "Fixture service with generic, mapped, and truly external boundary types.", + "tags": [], + "jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */", + "key": "demo", + "exportName": "DemoService", + "members": [ + { + "kind": "method", + "name": "inspect", + "signature": "inspect(agent: Agent<{ ready: true }>, flags: Flags): Present", + "summary": "Inspect one agent without flattening its generic state.", + "jsDoc": "/** Inspect one agent without flattening its generic state. */" + }, + { + "kind": "method", + "name": "acceptsExternal", + "signature": "acceptsExternal(schema: ZodType): void", + "summary": "Keep an npm-owned type as External.", + "jsDoc": "/** Keep an npm-owned type as External. */" + }, + { + "kind": "method", + "name": "setPhase", + "signature": "setPhase(phase: AgentPhase): void", + "summary": "Accept a developer-authored enum without flattening it.", + "jsDoc": "/** Accept a developer-authored enum without flattening it. */" + }, + { + "kind": "method", + "name": "inspectSyntax", + "signature": "inspectSyntax(zoo: SyntaxZoo): void", + "summary": "Exercise every retained type-graph shape from a public boundary.", + "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */" + }, + { + "kind": "method", + "name": "inspectAsync", + "signature": "async inspectAsync(zoo: SyntaxZoo): Promise", + "summary": "Preserve async source metadata without changing its type signature.", + "jsDoc": "/** Preserve async source metadata without changing its type signature. */" + }, + { + "kind": "method", + "name": "destructure", + "signature": "destructure({ name }: Payload, [suffix]: [string]): string", + "summary": "Retain an authored binding-pattern parameter.", + "jsDoc": "/** Retain an authored binding-pattern parameter. */" + } + ], + "types": [ + { + "name": "AbstractEntity", + "declaration": "export abstract class AbstractEntity implements Entity {\\n abstract readonly id: string;\\n}" + }, + { + "name": "Added", + "declaration": "export type Added = { readonly [Key in keyof Value]?: Value[Key] };" + }, + { + "name": "Agent", + "declaration": "export class Agent implements Entity {\\n readonly id: string;\\n state: State;\\n get label(): string;\\n set label(value: string);\\n run(input: Box): Promise>;\\n}" + }, + { + "name": "AgentPhase", + "declaration": "export enum AgentPhase {\\n Unknown,\\n Idle = 'idle',\\n Running = 'running',\\n}" + }, + { + "name": "Box", + "declaration": "export interface Box {\\n readonly value: T;\\n}" + }, + { + "name": "Callable", + "declaration": "export interface Callable {\\n (value: string): number;\\n new (value: string): Entity;\\n readonly [key: string]: unknown;\\n}" + }, + { + "name": "Entity", + "declaration": "export interface Entity {\\n readonly id: string;\\n}" + }, + { + "name": "Flags", + "declaration": "export type Flags = { readonly [K in keyof T]?: boolean };" + }, + { + "name": "Guards", + "declaration": "export interface Guards {\\n isEntity(value: unknown): value is Entity;\\n isFluent(): this is Guards;\\n assertEntity(value: unknown): asserts value is Entity;\\n assertPresent(value: unknown): asserts value;\\n fluent(): this;\\n}" + }, + { + "name": "Payload", + "declaration": "export interface Payload {\\n name: string;\\n count?: number;\\n}" + }, + { + "name": "PlainMap", + "declaration": "export type PlainMap = { [Key in keyof Value]: Value[Key] };" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + }, + { + "name": "Recursive", + "declaration": "export interface Recursive extends Box {\\n readonly next?: Recursive;\\n}" + }, + { + "name": "Remapped", + "declaration": "export type Remapped = { -readonly [Key in keyof Value as \`get\${Capitalize}\`]-?: Value[Key] };" + }, + { + "name": "Result", + "declaration": "export type Result = Value extends (...arguments_: never[]) => infer Output ? Output : never;" + }, + { + "name": "Route", + "declaration": "export type Route = \`/\${From}/to/\${To}/end\`;" + }, + { + "name": "StringResult", + "declaration": "export type StringResult = Value extends readonly [infer Output extends string] ? Output : never;" + }, + { + "name": "SyntaxZoo", + "declaration": "export interface SyntaxZoo {\\n anyValue: any;\\n bigintValue: bigint;\\n parenthesized: (Entity | null);\\n literals: 1 | 1n | -2 | -2n | false | \`fixed\`;\\n readonly uniqueToken: unique symbol;\\n intersection: Entity & { active: boolean; };\\n array: string[];\\n tuple: [head: string, count?: number, ...tail: boolean[]];\\n unnamedTuple: [string?, ...number[]];\\n readonlyTuple: readonly [string, number];\\n object: { readonly value?: string; 'quoted-name': number; 1: boolean; ['computed']: symbol; invoke?(input: number): void; };\\n callback: (this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise;\\n constCallback: (value: Value) => Value;\\n factory: new (value: Value) => Value;\\n abstractFactory: abstract new (id: string) => AbstractEntity;\\n indexed: Payload['name'];\\n inferred: Result<() => string>;\\n constrainedInfer: StringResult<['value']>;\\n topic: Topic<'ready'>;\\n route: Route<'source', 'target'>;\\n query: typeof phaseOrder;\\n instantiatedQuery: typeof genericFactory;\\n imported: import('zod').ZodType;\\n importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType;\\n importedModule: typeof import('zod');\\n process: NodeJS.Process;\\n callable: Callable;\\n guards: Guards;\\n variance: Variance>;\\n plainMap: PlainMap;\\n remapped: Remapped;\\n added: Added;\\n abstractEntity: AbstractEntity;\\n recursive: Recursive;\\n tagOnly: TagOnly;\\n unpunctuated: Unpunctuated;\\n}" + }, + { + "name": "TagOnly", + "declaration": "export interface TagOnly {\\n readonly value: string;\\n}" + }, + { + "name": "Topic", + "declaration": "export type Topic = \`demo/\${Name}\`;" + }, + { + "name": "Unpunctuated", + "declaration": "export interface Unpunctuated {\\n readonly value: string;\\n}" + }, + { + "name": "Variance", + "declaration": "export interface Variance {\\n consume: (input: Input) => void;\\n readonly produce: () => Output;\\n state: State;\\n}" + } + ] + } + ], + "events": [ + { + "tags": [], + "name": "demo/property", + "signature": "'demo/property'(payload: Payload): void" + }, + { + "description": "A generic fixture event.", + "summary": "A generic fixture event.", + "tags": [ + { + "name": "param", + "argument": "agent", + "comment": "- emitting agent.", + "text": "@param agent - emitting agent.\\n *" + }, + { + "name": "param", + "argument": "payload", + "comment": "- event payload.", + "text": "@param payload - event payload.\\n *" + }, + { + "name": "mode", + "comment": "emit", + "text": "@mode emit" + } + ], + "jsDoc": "/**\\n * A generic fixture event.\\n * @param agent - emitting agent.\\n * @param payload - event payload.\\n * @mode emit\\n */", + "name": "demo/ready", + "mode": "emit", + "signature": "'demo/ready'(agent: Agent<{ ready: true; }>, payload: Box): void" + }, + { + "tags": [ + { + "name": "mode", + "comment": "serial", + "text": "@mode serial" + } + ], + "jsDoc": "/** @mode serial */", + "name": "demo/serial-property", + "mode": "serial", + "signature": "'demo/serial-property'(payload: Payload): void" + }, + { + "tags": [], + "name": "demo/unmodeled", + "signature": "'demo/unmodeled'(): void" + } + ], + "objects": [ + { + "description": "Reference-passed capability object.", + "summary": "Reference-passed capability object.", + "tags": [ + { + "name": "typert", + "comment": "object", + "text": "@typert object" + } + ], + "jsDoc": "/**\\n * Reference-passed capability object.\\n * @typert object\\n */", + "name": "Agent", + "exportName": "Agent", + "members": [ + { + "kind": "property", + "name": "id", + "signature": "readonly id: string" + }, + { + "kind": "property", + "name": "state", + "signature": "state: State" + }, + { + "kind": "getter", + "name": "label", + "signature": "get label(): string", + "summary": "Read the public display label.", + "jsDoc": "/** Read the public display label. */" + }, + { + "kind": "setter", + "name": "label", + "signature": "set label(value: string)", + "summary": "Accept a public display label.", + "jsDoc": "/** Accept a public display label. */" + }, + { + "kind": "method", + "name": "run", + "signature": "run(input: Box): Promise>", + "summary": "Run one typed input.", + "jsDoc": "/** Run one typed input. */" + } + ], + "types": [ + { + "name": "Box", + "declaration": "export interface Box {\\n readonly value: T;\\n}" + }, + { + "name": "Present", + "declaration": "export type Present = T extends null | undefined ? never : T;" + } + ] + } + ] + }, +} +" +`; + +exports[`FaceModelEmitter > emits runnable Zod JavaScript, precise declarations, and runtime package metadata 2`] = ` +"/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import type { z } from 'zod' +import type { Payload as Payload$source } from '@fixture/host' + +export declare const Payload: z.ZodType + +export declare const TYPERT: unknown +" +`; + +exports[`WorkspaceAnalyzer > builds independent face models with an explicit cross-face type graph 1`] = ` +{ + "crossFaceLinks": [ + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "Agent", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "AgentPhase", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "Box", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "default", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "HostAgent", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + { + "fromFace": "client", + "fromPackage": "@fixture/client", + "name": "Payload", + "subpath": ".", + "toFace": "host", + "toPackage": "@fixture/host", + }, + ], + "faces": [ + { + "face": "host", + "graph": { + "declarations": [ + { + "abstract": false, + "description": "Reference-passed capability object.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/index.ts#Agent", + "implements": [ + "type:packages/host/src/index.ts:12:74#1", + ], + "jsDoc": "/** + * Reference-passed capability object. + * @typert object + */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/host/src/index.ts", + "line": 12, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/index.ts#Agent#id@480", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 15, + }, + "name": "id", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly id: string", + "type": "type:packages/host/src/index.ts:15:16#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/index.ts#Agent#state@502", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 16, + }, + "name": "state", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "state: State", + "type": "type:packages/host/src/index.ts:16:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Read the public display label.", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@735", + "jsDoc": "/** Read the public display label. */", + "kind": "getter", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 26, + }, + "name": "label", + "optional": false, + "readonly": false, + "signature": { + "parameters": [], + "returns": "type:packages/host/src/index.ts:26:16#1", + "typeParameters": [], + }, + "static": false, + "summary": "Read the public display label.", + "tags": [], + "text": "get label(): string", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Accept a public display label.", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@823", + "jsDoc": "/** Accept a public display label. */", + "kind": "setter", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 31, + }, + "name": "label", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:31:20#1", + }, + ], + "returns": "type:packages/host/src/index.ts:31:3#1", + "typeParameters": [], + }, + "static": false, + "summary": "Accept a public display label.", + "tags": [], + "text": "set label(value: string)", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Run one typed input.", + "id": "@fixture/host:packages/host/src/index.ts#Agent#run@902", + "jsDoc": "/** Run one typed input. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 36, + }, + "name": "run", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "input", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:36:21#1", + }, + ], + "returns": "type:packages/host/src/index.ts:36:34#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/index.ts:36:7#Value", + "name": "Value", + }, + ], + }, + "static": false, + "summary": "Run one typed input.", + "tags": [], + "text": "run(input: Box): Promise>", + "visibility": "public", + }, + ], + "name": "Agent", + "package": "@fixture/host", + "summary": "Reference-passed capability object.", + "tags": [ + { + "comment": "object", + "name": "typert", + "text": "@typert object", + }, + ], + "text": "export class Agent implements Entity { + static { } + static readonly kind: string; + readonly id: string; + state: State; + constructor(id: string, state: State); + get label(): string; + set label(value: string); + run(input: Box): Promise>; +}", + "typeParameters": [ + { + "const": false, + "constraint": "type:packages/host/src/index.ts:12:34#1", + "default": "type:packages/host/src/index.ts:12:43#1", + "id": "packages/host/src/index.ts:12:20#State", + "name": "State", + }, + ], + }, + { + "abstract": false, + "description": "Service exported only through a non-default alias.", + "exported": false, + "extends": [ + "type:packages/host/src/index.ts:44:30#1", + ], + "id": "@fixture/host:packages/host/src/index.ts#AliasedService", + "implements": [], + "jsDoc": "/** Service exported only through a non-default alias. */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/host/src/index.ts", + "line": 44, + }, + "members": [ + { + "abstract": false, + "async": false, + "description": "Report readiness.", + "id": "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + "jsDoc": "/** Report readiness. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 46, + }, + "name": "ready", + "optional": false, + "readonly": false, + "signature": { + "parameters": [], + "returns": "type:packages/host/src/index.ts:46:12#1", + "typeParameters": [], + }, + "static": false, + "summary": "Report readiness.", + "tags": [], + "text": "ready(): boolean", + "visibility": "public", + }, + ], + "name": "AliasedService", + "package": "@fixture/host", + "summary": "Service exported only through a non-default alias.", + "tags": [], + "text": "class AliasedService extends Service { + ready(): boolean; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Service exported only through the package default.", + "exported": false, + "extends": [ + "type:packages/host/src/index.ts:54:34#1", + ], + "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", + "implements": [], + "jsDoc": "/** Service exported only through the package default. */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/host/src/index.ts", + "line": 54, + }, + "members": [ + { + "abstract": false, + "async": false, + "description": "Report readiness.", + "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + "jsDoc": "/** Report readiness. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 56, + }, + "name": "ready", + "optional": false, + "readonly": false, + "signature": { + "parameters": [], + "returns": "type:packages/host/src/index.ts:56:12#1", + "typeParameters": [], + }, + "static": false, + "summary": "Report readiness.", + "tags": [], + "text": "ready(): boolean", + "visibility": "public", + }, + ], + "name": "DefaultOnlyService", + "package": "@fixture/host", + "summary": "Service exported only through the package default.", + "tags": [], + "text": "class DefaultOnlyService extends Service { + ready(): boolean; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Fixture service with generic, mapped, and truly external boundary types.", + "exported": true, + "extends": [ + "type:packages/host/src/index.ts:62:34#1", + ], + "id": "@fixture/host:packages/host/src/index.ts#DemoService", + "implements": [], + "jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/host/src/index.ts", + "line": 62, + }, + "members": [ + { + "abstract": false, + "async": false, + "description": "Inspect one agent without flattening its generic state.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", + "jsDoc": "/** Inspect one agent without flattening its generic state. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 68, + }, + "name": "inspect", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "agent", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:68:18#1", + }, + { + "binding": "identifier", + "name": "flags", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:68:49#1", + }, + ], + "returns": "type:packages/host/src/index.ts:68:66#1", + "typeParameters": [], + }, + "static": false, + "summary": "Inspect one agent without flattening its generic state.", + "tags": [], + "text": "inspect(agent: Agent<{ ready: true }>, flags: Flags): Present", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Keep an npm-owned type as External.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", + "jsDoc": "/** Keep an npm-owned type as External. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 73, + }, + "name": "acceptsExternal", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "schema", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:73:27#1", + }, + ], + "returns": "type:packages/host/src/index.ts:73:45#1", + "typeParameters": [], + }, + "static": false, + "summary": "Keep an npm-owned type as External.", + "tags": [], + "text": "acceptsExternal(schema: ZodType): void", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Accept a developer-authored enum without flattening it.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", + "jsDoc": "/** Accept a developer-authored enum without flattening it. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 78, + }, + "name": "setPhase", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "phase", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:78:19#1", + }, + ], + "returns": "type:packages/host/src/index.ts:78:32#1", + "typeParameters": [], + }, + "static": false, + "summary": "Accept a developer-authored enum without flattening it.", + "tags": [], + "text": "setPhase(phase: AgentPhase): void", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Exercise every retained type-graph shape from a public boundary.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", + "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 83, + }, + "name": "inspectSyntax", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "zoo", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:83:22#1", + }, + ], + "returns": "type:packages/host/src/index.ts:83:34#1", + "typeParameters": [], + }, + "static": false, + "summary": "Exercise every retained type-graph shape from a public boundary.", + "tags": [], + "text": "inspectSyntax(zoo: SyntaxZoo): void", + "visibility": "public", + }, + { + "abstract": false, + "async": true, + "description": "Preserve async source metadata without changing its type signature.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", + "jsDoc": "/** Preserve async source metadata without changing its type signature. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 88, + }, + "name": "inspectAsync", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "zoo", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:88:27#1", + }, + ], + "returns": "type:packages/host/src/index.ts:88:39#1", + "typeParameters": [], + }, + "static": false, + "summary": "Preserve async source metadata without changing its type signature.", + "tags": [], + "text": "async inspectAsync(zoo: SyntaxZoo): Promise", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "description": "Retain an authored binding-pattern parameter.", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + "jsDoc": "/** Retain an authored binding-pattern parameter. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/index.ts", + "line": 93, + }, + "name": "destructure", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "object", + "name": "{ name }", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:93:25#1", + }, + { + "binding": "array", + "name": "[suffix]", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:93:44#1", + }, + ], + "returns": "type:packages/host/src/index.ts:93:55#1", + "typeParameters": [], + }, + "static": false, + "summary": "Retain an authored binding-pattern parameter.", + "tags": [], + "text": "destructure({ name }: Payload, [suffix]: [string]): string", + "visibility": "public", + }, + ], + "name": "DemoService", + "package": "@fixture/host", + "summary": "Fixture service with generic, mapped, and truly external boundary types.", + "tags": [], + "text": "export class DemoService extends Service { + static readonly kind: string; + inspect(agent: Agent<{ + ready: true; + }>, flags: Flags): Present; + acceptsExternal(schema: ZodType): void; + setPhase(phase: AgentPhase): void; + inspectSyntax(zoo: SyntaxZoo): void; + async inspectAsync(zoo: SyntaxZoo): Promise; + destructure({ name }: Payload, [suffix]: [ + string + ]): string; +}", + "typeParameters": [], + }, + { + "abstract": true, + "description": "Abstract declarations remain distinct from concrete classes.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#AbstractEntity", + "implements": [ + "type:packages/host/src/models.ts:90:49#1", + ], + "jsDoc": "/** Abstract declarations remain distinct from concrete classes. */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 90, + }, + "members": [ + { + "abstract": true, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#AbstractEntity#id@2840", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 91, + }, + "name": "id", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "abstract readonly id: string", + "type": "type:packages/host/src/models.ts:91:25#1", + "visibility": "public", + }, + ], + "name": "AbstractEntity", + "package": "@fixture/host", + "summary": "Abstract declarations remain distinct from concrete classes.", + "tags": [], + "text": "export abstract class AbstractEntity implements Entity { + abstract readonly id: string; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Retain explicit mapped modifier addition.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Added", + "implements": [], + "jsDoc": "/** Retain explicit mapped modifier addition. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 70, + }, + "members": [], + "name": "Added", + "package": "@fixture/host", + "summary": "Retain explicit mapped modifier addition.", + "tags": [], + "text": "export type Added = { + +readonly [Key in keyof Value]+?: Value[Key]; +};", + "type": "type:packages/host/src/models.ts:70:28#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:70:19#Value", + "name": "Value", + }, + ], + }, + { + "abstract": false, + "description": "Developer-authored enum retained as a declaration.", + "enumMembers": [ + { + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 22, + }, + "name": "Unknown", + "tags": [], + }, + { + "initializer": "'idle'", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 23, + }, + "name": "Idle", + "tags": [], + }, + { + "initializer": "'running'", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 24, + }, + "name": "Running", + "tags": [], + }, + ], + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#AgentPhase", + "implements": [], + "jsDoc": "/** Developer-authored enum retained as a declaration. */", + "kind": "enum", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 21, + }, + "members": [], + "name": "AgentPhase", + "package": "@fixture/host", + "summary": "Developer-authored enum retained as a declaration.", + "tags": [], + "text": "export enum AgentPhase { + Unknown, + Idle = 'idle', + Running = 'running' +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Generic source form retained before conditional evaluation.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Box", + "implements": [], + "jsDoc": "/** Generic source form retained before conditional evaluation. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 2, + }, + "members": [ + { + "abstract": false, + "async": false, + "description": "The boxed value.", + "id": "@fixture/host:packages/host/src/models.ts#Box#value@121", + "jsDoc": "/** The boxed value. */", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 4, + }, + "name": "value", + "optional": false, + "readonly": true, + "static": false, + "summary": "The boxed value.", + "tags": [], + "text": "readonly value: T", + "type": "type:packages/host/src/models.ts:4:19#1", + "visibility": "public", + }, + ], + "name": "Box", + "package": "@fixture/host", + "summary": "Generic source form retained before conditional evaluation.", + "tags": [], + "text": "export interface Box { + readonly value: T; +}", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:2:22#T", + "name": "T", + }, + ], + }, + { + "abstract": false, + "description": "Signature members represented without flattening their callable forms.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Callable", + "implements": [], + "jsDoc": "/** Signature members represented without flattening their callable forms. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 34, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Callable#(call)@888", + "kind": "call", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 35, + }, + "name": "(call)", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:35:11#1", + }, + ], + "returns": "type:packages/host/src/models.ts:35:20#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "(value: string): number", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Callable#(construct)@914", + "kind": "construct", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 36, + }, + "name": "(construct)", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:36:15#1", + }, + ], + "returns": "type:packages/host/src/models.ts:36:24#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "new (value: string): Entity", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Callable#(index)@944", + "kind": "index", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 37, + }, + "name": "(index)", + "optional": false, + "readonly": true, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "key", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:37:18#1", + }, + ], + "returns": "type:packages/host/src/models.ts:37:27#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "readonly [key: string]: unknown", + "visibility": "public", + }, + ], + "name": "Callable", + "package": "@fixture/host", + "summary": "Signature members represented without flattening their callable forms.", + "tags": [], + "text": "export interface Callable { + (value: string): number; + new (value: string): Entity; + readonly [key: string]: unknown; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Explicit base edge for reference-passed objects.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Entity", + "implements": [], + "jsDoc": "/** Explicit base edge for reference-passed objects. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 16, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Entity#id@506", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 17, + }, + "name": "id", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly id: string", + "type": "type:packages/host/src/models.ts:17:16#1", + "visibility": "public", + }, + ], + "name": "Entity", + "package": "@fixture/host", + "summary": "Explicit base edge for reference-passed objects.", + "tags": [], + "text": "export interface Entity { + readonly id: string; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Mapped source form retained instead of materialized properties.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Flags", + "implements": [], + "jsDoc": "/** Mapped source form retained instead of materialized properties. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 11, + }, + "members": [], + "name": "Flags", + "package": "@fixture/host", + "summary": "Mapped source form retained instead of materialized properties.", + "tags": [], + "text": "export type Flags = { + readonly [K in keyof T]?: boolean; +};", + "type": "type:packages/host/src/models.ts:11:24#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:11:19#T", + "name": "T", + }, + ], + }, + { + "abstract": false, + "description": "Predicates and the polymorphic this type remain signatures.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Guards", + "implements": [], + "jsDoc": "/** Predicates and the polymorphic this type remain signatures. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 81, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Guards#isEntity@2519", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 82, + }, + "name": "isEntity", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:82:19#1", + }, + ], + "returns": "type:packages/host/src/models.ts:82:29#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "isEntity(value: unknown): value is Entity", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Guards#isFluent@2563", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 83, + }, + "name": "isFluent", + "optional": false, + "readonly": false, + "signature": { + "parameters": [], + "returns": "type:packages/host/src/models.ts:83:15#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "isFluent(): this is Guards", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Guards#assertEntity@2592", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 84, + }, + "name": "assertEntity", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:84:23#1", + }, + ], + "returns": "type:packages/host/src/models.ts:84:33#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "assertEntity(value: unknown): asserts value is Entity", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Guards#assertPresent@2648", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 85, + }, + "name": "assertPresent", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:85:24#1", + }, + ], + "returns": "type:packages/host/src/models.ts:85:34#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "assertPresent(value: unknown): asserts value", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Guards#fluent@2695", + "kind": "method", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 86, + }, + "name": "fluent", + "optional": false, + "readonly": false, + "signature": { + "parameters": [], + "returns": "type:packages/host/src/models.ts:86:13#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "fluent(): this", + "visibility": "public", + }, + ], + "name": "Guards", + "package": "@fixture/host", + "summary": "Predicates and the polymorphic this type remain signatures.", + "tags": [], + "text": "export interface Guards { + isEntity(value: unknown): value is Entity; + isFluent(): this is Guards; + assertEntity(value: unknown): asserts value is Entity; + assertPresent(value: unknown): asserts value; + fluent(): this; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Runtime-validating data root.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Payload", + "implements": [], + "jsDoc": "/** Runtime-validating data root. @typert schema */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 28, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Payload#name@747", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 29, + }, + "name": "name", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "name: string", + "type": "type:packages/host/src/models.ts:29:9#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Payload#count@762", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 30, + }, + "name": "count", + "optional": true, + "readonly": false, + "static": false, + "tags": [], + "text": "count?: number", + "type": "type:packages/host/src/models.ts:30:11#1", + "visibility": "public", + }, + ], + "name": "Payload", + "package": "@fixture/host", + "summary": "Runtime-validating data root.", + "tags": [ + { + "comment": "schema", + "name": "typert", + "text": "@typert schema", + }, + ], + "text": "export interface Payload { + name: string; + count?: number; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Preserve mapped modifiers when none were authored.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#PlainMap", + "implements": [], + "jsDoc": "/** Preserve mapped modifiers when none were authored. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 60, + }, + "members": [], + "name": "PlainMap", + "package": "@fixture/host", + "summary": "Preserve mapped modifiers when none were authored.", + "tags": [], + "text": "export type PlainMap = { + [Key in keyof Value]: Value[Key]; +};", + "type": "type:packages/host/src/models.ts:60:31#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:60:22#Value", + "name": "Value", + }, + ], + }, + { + "abstract": false, + "description": "Conditional source form retained instead of its resolved instantiations.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Present", + "implements": [], + "jsDoc": "/** Conditional source form retained instead of its resolved instantiations. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 8, + }, + "members": [], + "name": "Present", + "package": "@fixture/host", + "summary": "Conditional source form retained instead of its resolved instantiations.", + "tags": [], + "text": "export type Present = T extends null | undefined ? never : T;", + "type": "type:packages/host/src/models.ts:8:26#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:8:21#T", + "name": "T", + }, + ], + }, + { + "abstract": false, + "description": "Recursive declaration edges retain their declaration target.", + "exported": true, + "extends": [ + "type:packages/host/src/models.ts:95:36#1", + ], + "id": "@fixture/host:packages/host/src/models.ts#Recursive", + "implements": [], + "jsDoc": "/** Recursive declaration edges retain their declaration target. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 95, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Recursive#next@2991", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 96, + }, + "name": "next", + "optional": true, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly next?: Recursive", + "type": "type:packages/host/src/models.ts:96:19#1", + "visibility": "public", + }, + ], + "name": "Recursive", + "package": "@fixture/host", + "summary": "Recursive declaration edges retain their declaration target.", + "tags": [], + "text": "export interface Recursive extends Box { + readonly next?: Recursive; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Retain key remapping and explicit modifier removal.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Remapped", + "implements": [], + "jsDoc": "/** Retain key remapping and explicit modifier removal. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 65, + }, + "members": [], + "name": "Remapped", + "package": "@fixture/host", + "summary": "Retain key remapping and explicit modifier removal.", + "tags": [], + "text": "export type Remapped = { + -readonly [Key in keyof Value as \`get\${Capitalize}\`]-?: Value[Key]; +};", + "type": "type:packages/host/src/models.ts:65:31#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:65:22#Value", + "name": "Value", + }, + ], + }, + { + "abstract": false, + "description": "Infer form nested inside a conditional type.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Result", + "implements": [], + "jsDoc": "/** Infer form nested inside a conditional type. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 48, + }, + "members": [], + "name": "Result", + "package": "@fixture/host", + "summary": "Infer form nested inside a conditional type.", + "tags": [], + "text": "export type Result = Value extends (...arguments_: never[]) => infer Output ? Output : never;", + "type": "type:packages/host/src/models.ts:48:29#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:48:20#Value", + "name": "Value", + }, + ], + }, + { + "abstract": false, + "description": "Multiple template spans retain each authored suffix.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Route", + "implements": [], + "jsDoc": "/** Multiple template spans retain each authored suffix. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 57, + }, + "members": [], + "name": "Route", + "package": "@fixture/host", + "summary": "Multiple template spans retain each authored suffix.", + "tags": [], + "text": "export type Route = \`/\${From}/to/\${To}/end\`;", + "type": "type:packages/host/src/models.ts:57:61#1", + "typeParameters": [ + { + "const": false, + "constraint": "type:packages/host/src/models.ts:57:32#1", + "id": "packages/host/src/models.ts:57:19#From", + "name": "From", + }, + { + "const": false, + "constraint": "type:packages/host/src/models.ts:57:51#1", + "id": "packages/host/src/models.ts:57:40#To", + "name": "To", + }, + ], + }, + { + "abstract": false, + "description": "Constrained infer form retained before conditional evaluation.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#StringResult", + "implements": [], + "jsDoc": "/** Constrained infer form retained before conditional evaluation. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 51, + }, + "members": [], + "name": "StringResult", + "package": "@fixture/host", + "summary": "Constrained infer form retained before conditional evaluation.", + "tags": [], + "text": "export type StringResult = Value extends readonly [ + infer Output extends string +] ? Output : never;", + "type": "type:packages/host/src/models.ts:51:35#1", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:51:26#Value", + "name": "Value", + }, + ], + }, + { + "abstract": false, + "description": "Every supported TypeNode shape is reachable from this declaration.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo", + "implements": [], + "jsDoc": "/** Every supported TypeNode shape is reachable from this declaration. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 112, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#anyValue@3311", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 113, + }, + "name": "anyValue", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "anyValue: any", + "type": "type:packages/host/src/models.ts:113:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#bigintValue@3327", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 114, + }, + "name": "bigintValue", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "bigintValue: bigint", + "type": "type:packages/host/src/models.ts:114:16#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#parenthesized@3349", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 115, + }, + "name": "parenthesized", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "parenthesized: (Entity | null)", + "type": "type:packages/host/src/models.ts:115:18#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#literals@3382", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 116, + }, + "name": "literals", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "literals: 1 | 1n | -2 | -2n | false | \`fixed\`", + "type": "type:packages/host/src/models.ts:116:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#uniqueToken@3430", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 117, + }, + "name": "uniqueToken", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly uniqueToken: unique symbol", + "type": "type:packages/host/src/models.ts:117:25#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#intersection@3468", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 118, + }, + "name": "intersection", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "intersection: Entity & { active: boolean }", + "type": "type:packages/host/src/models.ts:118:17#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#array@3513", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 119, + }, + "name": "array", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "array: string[]", + "type": "type:packages/host/src/models.ts:119:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#tuple@3531", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 120, + }, + "name": "tuple", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "tuple: [head: string, count?: number, ...tail: boolean[]]", + "type": "type:packages/host/src/models.ts:120:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#unnamedTuple@3591", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 121, + }, + "name": "unnamedTuple", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "unnamedTuple: [string?, ...number[]]", + "type": "type:packages/host/src/models.ts:121:17#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#readonlyTuple@3630", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 122, + }, + "name": "readonlyTuple", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "readonlyTuple: readonly [string, number]", + "type": "type:packages/host/src/models.ts:122:18#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#object@3673", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 123, + }, + "name": "object", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "object: { readonly value?: string 'quoted-name': number 1: boolean ['computed']: symbol invoke?(input: number): void }", + "type": "type:packages/host/src/models.ts:123:11#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#callback@3816", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 130, + }, + "name": "callback", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "callback: ( this: Entity, value: Value, optional?: string, ...rest: number[] ) => Promise", + "type": "type:packages/host/src/models.ts:130:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#constCallback@3964", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 136, + }, + "name": "constCallback", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "constCallback: (value: Value) => Value", + "type": "type:packages/host/src/models.ts:136:18#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#factory@4044", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 137, + }, + "name": "factory", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "factory: new (value: Value) => Value", + "type": "type:packages/host/src/models.ts:137:12#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#abstractFactory@4105", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 138, + }, + "name": "abstractFactory", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "abstractFactory: abstract new (id: string) => AbstractEntity", + "type": "type:packages/host/src/models.ts:138:20#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#indexed@4168", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 139, + }, + "name": "indexed", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "indexed: Payload['name']", + "type": "type:packages/host/src/models.ts:139:12#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#inferred@4195", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 140, + }, + "name": "inferred", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "inferred: Result<() => string>", + "type": "type:packages/host/src/models.ts:140:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#constrainedInfer@4228", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 141, + }, + "name": "constrainedInfer", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "constrainedInfer: StringResult<['value']>", + "type": "type:packages/host/src/models.ts:141:21#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#topic@4272", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 142, + }, + "name": "topic", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "topic: Topic<'ready'>", + "type": "type:packages/host/src/models.ts:142:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#route@4296", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 143, + }, + "name": "route", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "route: Route<'source', 'target'>", + "type": "type:packages/host/src/models.ts:143:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#query@4331", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 144, + }, + "name": "query", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "query: typeof phaseOrder", + "type": "type:packages/host/src/models.ts:144:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#instantiatedQuery@4358", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 145, + }, + "name": "instantiatedQuery", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "instantiatedQuery: typeof genericFactory", + "type": "type:packages/host/src/models.ts:145:22#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#imported@4409", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 146, + }, + "name": "imported", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "imported: import('zod').ZodType", + "type": "type:packages/host/src/models.ts:146:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#importedWith@4451", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 147, + }, + "name": "importedWith", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType", + "type": "type:packages/host/src/models.ts:147:17#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#importedModule@4540", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 148, + }, + "name": "importedModule", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "importedModule: typeof import('zod')", + "type": "type:packages/host/src/models.ts:148:19#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#process@4579", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 149, + }, + "name": "process", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "process: NodeJS.Process", + "type": "type:packages/host/src/models.ts:149:12#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#callable@4605", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 150, + }, + "name": "callable", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "callable: Callable", + "type": "type:packages/host/src/models.ts:150:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#guards@4626", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 151, + }, + "name": "guards", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "guards: Guards", + "type": "type:packages/host/src/models.ts:151:11#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#variance@4643", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 152, + }, + "name": "variance", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "variance: Variance>", + "type": "type:packages/host/src/models.ts:152:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#plainMap@4694", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 153, + }, + "name": "plainMap", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "plainMap: PlainMap", + "type": "type:packages/host/src/models.ts:153:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#remapped@4724", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 154, + }, + "name": "remapped", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "remapped: Remapped", + "type": "type:packages/host/src/models.ts:154:13#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#added@4754", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 155, + }, + "name": "added", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "added: Added", + "type": "type:packages/host/src/models.ts:155:10#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#abstractEntity@4778", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 156, + }, + "name": "abstractEntity", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "abstractEntity: AbstractEntity", + "type": "type:packages/host/src/models.ts:156:19#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#recursive@4811", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 157, + }, + "name": "recursive", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "recursive: Recursive", + "type": "type:packages/host/src/models.ts:157:14#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#tagOnly@4834", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 158, + }, + "name": "tagOnly", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "tagOnly: TagOnly", + "type": "type:packages/host/src/models.ts:158:12#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#SyntaxZoo#unpunctuated@4853", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 159, + }, + "name": "unpunctuated", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "unpunctuated: Unpunctuated", + "type": "type:packages/host/src/models.ts:159:17#1", + "visibility": "public", + }, + ], + "name": "SyntaxZoo", + "package": "@fixture/host", + "summary": "Every supported TypeNode shape is reachable from this declaration.", + "tags": [], + "text": "export interface SyntaxZoo { + anyValue: any; + bigintValue: bigint; + parenthesized: (Entity | null); + literals: 1 | 1n | -2 | -2n | false | \`fixed\`; + readonly uniqueToken: unique symbol; + intersection: Entity & { + active: boolean; + }; + array: string[]; + tuple: [ + head: string, + count?: number, + ...tail: boolean[] + ]; + unnamedTuple: [ + string?, + ...number[] + ]; + readonlyTuple: readonly [ + string, + number + ]; + object: { + readonly value?: string; + 'quoted-name': number; + 1: boolean; + ['computed']: symbol; + invoke?(input: number): void; + }; + callback: (this: Entity, value: Value, optional?: string, ...rest: number[]) => Promise; + constCallback: (value: Value) => Value; + factory: new (value: Value) => Value; + abstractFactory: abstract new (id: string) => AbstractEntity; + indexed: Payload['name']; + inferred: Result<() => string>; + constrainedInfer: StringResult<[ + 'value' + ]>; + topic: Topic<'ready'>; + route: Route<'source', 'target'>; + query: typeof phaseOrder; + instantiatedQuery: typeof genericFactory; + imported: import('zod').ZodType; + importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType; + importedModule: typeof import('zod'); + process: NodeJS.Process; + callable: Callable; + guards: Guards; + variance: Variance>; + plainMap: PlainMap; + remapped: Remapped; + added: Added; + abstractEntity: AbstractEntity; + recursive: Recursive; + tagOnly: TagOnly; + unpunctuated: Unpunctuated; +}", + "typeParameters": [], + }, + { + "abstract": false, + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#TagOnly", + "implements": [], + "jsDoc": "/** + * @deprecated + */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 102, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#TagOnly#value@3072", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 103, + }, + "name": "value", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly value: string", + "type": "type:packages/host/src/models.ts:103:19#1", + "visibility": "public", + }, + ], + "name": "TagOnly", + "package": "@fixture/host", + "tags": [ + { + "name": "deprecated", + "text": "@deprecated", + }, + ], + "text": "export interface TagOnly { + readonly value: string; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Template-literal source form.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Topic", + "implements": [], + "jsDoc": "/** Template-literal source form. */", + "kind": "alias", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 54, + }, + "members": [], + "name": "Topic", + "package": "@fixture/host", + "summary": "Template-literal source form.", + "tags": [], + "text": "export type Topic = \`demo/\${Name}\`;", + "type": "type:packages/host/src/models.ts:54:42#1", + "typeParameters": [ + { + "const": false, + "constraint": "type:packages/host/src/models.ts:54:32#1", + "id": "packages/host/src/models.ts:54:19#Name", + "name": "Name", + }, + ], + }, + { + "abstract": false, + "description": "Description without terminal punctuation", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Unpunctuated", + "implements": [], + "jsDoc": "/** Description without terminal punctuation */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 107, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Unpunctuated#value@3180", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 108, + }, + "name": "value", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly value: string", + "type": "type:packages/host/src/models.ts:108:19#1", + "visibility": "public", + }, + ], + "name": "Unpunctuated", + "package": "@fixture/host", + "summary": "Description without terminal punctuation", + "tags": [], + "text": "export interface Unpunctuated { + readonly value: string; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Input, output, and invariant parameters retain authored variance.", + "exported": true, + "extends": [], + "id": "@fixture/host:packages/host/src/models.ts#Variance", + "implements": [], + "jsDoc": "/** Input, output, and invariant parameters retain authored variance. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/host/src/models.ts", + "line": 41, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Variance#consume@1118", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 42, + }, + "name": "consume", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "consume: (input: Input) => void", + "type": "type:packages/host/src/models.ts:42:12#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Variance#produce@1152", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 43, + }, + "name": "produce", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly produce: () => Output", + "type": "type:packages/host/src/models.ts:43:21#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/host:packages/host/src/models.ts#Variance#state@1185", + "kind": "property", + "location": { + "column": 3, + "file": "packages/host/src/models.ts", + "line": 44, + }, + "name": "state", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "state: State", + "type": "type:packages/host/src/models.ts:44:10#1", + "visibility": "public", + }, + ], + "name": "Variance", + "package": "@fixture/host", + "summary": "Input, output, and invariant parameters retain authored variance.", + "tags": [], + "text": "export interface Variance { + consume: (input: Input) => void; + readonly produce: () => Output; + state: State; +}", + "typeParameters": [ + { + "const": false, + "id": "packages/host/src/models.ts:41:27#Input", + "name": "Input", + "variance": "in", + }, + { + "const": false, + "id": "packages/host/src/models.ts:41:37#Output", + "name": "Output", + "variance": "out", + }, + { + "const": false, + "id": "packages/host/src/models.ts:41:49#State", + "name": "State", + "variance": "in-out", + }, + ], + }, + ], + "nodes": [ + { + "arguments": [ + "type:packages/host/src/index.ts:116:31#1", + ], + "id": "type:packages/host/src/index.ts:116:25#1", + "kind": "reference", + "name": "Agent", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + }, + { + "id": "type:packages/host/src/index.ts:116:31#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/index.ts:116:31#1#ready@3038", + "kind": "property", + "location": { + "column": 33, + "file": "packages/host/src/index.ts", + "line": 116, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/host/src/index.ts:116:40#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/index.ts:116:40#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "id": "type:packages/host/src/index.ts:116:5#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "agent", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:116:25#1", + }, + { + "binding": "identifier", + "name": "payload", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:116:58#1", + }, + ], + "returns": "type:packages/host/src/index.ts:116:73#1", + "typeParameters": [], + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:116:62#1", + ], + "id": "type:packages/host/src/index.ts:116:58#1", + "kind": "reference", + "name": "Box", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:116:62#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/index.ts:116:73#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/index.ts:118:25#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/index.ts:118:5#1", + "kind": "function", + "signature": { + "parameters": [], + "returns": "type:packages/host/src/index.ts:118:25#1", + "typeParameters": [], + }, + }, + { + "id": "type:packages/host/src/index.ts:12:34#1", + "kind": "keyword", + "name": "object", + }, + { + "id": "type:packages/host/src/index.ts:12:43#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/index.ts:12:43#1#ready@387", + "kind": "property", + "location": { + "column": 45, + "file": "packages/host/src/index.ts", + "line": 12, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: boolean", + "type": "type:packages/host/src/index.ts:12:52#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/index.ts:12:52#1", + "kind": "keyword", + "name": "boolean", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:12:74#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/index.ts:120:22#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "payload", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:120:32#1", + }, + ], + "returns": "type:packages/host/src/index.ts:120:44#1", + "typeParameters": [], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:120:32#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/index.ts:120:44#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/index.ts:123:29#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "payload", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:123:39#1", + }, + ], + "returns": "type:packages/host/src/index.ts:123:51#1", + "typeParameters": [], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:123:39#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/index.ts:123:51#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [ + "type:packages/host/src/index.ts:139:31#1", + ], + "id": "type:packages/host/src/index.ts:139:25#1", + "kind": "reference", + "name": "Agent", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + }, + { + "id": "type:packages/host/src/index.ts:139:31#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/index.ts:139:31#1#ready@3476", + "kind": "property", + "location": { + "column": 33, + "file": "packages/host/src/index.ts", + "line": 139, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/host/src/index.ts:139:40#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/index.ts:139:40#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "id": "type:packages/host/src/index.ts:139:5#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "agent", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:139:25#1", + }, + { + "binding": "identifier", + "name": "payload", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/index.ts:139:58#1", + }, + ], + "returns": "type:packages/host/src/index.ts:139:73#1", + "typeParameters": [], + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:139:62#1", + ], + "id": "type:packages/host/src/index.ts:139:58#1", + "kind": "reference", + "name": "Box", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:139:62#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/index.ts:139:73#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/index.ts:15:16#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:16:10#1", + "kind": "reference", + "name": "State", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/index.ts:12:20#State", + }, + }, + { + "id": "type:packages/host/src/index.ts:26:16#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/index.ts:31:20#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/index.ts:31:3#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [ + "type:packages/host/src/index.ts:36:25#1", + ], + "id": "type:packages/host/src/index.ts:36:21#1", + "kind": "reference", + "name": "Box", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:36:25#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/index.ts:36:7#Value", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:36:42#1", + ], + "id": "type:packages/host/src/index.ts:36:34#1", + "kind": "reference", + "name": "Promise", + "target": { + "kind": "standard", + "name": "Promise", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:36:50#1", + ], + "id": "type:packages/host/src/index.ts:36:42#1", + "kind": "reference", + "name": "Present", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Present", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:36:50#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/index.ts:36:7#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:44:30#1", + "kind": "reference", + "name": "Service", + "target": { + "kind": "external", + "module": "cordis", + "name": "Service", + "subpath": ".", + }, + }, + { + "id": "type:packages/host/src/index.ts:46:12#1", + "kind": "keyword", + "name": "boolean", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:54:34#1", + "kind": "reference", + "name": "Service", + "target": { + "kind": "external", + "module": "cordis", + "name": "Service", + "subpath": ".", + }, + }, + { + "id": "type:packages/host/src/index.ts:56:12#1", + "kind": "keyword", + "name": "boolean", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:62:34#1", + "kind": "reference", + "name": "Service", + "target": { + "kind": "external", + "module": "cordis", + "name": "Service", + "subpath": ".", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:68:24#1", + ], + "id": "type:packages/host/src/index.ts:68:18#1", + "kind": "reference", + "name": "Agent", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + }, + { + "id": "type:packages/host/src/index.ts:68:24#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/index.ts:68:24#1#ready@1790", + "kind": "property", + "location": { + "column": 26, + "file": "packages/host/src/index.ts", + "line": 68, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/host/src/index.ts:68:33#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/index.ts:68:33#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:68:55#1", + ], + "id": "type:packages/host/src/index.ts:68:49#1", + "kind": "reference", + "name": "Flags", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Flags", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:68:55#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:68:74#1", + ], + "id": "type:packages/host/src/index.ts:68:66#1", + "kind": "reference", + "name": "Present", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Present", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:68:74#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:73:35#1", + ], + "id": "type:packages/host/src/index.ts:73:27#1", + "kind": "reference", + "name": "ZodType", + "target": { + "kind": "external", + "module": "zod", + "name": "ZodType", + "subpath": ".", + }, + }, + { + "id": "type:packages/host/src/index.ts:73:35#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/index.ts:73:45#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:78:19#1", + "kind": "reference", + "name": "AgentPhase", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#AgentPhase", + }, + }, + { + "id": "type:packages/host/src/index.ts:78:32#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:83:22#1", + "kind": "reference", + "name": "SyntaxZoo", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#SyntaxZoo", + }, + }, + { + "id": "type:packages/host/src/index.ts:83:34#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:88:27#1", + "kind": "reference", + "name": "SyntaxZoo", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#SyntaxZoo", + }, + }, + { + "arguments": [ + "type:packages/host/src/index.ts:88:47#1", + ], + "id": "type:packages/host/src/index.ts:88:39#1", + "kind": "reference", + "name": "Promise", + "target": { + "kind": "standard", + "name": "Promise", + }, + }, + { + "id": "type:packages/host/src/index.ts:88:47#1", + "kind": "keyword", + "name": "void", + }, + { + "arguments": [], + "id": "type:packages/host/src/index.ts:93:25#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "elements": [ + { + "optional": false, + "rest": false, + "type": "type:packages/host/src/index.ts:93:45#1", + }, + ], + "id": "type:packages/host/src/index.ts:93:44#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/index.ts:93:45#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/index.ts:93:55#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:103:19#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:108:19#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:11:24#1", + "kind": "mapped", + "optional": "add", + "parameter": { + "const": false, + "constraint": "type:packages/host/src/models.ts:12:18#1", + "id": "packages/host/src/models.ts:12:13#K", + "name": "K", + }, + "readonly": "add", + "value": "type:packages/host/src/models.ts:12:29#1", + }, + { + "id": "type:packages/host/src/models.ts:113:13#1", + "kind": "keyword", + "name": "any", + }, + { + "id": "type:packages/host/src/models.ts:114:16#1", + "kind": "keyword", + "name": "bigint", + }, + { + "id": "type:packages/host/src/models.ts:115:18#1", + "kind": "parenthesized", + "type": "type:packages/host/src/models.ts:115:19#1", + }, + { + "id": "type:packages/host/src/models.ts:115:19#1", + "kind": "union", + "types": [ + "type:packages/host/src/models.ts:115:19#2", + "type:packages/host/src/models.ts:115:28#1", + ], + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:115:19#2", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/models.ts:115:28#1", + "kind": "literal", + "text": "null", + "value": null, + }, + { + "id": "type:packages/host/src/models.ts:116:13#1", + "kind": "union", + "types": [ + "type:packages/host/src/models.ts:116:13#2", + "type:packages/host/src/models.ts:116:17#1", + "type:packages/host/src/models.ts:116:22#1", + "type:packages/host/src/models.ts:116:27#1", + "type:packages/host/src/models.ts:116:33#1", + "type:packages/host/src/models.ts:116:41#1", + ], + }, + { + "id": "type:packages/host/src/models.ts:116:13#2", + "kind": "literal", + "text": "1", + "value": 1, + }, + { + "id": "type:packages/host/src/models.ts:116:17#1", + "kind": "literal", + "text": "1n", + "value": 1n, + }, + { + "id": "type:packages/host/src/models.ts:116:22#1", + "kind": "literal", + "text": "-2", + "value": -2, + }, + { + "id": "type:packages/host/src/models.ts:116:27#1", + "kind": "literal", + "text": "-2n", + "value": -2n, + }, + { + "id": "type:packages/host/src/models.ts:116:33#1", + "kind": "literal", + "text": "false", + "value": false, + }, + { + "id": "type:packages/host/src/models.ts:116:41#1", + "kind": "literal", + "text": "\`fixed\`", + "value": "fixed", + }, + { + "id": "type:packages/host/src/models.ts:117:25#1", + "kind": "operator", + "operator": "unique", + "type": "type:packages/host/src/models.ts:117:32#1", + }, + { + "id": "type:packages/host/src/models.ts:117:32#1", + "kind": "keyword", + "name": "symbol", + }, + { + "id": "type:packages/host/src/models.ts:118:17#1", + "kind": "intersection", + "types": [ + "type:packages/host/src/models.ts:118:17#2", + "type:packages/host/src/models.ts:118:26#1", + ], + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:118:17#2", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/models.ts:118:26#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:118:26#1#active@3493", + "kind": "property", + "location": { + "column": 28, + "file": "packages/host/src/models.ts", + "line": 118, + }, + "name": "active", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "active: boolean", + "type": "type:packages/host/src/models.ts:118:36#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/models.ts:118:36#1", + "kind": "keyword", + "name": "boolean", + }, + { + "element": "type:packages/host/src/models.ts:119:10#2", + "id": "type:packages/host/src/models.ts:119:10#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:119:10#2", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:12:18#1", + "kind": "operator", + "operator": "keyof", + "type": "type:packages/host/src/models.ts:12:24#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:12:24#1", + "kind": "reference", + "name": "T", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:11:19#T", + }, + }, + { + "id": "type:packages/host/src/models.ts:12:29#1", + "kind": "keyword", + "name": "boolean", + }, + { + "elements": [ + { + "name": "head", + "optional": false, + "rest": false, + "type": "type:packages/host/src/models.ts:120:17#1", + }, + { + "name": "count", + "optional": true, + "rest": false, + "type": "type:packages/host/src/models.ts:120:33#1", + }, + { + "name": "tail", + "optional": false, + "rest": true, + "type": "type:packages/host/src/models.ts:120:50#1", + }, + ], + "id": "type:packages/host/src/models.ts:120:10#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/models.ts:120:17#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:120:33#1", + "kind": "keyword", + "name": "number", + }, + { + "element": "type:packages/host/src/models.ts:120:50#2", + "id": "type:packages/host/src/models.ts:120:50#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:120:50#2", + "kind": "keyword", + "name": "boolean", + }, + { + "elements": [ + { + "optional": true, + "rest": false, + "type": "type:packages/host/src/models.ts:121:18#1", + }, + { + "optional": false, + "rest": true, + "type": "type:packages/host/src/models.ts:121:30#1", + }, + ], + "id": "type:packages/host/src/models.ts:121:17#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/models.ts:121:18#1", + "kind": "keyword", + "name": "string", + }, + { + "element": "type:packages/host/src/models.ts:121:30#2", + "id": "type:packages/host/src/models.ts:121:30#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:121:30#2", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:122:18#1", + "kind": "operator", + "operator": "readonly", + "type": "type:packages/host/src/models.ts:122:27#1", + }, + { + "elements": [ + { + "optional": false, + "rest": false, + "type": "type:packages/host/src/models.ts:122:28#1", + }, + { + "optional": false, + "rest": false, + "type": "type:packages/host/src/models.ts:122:36#1", + }, + ], + "id": "type:packages/host/src/models.ts:122:27#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/models.ts:122:28#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:122:36#1", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:123:11#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:123:11#1#value@3687", + "kind": "property", + "location": { + "column": 5, + "file": "packages/host/src/models.ts", + "line": 124, + }, + "name": "value", + "optional": true, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly value?: string", + "type": "type:packages/host/src/models.ts:124:22#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:123:11#1#quoted-name@3715", + "kind": "property", + "location": { + "column": 5, + "file": "packages/host/src/models.ts", + "line": 125, + }, + "name": "quoted-name", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "'quoted-name': number", + "type": "type:packages/host/src/models.ts:125:20#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:123:11#1#1@3741", + "kind": "property", + "location": { + "column": 5, + "file": "packages/host/src/models.ts", + "line": 126, + }, + "name": "1", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "1: boolean", + "type": "type:packages/host/src/models.ts:126:8#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756", + "kind": "property", + "location": { + "column": 5, + "file": "packages/host/src/models.ts", + "line": 127, + }, + "name": "['computed']", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "['computed']: symbol", + "type": "type:packages/host/src/models.ts:127:19#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "type:packages/host/src/models.ts:123:11#1#invoke@3781", + "kind": "method", + "location": { + "column": 5, + "file": "packages/host/src/models.ts", + "line": 128, + }, + "name": "invoke", + "optional": true, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "input", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:128:20#1", + }, + ], + "returns": "type:packages/host/src/models.ts:128:29#1", + "typeParameters": [], + }, + "static": false, + "tags": [], + "text": "invoke?(input: number): void", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/host/src/models.ts:124:22#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:125:20#1", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:126:8#1", + "kind": "keyword", + "name": "boolean", + }, + { + "id": "type:packages/host/src/models.ts:127:19#1", + "kind": "keyword", + "name": "symbol", + }, + { + "id": "type:packages/host/src/models.ts:128:20#1", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:128:29#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/models.ts:130:13#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "this", + "optional": false, + "receiver": true, + "rest": false, + "type": "type:packages/host/src/models.ts:131:11#1", + }, + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:132:12#1", + }, + { + "binding": "identifier", + "name": "optional", + "optional": true, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:133:16#1", + }, + { + "binding": "identifier", + "name": "rest", + "optional": false, + "receiver": false, + "rest": true, + "type": "type:packages/host/src/models.ts:134:14#1", + }, + ], + "returns": "type:packages/host/src/models.ts:135:8#1", + "typeParameters": [ + { + "const": false, + "constraint": "type:packages/host/src/models.ts:130:28#1", + "default": "type:packages/host/src/models.ts:130:37#1", + "id": "packages/host/src/models.ts:130:14#Value", + "name": "Value", + }, + ], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:130:28#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:130:37#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:131:11#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:132:12#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:130:14#Value", + }, + }, + { + "id": "type:packages/host/src/models.ts:133:16#1", + "kind": "keyword", + "name": "string", + }, + { + "element": "type:packages/host/src/models.ts:134:14#2", + "id": "type:packages/host/src/models.ts:134:14#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:134:14#2", + "kind": "keyword", + "name": "number", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:135:16#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:130:14#Value", + }, + }, + { + "arguments": [ + "type:packages/host/src/models.ts:135:16#1", + ], + "id": "type:packages/host/src/models.ts:135:8#1", + "kind": "reference", + "name": "Promise", + "target": { + "kind": "standard", + "name": "Promise", + }, + }, + { + "id": "type:packages/host/src/models.ts:136:18#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:136:65#1", + }, + ], + "returns": "type:packages/host/src/models.ts:136:75#1", + "typeParameters": [ + { + "const": true, + "constraint": "type:packages/host/src/models.ts:136:39#1", + "id": "packages/host/src/models.ts:136:19#Value", + "name": "Value", + }, + ], + }, + }, + { + "id": "type:packages/host/src/models.ts:136:39#1", + "kind": "operator", + "operator": "readonly", + "type": "type:packages/host/src/models.ts:136:48#1", + }, + { + "element": "type:packages/host/src/models.ts:136:48#2", + "id": "type:packages/host/src/models.ts:136:48#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:136:48#2", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:136:65#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:136:19#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:136:75#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:136:19#Value", + }, + }, + { + "abstract": false, + "id": "type:packages/host/src/models.ts:137:12#1", + "kind": "constructor", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "value", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:137:46#1", + }, + ], + "returns": "type:packages/host/src/models.ts:137:56#1", + "typeParameters": [ + { + "const": false, + "constraint": "type:packages/host/src/models.ts:137:31#1", + "id": "packages/host/src/models.ts:137:17#Value", + "name": "Value", + }, + ], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:137:31#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:137:46#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:137:17#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:137:56#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:137:17#Value", + }, + }, + { + "abstract": true, + "id": "type:packages/host/src/models.ts:138:20#1", + "kind": "constructor", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "id", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:138:38#1", + }, + ], + "returns": "type:packages/host/src/models.ts:138:49#1", + "typeParameters": [], + }, + }, + { + "id": "type:packages/host/src/models.ts:138:38#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:138:49#1", + "kind": "reference", + "name": "AbstractEntity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#AbstractEntity", + }, + }, + { + "id": "type:packages/host/src/models.ts:139:12#1", + "index": "type:packages/host/src/models.ts:139:20#1", + "kind": "indexed-access", + "object": "type:packages/host/src/models.ts:139:12#2", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:139:12#2", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/models.ts:139:20#1", + "kind": "literal", + "text": "'name'", + "value": "name", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:140:20#1", + ], + "id": "type:packages/host/src/models.ts:140:13#1", + "kind": "reference", + "name": "Result", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Result", + }, + }, + { + "id": "type:packages/host/src/models.ts:140:20#1", + "kind": "function", + "signature": { + "parameters": [], + "returns": "type:packages/host/src/models.ts:140:26#1", + "typeParameters": [], + }, + }, + { + "id": "type:packages/host/src/models.ts:140:26#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:141:34#1", + ], + "id": "type:packages/host/src/models.ts:141:21#1", + "kind": "reference", + "name": "StringResult", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#StringResult", + }, + }, + { + "elements": [ + { + "optional": false, + "rest": false, + "type": "type:packages/host/src/models.ts:141:35#1", + }, + ], + "id": "type:packages/host/src/models.ts:141:34#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/models.ts:141:35#1", + "kind": "literal", + "text": "'value'", + "value": "value", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:142:16#1", + ], + "id": "type:packages/host/src/models.ts:142:10#1", + "kind": "reference", + "name": "Topic", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Topic", + }, + }, + { + "id": "type:packages/host/src/models.ts:142:16#1", + "kind": "literal", + "text": "'ready'", + "value": "ready", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:143:16#1", + "type:packages/host/src/models.ts:143:26#1", + ], + "id": "type:packages/host/src/models.ts:143:10#1", + "kind": "reference", + "name": "Route", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Route", + }, + }, + { + "id": "type:packages/host/src/models.ts:143:16#1", + "kind": "literal", + "text": "'source'", + "value": "source", + }, + { + "id": "type:packages/host/src/models.ts:143:26#1", + "kind": "literal", + "text": "'target'", + "value": "target", + }, + { + "arguments": [], + "expression": "phaseOrder", + "id": "type:packages/host/src/models.ts:144:10#1", + "kind": "type-query", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:145:44#1", + ], + "expression": "genericFactory", + "id": "type:packages/host/src/models.ts:145:22#1", + "kind": "type-query", + }, + { + "id": "type:packages/host/src/models.ts:145:44#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:146:35#1", + ], + "id": "type:packages/host/src/models.ts:146:13#1", + "kind": "import-type", + "module": "zod", + "qualifier": "ZodType", + "target": { + "kind": "external", + "module": "zod", + "name": "ZodType", + "subpath": ".", + }, + "typeof": false, + }, + { + "id": "type:packages/host/src/models.ts:146:35#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:147:82#1", + ], + "attributes": "{ with: { 'resolution-mode': 'import' } }", + "id": "type:packages/host/src/models.ts:147:17#1", + "kind": "import-type", + "module": "zod", + "qualifier": "ZodType", + "target": { + "kind": "external", + "module": "zod", + "name": "ZodType", + "subpath": ".", + }, + "typeof": false, + }, + { + "id": "type:packages/host/src/models.ts:147:82#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:148:19#1", + "kind": "import-type", + "module": "zod", + "typeof": true, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:149:12#1", + "kind": "reference", + "name": "NodeJS.Process", + "target": { + "kind": "external", + "module": "@types/node", + "name": "Process", + "subpath": ".", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:150:13#1", + "kind": "reference", + "name": "Callable", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Callable", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:151:11#1", + "kind": "reference", + "name": "Guards", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Guards", + }, + }, + { + "arguments": [ + "type:packages/host/src/models.ts:152:22#1", + "type:packages/host/src/models.ts:152:30#1", + "type:packages/host/src/models.ts:152:39#1", + ], + "id": "type:packages/host/src/models.ts:152:13#1", + "kind": "reference", + "name": "Variance", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Variance", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:152:22#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:152:30#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [ + "type:packages/host/src/models.ts:152:43#1", + ], + "id": "type:packages/host/src/models.ts:152:39#1", + "kind": "reference", + "name": "Box", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + }, + { + "id": "type:packages/host/src/models.ts:152:43#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:153:22#1", + ], + "id": "type:packages/host/src/models.ts:153:13#1", + "kind": "reference", + "name": "PlainMap", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#PlainMap", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:153:22#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [ + "type:packages/host/src/models.ts:154:22#1", + ], + "id": "type:packages/host/src/models.ts:154:13#1", + "kind": "reference", + "name": "Remapped", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Remapped", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:154:22#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [ + "type:packages/host/src/models.ts:155:16#1", + ], + "id": "type:packages/host/src/models.ts:155:10#1", + "kind": "reference", + "name": "Added", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Added", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:155:16#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:156:19#1", + "kind": "reference", + "name": "AbstractEntity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#AbstractEntity", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:157:14#1", + "kind": "reference", + "name": "Recursive", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Recursive", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:158:12#1", + "kind": "reference", + "name": "TagOnly", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#TagOnly", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:159:17#1", + "kind": "reference", + "name": "Unpunctuated", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Unpunctuated", + }, + }, + { + "id": "type:packages/host/src/models.ts:17:16#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:28:1#1", + "kind": "reference", + "name": "Payload", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + }, + { + "id": "type:packages/host/src/models.ts:29:9#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:30:11#1", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:35:11#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:35:20#1", + "kind": "keyword", + "name": "number", + }, + { + "id": "type:packages/host/src/models.ts:36:15#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:36:24#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/models.ts:37:18#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:37:27#1", + "kind": "keyword", + "name": "unknown", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:4:19#1", + "kind": "reference", + "name": "T", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:2:22#T", + }, + }, + { + "id": "type:packages/host/src/models.ts:42:12#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "input", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/host/src/models.ts:42:20#1", + }, + ], + "returns": "type:packages/host/src/models.ts:42:30#1", + "typeParameters": [], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:42:20#1", + "kind": "reference", + "name": "Input", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:41:27#Input", + }, + }, + { + "id": "type:packages/host/src/models.ts:42:30#1", + "kind": "keyword", + "name": "void", + }, + { + "id": "type:packages/host/src/models.ts:43:21#1", + "kind": "function", + "signature": { + "parameters": [], + "returns": "type:packages/host/src/models.ts:43:27#1", + "typeParameters": [], + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:43:27#1", + "kind": "reference", + "name": "Output", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:41:37#Output", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:44:10#1", + "kind": "reference", + "name": "State", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:41:49#State", + }, + }, + { + "check": "type:packages/host/src/models.ts:48:29#2", + "extends": "type:packages/host/src/models.ts:48:43#1", + "id": "type:packages/host/src/models.ts:48:29#1", + "kind": "conditional", + "whenFalse": "type:packages/host/src/models.ts:48:95#1", + "whenTrue": "type:packages/host/src/models.ts:48:86#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:48:29#2", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:48:20#Value", + }, + }, + { + "id": "type:packages/host/src/models.ts:48:43#1", + "kind": "function", + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "arguments_", + "optional": false, + "receiver": false, + "rest": true, + "type": "type:packages/host/src/models.ts:48:59#1", + }, + ], + "returns": "type:packages/host/src/models.ts:48:71#1", + "typeParameters": [], + }, + }, + { + "element": "type:packages/host/src/models.ts:48:59#2", + "id": "type:packages/host/src/models.ts:48:59#1", + "kind": "array", + }, + { + "id": "type:packages/host/src/models.ts:48:59#2", + "kind": "keyword", + "name": "never", + }, + { + "id": "type:packages/host/src/models.ts:48:71#1", + "kind": "infer", + "parameter": { + "const": false, + "id": "packages/host/src/models.ts:48:77#Output", + "name": "Output", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:48:86#1", + "kind": "reference", + "name": "Output", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:48:77#Output", + }, + }, + { + "id": "type:packages/host/src/models.ts:48:95#1", + "kind": "keyword", + "name": "never", + }, + { + "check": "type:packages/host/src/models.ts:51:35#2", + "extends": "type:packages/host/src/models.ts:51:49#1", + "id": "type:packages/host/src/models.ts:51:35#1", + "kind": "conditional", + "whenFalse": "type:packages/host/src/models.ts:51:99#1", + "whenTrue": "type:packages/host/src/models.ts:51:90#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:51:35#2", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:51:26#Value", + }, + }, + { + "id": "type:packages/host/src/models.ts:51:49#1", + "kind": "operator", + "operator": "readonly", + "type": "type:packages/host/src/models.ts:51:58#1", + }, + { + "elements": [ + { + "optional": false, + "rest": false, + "type": "type:packages/host/src/models.ts:51:59#1", + }, + ], + "id": "type:packages/host/src/models.ts:51:58#1", + "kind": "tuple", + }, + { + "id": "type:packages/host/src/models.ts:51:59#1", + "kind": "infer", + "parameter": { + "const": false, + "constraint": "type:packages/host/src/models.ts:51:80#1", + "id": "packages/host/src/models.ts:51:65#Output", + "name": "Output", + }, + }, + { + "id": "type:packages/host/src/models.ts:51:80#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:51:90#1", + "kind": "reference", + "name": "Output", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:51:65#Output", + }, + }, + { + "id": "type:packages/host/src/models.ts:51:99#1", + "kind": "keyword", + "name": "never", + }, + { + "id": "type:packages/host/src/models.ts:54:32#1", + "kind": "keyword", + "name": "string", + }, + { + "head": "demo/", + "id": "type:packages/host/src/models.ts:54:42#1", + "kind": "template-literal", + "spans": [ + { + "text": "", + "type": "type:packages/host/src/models.ts:54:50#1", + }, + ], + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:54:50#1", + "kind": "reference", + "name": "Name", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:54:19#Name", + }, + }, + { + "id": "type:packages/host/src/models.ts:57:32#1", + "kind": "keyword", + "name": "string", + }, + { + "id": "type:packages/host/src/models.ts:57:51#1", + "kind": "keyword", + "name": "string", + }, + { + "head": "/", + "id": "type:packages/host/src/models.ts:57:61#1", + "kind": "template-literal", + "spans": [ + { + "text": "/to/", + "type": "type:packages/host/src/models.ts:57:65#1", + }, + { + "text": "/end", + "type": "type:packages/host/src/models.ts:57:76#1", + }, + ], + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:57:65#1", + "kind": "reference", + "name": "From", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:57:19#From", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:57:76#1", + "kind": "reference", + "name": "To", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:57:40#To", + }, + }, + { + "id": "type:packages/host/src/models.ts:60:31#1", + "kind": "mapped", + "optional": "preserve", + "parameter": { + "const": false, + "constraint": "type:packages/host/src/models.ts:61:11#1", + "id": "packages/host/src/models.ts:61:4#Key", + "name": "Key", + }, + "readonly": "preserve", + "value": "type:packages/host/src/models.ts:61:25#1", + }, + { + "id": "type:packages/host/src/models.ts:61:11#1", + "kind": "operator", + "operator": "keyof", + "type": "type:packages/host/src/models.ts:61:17#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:61:17#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:60:22#Value", + }, + }, + { + "id": "type:packages/host/src/models.ts:61:25#1", + "index": "type:packages/host/src/models.ts:61:31#1", + "kind": "indexed-access", + "object": "type:packages/host/src/models.ts:61:25#2", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:61:25#2", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:60:22#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:61:31#1", + "kind": "reference", + "name": "Key", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:61:4#Key", + }, + }, + { + "id": "type:packages/host/src/models.ts:65:31#1", + "kind": "mapped", + "nameType": "type:packages/host/src/models.ts:66:36#1", + "optional": "remove", + "parameter": { + "const": false, + "constraint": "type:packages/host/src/models.ts:66:21#1", + "id": "packages/host/src/models.ts:66:14#Key", + "name": "Key", + }, + "readonly": "remove", + "value": "type:packages/host/src/models.ts:66:73#1", + }, + { + "id": "type:packages/host/src/models.ts:66:21#1", + "kind": "operator", + "operator": "keyof", + "type": "type:packages/host/src/models.ts:66:27#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:66:27#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:65:22#Value", + }, + }, + { + "head": "get", + "id": "type:packages/host/src/models.ts:66:36#1", + "kind": "template-literal", + "spans": [ + { + "text": "", + "type": "type:packages/host/src/models.ts:66:42#1", + }, + ], + }, + { + "arguments": [ + "type:packages/host/src/models.ts:66:53#1", + ], + "id": "type:packages/host/src/models.ts:66:42#1", + "kind": "reference", + "name": "Capitalize", + "target": { + "kind": "standard", + "name": "Capitalize", + }, + }, + { + "id": "type:packages/host/src/models.ts:66:53#1", + "kind": "intersection", + "types": [ + "type:packages/host/src/models.ts:66:53#2", + "type:packages/host/src/models.ts:66:62#1", + ], + }, + { + "id": "type:packages/host/src/models.ts:66:53#2", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:66:62#1", + "kind": "reference", + "name": "Key", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:66:14#Key", + }, + }, + { + "id": "type:packages/host/src/models.ts:66:73#1", + "index": "type:packages/host/src/models.ts:66:79#1", + "kind": "indexed-access", + "object": "type:packages/host/src/models.ts:66:73#2", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:66:73#2", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:65:22#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:66:79#1", + "kind": "reference", + "name": "Key", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:66:14#Key", + }, + }, + { + "id": "type:packages/host/src/models.ts:70:28#1", + "kind": "mapped", + "optional": "add", + "parameter": { + "const": false, + "constraint": "type:packages/host/src/models.ts:71:21#1", + "id": "packages/host/src/models.ts:71:14#Key", + "name": "Key", + }, + "readonly": "add", + "value": "type:packages/host/src/models.ts:71:37#1", + }, + { + "id": "type:packages/host/src/models.ts:71:21#1", + "kind": "operator", + "operator": "keyof", + "type": "type:packages/host/src/models.ts:71:27#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:71:27#1", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:70:19#Value", + }, + }, + { + "id": "type:packages/host/src/models.ts:71:37#1", + "index": "type:packages/host/src/models.ts:71:43#1", + "kind": "indexed-access", + "object": "type:packages/host/src/models.ts:71:37#2", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:71:37#2", + "kind": "reference", + "name": "Value", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:70:19#Value", + }, + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:71:43#1", + "kind": "reference", + "name": "Key", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:71:14#Key", + }, + }, + { + "check": "type:packages/host/src/models.ts:8:26#2", + "extends": "type:packages/host/src/models.ts:8:36#1", + "id": "type:packages/host/src/models.ts:8:26#1", + "kind": "conditional", + "whenFalse": "type:packages/host/src/models.ts:8:63#1", + "whenTrue": "type:packages/host/src/models.ts:8:55#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:8:26#2", + "kind": "reference", + "name": "T", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:8:21#T", + }, + }, + { + "id": "type:packages/host/src/models.ts:8:36#1", + "kind": "union", + "types": [ + "type:packages/host/src/models.ts:8:36#2", + "type:packages/host/src/models.ts:8:43#1", + ], + }, + { + "id": "type:packages/host/src/models.ts:8:36#2", + "kind": "literal", + "text": "null", + "value": null, + }, + { + "id": "type:packages/host/src/models.ts:8:43#1", + "kind": "keyword", + "name": "undefined", + }, + { + "id": "type:packages/host/src/models.ts:8:55#1", + "kind": "keyword", + "name": "never", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:8:63#1", + "kind": "reference", + "name": "T", + "target": { + "kind": "type-parameter", + "parameter": "packages/host/src/models.ts:8:21#T", + }, + }, + { + "id": "type:packages/host/src/models.ts:82:19#1", + "kind": "keyword", + "name": "unknown", + }, + { + "asserts": false, + "id": "type:packages/host/src/models.ts:82:29#1", + "kind": "predicate", + "parameter": "value", + "type": "type:packages/host/src/models.ts:82:38#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:82:38#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "asserts": false, + "id": "type:packages/host/src/models.ts:83:15#1", + "kind": "predicate", + "parameter": "this", + "type": "type:packages/host/src/models.ts:83:23#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:83:23#1", + "kind": "reference", + "name": "Guards", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Guards", + }, + }, + { + "id": "type:packages/host/src/models.ts:84:23#1", + "kind": "keyword", + "name": "unknown", + }, + { + "asserts": true, + "id": "type:packages/host/src/models.ts:84:33#1", + "kind": "predicate", + "parameter": "value", + "type": "type:packages/host/src/models.ts:84:50#1", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:84:50#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/models.ts:85:24#1", + "kind": "keyword", + "name": "unknown", + }, + { + "asserts": true, + "id": "type:packages/host/src/models.ts:85:34#1", + "kind": "predicate", + "parameter": "value", + }, + { + "id": "type:packages/host/src/models.ts:86:13#1", + "kind": "this", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:90:49#1", + "kind": "reference", + "name": "Entity", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + }, + { + "id": "type:packages/host/src/models.ts:91:25#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [ + "type:packages/host/src/models.ts:95:40#1", + ], + "id": "type:packages/host/src/models.ts:95:36#1", + "kind": "reference", + "name": "Box", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + }, + { + "id": "type:packages/host/src/models.ts:95:40#1", + "kind": "keyword", + "name": "string", + }, + { + "arguments": [], + "id": "type:packages/host/src/models.ts:96:19#1", + "kind": "reference", + "name": "Recursive", + "target": { + "kind": "declaration", + "symbol": "@fixture/host:packages/host/src/models.ts#Recursive", + }, + }, + ], + }, + "packages": [ + { + "events": [ + { + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 120, + }, + "name": "demo/property", + "signature": "type:packages/host/src/index.ts:120:22#1", + "tags": [], + "text": "'demo/property': (payload: Payload) => void", + }, + { + "description": "A generic fixture event.", + "jsDoc": "/** + * A generic fixture event. + * @param agent - emitting agent. + * @param payload - event payload. + * @mode emit + */", + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 116, + }, + "mode": "emit", + "name": "demo/ready", + "signature": "type:packages/host/src/index.ts:116:5#1", + "summary": "A generic fixture event.", + "tags": [ + { + "argument": "agent", + "comment": "- emitting agent.", + "name": "param", + "text": "@param agent - emitting agent. + *", + }, + { + "argument": "payload", + "comment": "- event payload.", + "name": "param", + "text": "@param payload - event payload. + *", + }, + { + "comment": "emit", + "name": "mode", + "text": "@mode emit", + }, + ], + "text": "'demo/ready'(agent: Agent<{ ready: true }>, payload: Box): void", + }, + { + "jsDoc": "/** @mode serial */", + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 123, + }, + "mode": "serial", + "name": "demo/serial-property", + "signature": "type:packages/host/src/index.ts:123:29#1", + "tags": [ + { + "comment": "serial", + "name": "mode", + "text": "@mode serial", + }, + ], + "text": "'demo/serial-property': (payload: Payload) => void", + }, + { + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 118, + }, + "name": "demo/unmodeled", + "signature": "type:packages/host/src/index.ts:118:5#1", + "tags": [], + "text": "'demo/unmodeled'(): void", + }, + ], + "exports": [ + { + "aliases": [ + "Agent", + ], + "name": "Agent", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + { + "aliases": [ + "AgentPhase", + ], + "name": "AgentPhase", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#AgentPhase", + }, + { + "aliases": [ + "Box", + ], + "name": "Box", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + { + "aliases": [ + "default", + "DefaultOnlyService", + ], + "name": "default", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", + }, + { + "aliases": [ + "DemoService", + ], + "name": "DemoService", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#DemoService", + }, + { + "aliases": [ + "Entity", + ], + "name": "Entity", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + { + "aliases": [ + "Flags", + ], + "name": "Flags", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Flags", + }, + { + "aliases": [ + "HostAgent", + "Agent", + ], + "name": "HostAgent", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + { + "aliases": [ + "Payload", + ], + "name": "Payload", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + { + "aliases": [ + "Present", + ], + "name": "Present", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Present", + }, + { + "aliases": [ + "PublicAliasedService", + "AliasedService", + ], + "name": "PublicAliasedService", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#AliasedService", + }, + { + "aliases": [ + "AbstractEntity", + ], + "name": "AbstractEntity", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#AbstractEntity", + }, + { + "aliases": [ + "Added", + ], + "name": "Added", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Added", + }, + { + "aliases": [ + "AgentPhase", + ], + "name": "AgentPhase", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#AgentPhase", + }, + { + "aliases": [ + "Box", + ], + "name": "Box", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + { + "aliases": [ + "Callable", + ], + "name": "Callable", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Callable", + }, + { + "aliases": [ + "Entity", + ], + "name": "Entity", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Entity", + }, + { + "aliases": [ + "Flags", + ], + "name": "Flags", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Flags", + }, + { + "aliases": [ + "genericFactory", + ], + "name": "genericFactory", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#genericFactory", + }, + { + "aliases": [ + "Guards", + ], + "name": "Guards", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Guards", + }, + { + "aliases": [ + "Payload", + ], + "name": "Payload", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + { + "aliases": [ + "phaseOrder", + ], + "name": "phaseOrder", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#phaseOrder", + }, + { + "aliases": [ + "PlainMap", + ], + "name": "PlainMap", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#PlainMap", + }, + { + "aliases": [ + "Present", + ], + "name": "Present", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Present", + }, + { + "aliases": [ + "Recursive", + ], + "name": "Recursive", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Recursive", + }, + { + "aliases": [ + "Remapped", + ], + "name": "Remapped", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Remapped", + }, + { + "aliases": [ + "Result", + ], + "name": "Result", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Result", + }, + { + "aliases": [ + "Route", + ], + "name": "Route", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Route", + }, + { + "aliases": [ + "StringResult", + ], + "name": "StringResult", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#StringResult", + }, + { + "aliases": [ + "SyntaxZoo", + ], + "name": "SyntaxZoo", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#SyntaxZoo", + }, + { + "aliases": [ + "TagOnly", + ], + "name": "TagOnly", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#TagOnly", + }, + { + "aliases": [ + "Topic", + ], + "name": "Topic", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Topic", + }, + { + "aliases": [ + "Unpunctuated", + ], + "name": "Unpunctuated", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Unpunctuated", + }, + { + "aliases": [ + "Variance", + ], + "name": "Variance", + "subpath": "./models", + "symbol": "@fixture/host:packages/host/src/models.ts#Variance", + }, + ], + "name": "@fixture/host", + "objects": [ + { + "description": "Reference-passed capability object.", + "export": { + "aliases": [ + "Agent", + ], + "name": "Agent", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + }, + "jsDoc": "/** + * Reference-passed capability object. + * @typert object + */", + "passing": "reference", + "summary": "Reference-passed capability object.", + "symbol": "@fixture/host:packages/host/src/index.ts#Agent", + "tags": [ + { + "comment": "object", + "name": "typert", + "text": "@typert object", + }, + ], + }, + ], + "root": "packages/host", + "schemas": [ + { + "description": "Runtime-validating data root.", + "export": { + "aliases": [ + "Payload", + ], + "name": "Payload", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + }, + "jsDoc": "/** Runtime-validating data root. @typert schema */", + "summary": "Runtime-validating data root.", + "symbol": "@fixture/host:packages/host/src/models.ts#Payload", + "tags": [ + { + "comment": "schema", + "name": "typert", + "text": "@typert schema", + }, + ], + "type": "type:packages/host/src/models.ts:28:1#1", + }, + ], + "services": [ + { + "description": "Service exported only through a non-default alias.", + "export": { + "aliases": [ + "PublicAliasedService", + "AliasedService", + ], + "name": "PublicAliasedService", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#AliasedService", + }, + "jsDoc": "/** Service exported only through a non-default alias. */", + "key": "aliased", + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 101, + }, + "members": [ + "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + ], + "summary": "Service exported only through a non-default alias.", + "symbol": "@fixture/host:packages/host/src/index.ts#AliasedService", + "tags": [], + }, + { + "description": "Service exported only through the package default.", + "export": { + "aliases": [ + "default", + "DefaultOnlyService", + ], + "name": "default", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", + }, + "jsDoc": "/** Service exported only through the package default. */", + "key": "defaultOnly", + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 102, + }, + "members": [ + "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + ], + "summary": "Service exported only through the package default.", + "symbol": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", + "tags": [], + }, + { + "description": "Fixture service with generic, mapped, and truly external boundary types.", + "export": { + "aliases": [ + "DemoService", + ], + "name": "DemoService", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/index.ts#DemoService", + }, + "jsDoc": "/** Fixture service with generic, mapped, and truly external boundary types. */", + "key": "demo", + "location": { + "column": 5, + "file": "packages/host/src/index.ts", + "line": 100, + }, + "members": [ + "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", + "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", + "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", + "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + ], + "summary": "Fixture service with generic, mapped, and truly external boundary types.", + "symbol": "@fixture/host:packages/host/src/index.ts#DemoService", + "tags": [], + }, + ], + }, + ], + }, + { + "face": "client", + "graph": { + "declarations": [ + { + "abstract": false, + "description": "Client-owned inheritance preserves an explicit generic cross-face edge.", + "exported": true, + "extends": [ + "type:packages/client/src/index.ts:11:38#1", + ], + "id": "@fixture/client:packages/client/src/index.ts#ClientAgent", + "implements": [], + "jsDoc": "/** Client-owned inheritance preserves an explicit generic cross-face edge. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/client/src/index.ts", + "line": 11, + }, + "members": [], + "name": "ClientAgent", + "package": "@fixture/client", + "summary": "Client-owned inheritance preserves an explicit generic cross-face edge.", + "tags": [], + "text": "export interface ClientAgent extends HostAgent<{ + ready: true; +}> { +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Client-face service.", + "exported": true, + "extends": [ + "type:packages/client/src/index.ts:26:35#1", + ], + "id": "@fixture/client:packages/client/src/index.ts#ClientBridge", + "implements": [], + "jsDoc": "/** Client-face service. */", + "kind": "class", + "location": { + "column": 1, + "file": "packages/client/src/index.ts", + "line": 26, + }, + "members": [ + { + "abstract": false, + "async": false, + "description": "Return the host-owned object unchanged.", + "id": "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + "jsDoc": "/** Return the host-owned object unchanged. */", + "kind": "method", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 28, + }, + "name": "reflect", + "optional": false, + "readonly": false, + "signature": { + "parameters": [ + { + "binding": "identifier", + "name": "view", + "optional": false, + "receiver": false, + "rest": false, + "type": "type:packages/client/src/index.ts:28:17#1", + }, + ], + "returns": "type:packages/client/src/index.ts:28:30#1", + "typeParameters": [], + }, + "static": false, + "summary": "Return the host-owned object unchanged.", + "tags": [], + "text": "reflect(view: ClientView): HostAgent<{ ready: true }>", + "visibility": "public", + }, + ], + "name": "ClientBridge", + "package": "@fixture/client", + "summary": "Client-face service.", + "tags": [], + "text": "export class ClientBridge extends Service { + reflect(view: ClientView): HostAgent<{ + ready: true; + }>; +}", + "typeParameters": [], + }, + { + "abstract": false, + "description": "Client-owned view with explicit references to host exports.", + "exported": true, + "extends": [], + "id": "@fixture/client:packages/client/src/index.ts#ClientView", + "implements": [], + "jsDoc": "/** Client-owned view with explicit references to host exports. */", + "kind": "interface", + "location": { + "column": 1, + "file": "packages/client/src/index.ts", + "line": 14, + }, + "members": [ + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#agent@587", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 15, + }, + "name": "agent", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly agent: HostAgent<{ ready: true }>", + "type": "type:packages/client/src/index.ts:15:19#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#inherited@632", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 16, + }, + "name": "inherited", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly inherited: ClientAgent", + "type": "type:packages/client/src/index.ts:16:23#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgent@666", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 17, + }, + "name": "importedAgent", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly importedAgent: import('@fixture/host').Agent<{ ready: true }>", + "type": "type:packages/client/src/index.ts:17:27#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgentWithNamedArgument@739", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 18, + }, + "name": "importedAgentWithNamedArgument", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly importedAgentWithNamedArgument: import('@fixture/host').Agent", + "type": "type:packages/client/src/index.ts:18:44#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#namespaceAgent@821", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 19, + }, + "name": "namespaceAgent", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly namespaceAgent: Host.Agent<{ ready: true }>", + "type": "type:packages/client/src/index.ts:19:28#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#defaultService@876", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 20, + }, + "name": "defaultService", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly defaultService: HostDefault", + "type": "type:packages/client/src/index.ts:20:28#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#payload@915", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 21, + }, + "name": "payload", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly payload: Payload", + "type": "type:packages/client/src/index.ts:21:21#1", + "visibility": "public", + }, + { + "abstract": false, + "async": false, + "id": "@fixture/client:packages/client/src/index.ts#ClientView#phase@943", + "kind": "property", + "location": { + "column": 3, + "file": "packages/client/src/index.ts", + "line": 22, + }, + "name": "phase", + "optional": false, + "readonly": true, + "static": false, + "tags": [], + "text": "readonly phase: AgentPhase", + "type": "type:packages/client/src/index.ts:22:19#1", + "visibility": "public", + }, + ], + "name": "ClientView", + "package": "@fixture/client", + "summary": "Client-owned view with explicit references to host exports.", + "tags": [], + "text": "export interface ClientView { + readonly agent: HostAgent<{ + ready: true; + }>; + readonly inherited: ClientAgent; + readonly importedAgent: import('@fixture/host').Agent<{ + ready: true; + }>; + readonly importedAgentWithNamedArgument: import('@fixture/host').Agent; + readonly namespaceAgent: Host.Agent<{ + ready: true; + }>; + readonly defaultService: HostDefault; + readonly payload: Payload; + readonly phase: AgentPhase; +}", + "typeParameters": [], + }, + ], + "nodes": [ + { + "arguments": [ + "type:packages/client/src/index.ts:11:48#1", + ], + "id": "type:packages/client/src/index.ts:11:38#1", + "kind": "reference", + "name": "HostAgent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "HostAgent", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "id": "type:packages/client/src/index.ts:11:48#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/client/src/index.ts:11:48#1#ready@469", + "kind": "property", + "location": { + "column": 50, + "file": "packages/client/src/index.ts", + "line": 11, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/client/src/index.ts:11:57#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/client/src/index.ts:11:57#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "arguments": [ + "type:packages/client/src/index.ts:15:29#1", + ], + "id": "type:packages/client/src/index.ts:15:19#1", + "kind": "reference", + "name": "HostAgent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "HostAgent", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "id": "type:packages/client/src/index.ts:15:29#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/client/src/index.ts:15:29#1#ready@615", + "kind": "property", + "location": { + "column": 31, + "file": "packages/client/src/index.ts", + "line": 15, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/client/src/index.ts:15:38#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/client/src/index.ts:15:38#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:16:23#1", + "kind": "reference", + "name": "ClientAgent", + "target": { + "kind": "declaration", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientAgent", + }, + }, + { + "arguments": [ + "type:packages/client/src/index.ts:17:57#1", + ], + "id": "type:packages/client/src/index.ts:17:27#1", + "kind": "import-type", + "module": "@fixture/host", + "qualifier": "Agent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "Agent", + "package": "@fixture/host", + "subpath": ".", + }, + "typeof": false, + }, + { + "id": "type:packages/client/src/index.ts:17:57#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/client/src/index.ts:17:57#1#ready@722", + "kind": "property", + "location": { + "column": 59, + "file": "packages/client/src/index.ts", + "line": 17, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/client/src/index.ts:17:66#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/client/src/index.ts:17:66#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "arguments": [ + "type:packages/client/src/index.ts:18:74#1", + ], + "id": "type:packages/client/src/index.ts:18:44#1", + "kind": "import-type", + "module": "@fixture/host", + "qualifier": "Agent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "Agent", + "package": "@fixture/host", + "subpath": ".", + }, + "typeof": false, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:18:74#1", + "kind": "reference", + "name": "Payload", + "target": { + "face": "host", + "kind": "cross-face", + "name": "Payload", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "arguments": [ + "type:packages/client/src/index.ts:19:39#1", + ], + "id": "type:packages/client/src/index.ts:19:28#1", + "kind": "reference", + "name": "Host.Agent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "Agent", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "id": "type:packages/client/src/index.ts:19:39#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/client/src/index.ts:19:39#1#ready@859", + "kind": "property", + "location": { + "column": 41, + "file": "packages/client/src/index.ts", + "line": 19, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/client/src/index.ts:19:48#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/client/src/index.ts:19:48#1", + "kind": "literal", + "text": "true", + "value": true, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:20:28#1", + "kind": "reference", + "name": "HostDefault", + "target": { + "face": "host", + "kind": "cross-face", + "name": "default", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:21:21#1", + "kind": "reference", + "name": "Payload", + "target": { + "face": "host", + "kind": "cross-face", + "name": "Payload", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:22:19#1", + "kind": "reference", + "name": "AgentPhase", + "target": { + "face": "host", + "kind": "cross-face", + "name": "AgentPhase", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:26:35#1", + "kind": "reference", + "name": "Service", + "target": { + "kind": "external", + "module": "cordis", + "name": "Service", + "subpath": ".", + }, + }, + { + "arguments": [], + "id": "type:packages/client/src/index.ts:28:17#1", + "kind": "reference", + "name": "ClientView", + "target": { + "kind": "declaration", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientView", + }, + }, + { + "arguments": [ + "type:packages/client/src/index.ts:28:40#1", + ], + "id": "type:packages/client/src/index.ts:28:30#1", + "kind": "reference", + "name": "HostAgent", + "target": { + "face": "host", + "kind": "cross-face", + "name": "HostAgent", + "package": "@fixture/host", + "subpath": ".", + }, + }, + { + "id": "type:packages/client/src/index.ts:28:40#1", + "kind": "object", + "members": [ + { + "abstract": false, + "async": false, + "id": "type:packages/client/src/index.ts:28:40#1#ready@1135", + "kind": "property", + "location": { + "column": 42, + "file": "packages/client/src/index.ts", + "line": 28, + }, + "name": "ready", + "optional": false, + "readonly": false, + "static": false, + "tags": [], + "text": "ready: true", + "type": "type:packages/client/src/index.ts:28:49#1", + "visibility": "public", + }, + ], + }, + { + "id": "type:packages/client/src/index.ts:28:49#1", + "kind": "literal", + "text": "true", + "value": true, + }, + ], + }, + "packages": [ + { + "events": [], + "exports": [ + { + "aliases": [ + "ClientAgent", + ], + "name": "ClientAgent", + "subpath": ".", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientAgent", + }, + { + "aliases": [ + "ClientBridge", + ], + "name": "ClientBridge", + "subpath": ".", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", + }, + { + "aliases": [ + "ClientView", + ], + "name": "ClientView", + "subpath": ".", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientView", + }, + { + "aliases": [ + "default", + "ClientBridge", + ], + "name": "default", + "subpath": ".", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", + }, + { + "aliases": [ + "ReexportedBox", + "Box", + ], + "name": "ReexportedBox", + "subpath": ".", + "symbol": "@fixture/host:packages/host/src/models.ts#Box", + }, + { + "aliases": [ + "ReexportedZodType", + "ZodType", + ], + "name": "ReexportedZodType", + "subpath": ".", + "symbol": ":../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType", + }, + ], + "name": "@fixture/client", + "objects": [], + "root": "packages/client", + "schemas": [], + "services": [ + { + "description": "Client-face service.", + "export": { + "aliases": [ + "ClientBridge", + ], + "name": "ClientBridge", + "subpath": ".", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", + }, + "jsDoc": "/** Client-face service. */", + "key": "clientBridge", + "location": { + "column": 5, + "file": "packages/client/src/index.ts", + "line": 35, + }, + "members": [ + "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + ], + "summary": "Client-face service.", + "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", + "tags": [], + }, + ], + }, + ], + }, + ], +} +`; diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts similarity index 82% rename from packages/core/agent/tests/gen-cordis-catalog.spec.ts rename to packages/typert/generator/tests/cordis-catalog-contract.spec.ts index 23c558bd95..4092ac7e63 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts @@ -1,5 +1,5 @@ /** - * Contract and negative-path tests for the cordis catalog generator + * Model-extraction and negative-path contracts for the Cordis catalog generator * (`scripts/gen-cordis-catalog.ts`). */ @@ -7,16 +7,91 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts' +import { + collectEvents as collectEventsWithPolicy, + collectServices as collectServicesWithPolicy, + renderEvents as renderEventsWithPolicy, + renderServices as renderServicesWithPolicy, +} from '../src/cordis-catalog.ts' +import type { + CordisCatalogPolicy, + EventEntry, + ServiceEntry, +} from '../src/cordis-catalog.ts' + +const TEST_POLICY: CordisCatalogPolicy = { + linkedTypePages: { SessionEvent: 'core.md' }, + foundationTypeNames: new Set(['AbortSignal', 'Promise', 'Readonly']), + typeLinkExemptions: { PresetSpec: 'fixture deployment metadata' }, + inheritedEvents: [], + inheritedServices: [], +} + +function collectEvents(root: string): EventEntry[] { + return collectEventsWithPolicy(root, TEST_POLICY) +} + +function collectServices(root: string): ServiceEntry[] { + return collectServicesWithPolicy(root, TEST_POLICY) +} + +function renderEvents(events: EventEntry[]): string { + return renderEventsWithPolicy(events, TEST_POLICY) +} + +function renderServices(services: ServiceEntry[]): string { + return renderServicesWithPolicy(services, TEST_POLICY) +} + +const TYPE_FIXTURES = [ + 'export interface FixtureEntry {}', + 'interface SessionEvent {}', + 'interface PresetSpec {}', + 'interface MissingOne {}', + 'type missingTwo = string', + 'interface MissingServiceType {}', + '', +].join('\n') + +/** Materialize one independently compilable package and its host aggregate. */ +function writeProject(root: string, source: string): void { + const packageRoot = join(root, 'packages', 'group', 'fix') + const sourceRoot = join(packageRoot, 'src') + mkdirSync(sourceRoot, { recursive: true }) + writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ + files: [], + references: [{ path: './packages/group/fix' }], + })) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/fix', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + })) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + composite: true, + module: 'ESNext', + moduleResolution: 'Bundler', + rootDir: 'src', + target: 'ES2022', + }, + include: ['src'], + })) + writeFileSync(join(sourceRoot, 'index.ts'), `${TYPE_FIXTURES}${source}`) +} /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ function fixtureRoot(eventsBlock: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) - const dir = join(root, 'packages', 'group', 'fix', 'src') - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'index.ts'), + writeProject( + root, `declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, ) return root @@ -27,10 +102,8 @@ function fixtureRoot(eventsBlock: string): string { * `collectServices`. */ function serviceFixtureRoot(classSource: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) - const dir = join(root, 'packages', 'group', 'fix', 'src') - mkdirSync(dir, { recursive: true }) - writeFileSync( - join(dir, 'index.ts'), + writeProject( + root, `declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, ) return root @@ -95,9 +168,9 @@ describe('gen-cordis-catalog collectEvents', () => { 'fix/two', 'packages/group/fix/src/index.ts', 'missingTwo', - 'Add it to LINK_MAP', - 'FOUNDATION_TYPE_NAMES', - 'TYPE_LINK_EXEMPTIONS', + 'Add it to linkedTypePages', + 'foundationTypeNames', + 'typeLinkExemptions', ].join('[\\s\\S]*')) expect(() => collectEvents(make( ' /**\n * First.\n * @param value - first value.\n * @mode emit\n */\n \'fix/one\'(value: MissingOne): void\n /**\n * Second.\n * @param value - second value.\n * @mode emit\n */\n \'fix/two\'(value: missingTwo): void', @@ -222,7 +295,7 @@ export class FixService { it('hard-errors on an unannotated (inferred) return type', () => { expect(() => collectServices(makeService( '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}', - ))).toThrow(/no return type annotation/) + ))).toThrow(/missing an explicit type annotation/) }) it('hard-errors on a service class with no JSDoc', () => { diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts new file mode 100644 index 0000000000..6e6b93dfdc --- /dev/null +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + projectCordisCatalog, + renderEvents, + renderServices, +} from '../src/cordis-catalog.ts' +import { CORDIS_CATALOG_POLICY } from '../../../../scripts/gen-cordis-catalog.ts' + +const workspaceRoot = resolve(import.meta.dirname, '../../../..') + +describe('Typert-backed Cordis catalog', () => { + it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => { + const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY) + const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8') + + expect(renderEvents([...model.events], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/events.md')) + expect(renderServices([...model.services], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/services.md')) + expect(projector.renderRuntimeApi(model)).toBe( + expected('packages/cordis/tool-cordis/src/api-catalog.ts'), + ) + }) +}) diff --git a/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts new file mode 100644 index 0000000000..970e8a5dda --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts @@ -0,0 +1,7 @@ +declare module 'cordis' { + export class Service { protected readonly __service?: never } + + export interface Context {} + + export interface Events {} +} diff --git a/packages/typert/generator/tests/fixtures/type-model/package.json b/packages/typert/generator/tests/fixtures/type-model/package.json new file mode 100644 index 0000000000..9eba53f67c --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/workspace", + "private": true, + "type": "module" +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/client/package.json b/packages/typert/generator/tests/fixtures/type-model/packages/client/package.json new file mode 100644 index 0000000000..dbfdb7c141 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fixture/client", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client/typert": { + "types": "./lib/typert.client.d.ts", + "default": "./lib/typert.client.js" + } + }, + "files": [ + "lib/typert.client.js", + "lib/typert.client.d.ts" + ] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts new file mode 100644 index 0000000000..82080a344e --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts @@ -0,0 +1,39 @@ +import { Service } from 'cordis' +import type HostDefault from '@fixture/host' +import type * as Host from '@fixture/host' +import type { AgentPhase } from '@fixture/host' +import type { HostAgent, Payload } from '@fixture/host' + +export type { Box as ReexportedBox } from '@fixture/host' +export type { ZodType as ReexportedZodType } from 'zod' + +/** Client-owned inheritance preserves an explicit generic cross-face edge. */ +export interface ClientAgent extends HostAgent<{ ready: true }> {} + +/** Client-owned view with explicit references to host exports. */ +export interface ClientView { + readonly agent: HostAgent<{ ready: true }> + readonly inherited: ClientAgent + readonly importedAgent: import('@fixture/host').Agent<{ ready: true }> + readonly importedAgentWithNamedArgument: import('@fixture/host').Agent + readonly namespaceAgent: Host.Agent<{ ready: true }> + readonly defaultService: HostDefault + readonly payload: Payload + readonly phase: AgentPhase +} + +/** Client-face service. */ +export class ClientBridge extends Service { + /** Return the host-owned object unchanged. */ + reflect(view: ClientView): HostAgent<{ ready: true }> { + return view.agent + } +} + +declare module 'cordis' { + interface Context { + clientBridge: ClientBridge + } +} + +export default ClientBridge diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json b/packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json new file mode 100644 index 0000000000..a7bd818bf6 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../host" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/package.json b/packages/typert/generator/tests/fixtures/type-model/packages/host/package.json new file mode 100644 index 0000000000..3614889eb4 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/package.json @@ -0,0 +1,23 @@ +{ + "name": "@fixture/host", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./models": { + "types": "./lib/types/models.d.ts", + "default": "./lib/models.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + } + }, + "files": [ + "lib/typert.host.js", + "lib/typert.host.d.ts" + ] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts new file mode 100644 index 0000000000..bb73873699 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts @@ -0,0 +1,143 @@ +import { Service } from 'cordis' +import type { ZodType } from 'zod' +import type { AgentPhase, Box, Entity, Flags, Payload, Present, SyntaxZoo } from './models.ts' + +export { AgentPhase } from './models.ts' +export type { Box, Entity, Flags, Payload, Present } from './models.ts' + +/** + * Reference-passed capability object. + * @typert object + */ +export class Agent implements Entity { + static {} + static readonly kind: string = 'agent' + readonly id: string + state: State + protected readonly generation: number = 1 + private readonly secret: string = 'fixture' + + constructor(id: string, state: State) { + this.id = id + this.state = state + } + + /** Read the public display label. */ + get label(): string { + return this.id + } + + /** Accept a public display label. */ + set label(value: string) { + void value + } + + /** Run one typed input. */ + run(input: Box): Promise> { + return Promise.resolve(input.value as Present) + } +} + +export { Agent as HostAgent } + +/** Service exported only through a non-default alias. */ +class AliasedService extends Service { + /** Report readiness. */ + ready(): boolean { + return true + } +} + +export { AliasedService as PublicAliasedService } + +/** Service exported only through the package default. */ +class DefaultOnlyService extends Service { + /** Report readiness. */ + ready(): boolean { + return true + } +} + +/** Fixture service with generic, mapped, and truly external boundary types. */ +export class DemoService extends Service { + static readonly kind: string = 'demo' + protected readonly generation: number = 1 + private readonly secret: string = 'fixture' + + /** Inspect one agent without flattening its generic state. */ + inspect(agent: Agent<{ ready: true }>, flags: Flags): Present { + return { name: agent.id, count: Object.keys(flags).length } + } + + /** Keep an npm-owned type as External. */ + acceptsExternal(schema: ZodType): void { + void schema + } + + /** Accept a developer-authored enum without flattening it. */ + setPhase(phase: AgentPhase): void { + void phase + } + + /** Exercise every retained type-graph shape from a public boundary. */ + inspectSyntax(zoo: SyntaxZoo): void { + void zoo + } + + /** Preserve async source metadata without changing its type signature. */ + async inspectAsync(zoo: SyntaxZoo): Promise { + void zoo + } + + /** Retain an authored binding-pattern parameter. */ + destructure({ name }: Payload, [suffix]: [string]): string { + return name + suffix + } +} + +declare module 'cordis' { + interface Context { + demo: DemoService + aliased: AliasedService + defaultOnly: DefaultOnlyService + ignoredInline: {} + ignoredPrimitive: string + ignoredExternal: ZodType + ignoredMethod(): void + } + + interface Events { + /** + * A generic fixture event. + * @param agent - emitting agent. + * @param payload - event payload. + * @mode emit + */ + 'demo/ready'(agent: Agent<{ ready: true }>, payload: Box): void + + 'demo/unmodeled'(): void + + 'demo/property': (payload: Payload) => void + + /** @mode serial */ + 'demo/serial-property': (payload: Payload) => void + + (payload: Payload): void + } + + interface IgnoredInterface {} + + type IgnoredDeclaration = string +} + +declare module 'cordis' { + interface Context { + demo: DemoService + } + + interface Events { + 'demo/ready'(agent: Agent<{ ready: true }>, payload: Box): void + } +} + +export default DefaultOnlyService diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts new file mode 100644 index 0000000000..78994562d9 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts @@ -0,0 +1,160 @@ +/** Generic source form retained before conditional evaluation. */ +export interface Box { + /** The boxed value. */ + readonly value: T +} + +/** Conditional source form retained instead of its resolved instantiations. */ +export type Present = T extends null | undefined ? never : T + +/** Mapped source form retained instead of materialized properties. */ +export type Flags = { + readonly [K in keyof T]?: boolean +} + +/** Explicit base edge for reference-passed objects. */ +export interface Entity { + readonly id: string +} + +/** Developer-authored enum retained as a declaration. */ +export enum AgentPhase { + Unknown, + Idle = 'idle', + Running = 'running', +} + +/** Runtime-validating data root. @typert schema */ +export interface Payload { + name: string + count?: number +} + +/** Signature members represented without flattening their callable forms. */ +export interface Callable { + (value: string): number + new (value: string): Entity + readonly [key: string]: unknown +} + +/** Input, output, and invariant parameters retain authored variance. */ +export interface Variance { + consume: (input: Input) => void + readonly produce: () => Output + state: State +} + +/** Infer form nested inside a conditional type. */ +export type Result = Value extends (...arguments_: never[]) => infer Output ? Output : never + +/** Constrained infer form retained before conditional evaluation. */ +export type StringResult = Value extends readonly [infer Output extends string] ? Output : never + +/** Template-literal source form. */ +export type Topic = `demo/${Name}` + +/** Multiple template spans retain each authored suffix. */ +export type Route = `/${From}/to/${To}/end` + +/** Preserve mapped modifiers when none were authored. */ +export type PlainMap = { + [Key in keyof Value]: Value[Key] +} + +/** Retain key remapping and explicit modifier removal. */ +export type Remapped = { + -readonly [Key in keyof Value as `get${Capitalize}`]-?: Value[Key] +} + +/** Retain explicit mapped modifier addition. */ +export type Added = { + +readonly [Key in keyof Value]+?: Value[Key] +} + +/** Value used by a type query and indexed access. */ +export const phaseOrder = ['idle', 'running'] as const + +/** Generic value used by an instantiated type query. */ +export declare function genericFactory(): Value + +/** Predicates and the polymorphic this type remain signatures. */ +export interface Guards { + isEntity(value: unknown): value is Entity + isFluent(): this is Guards + assertEntity(value: unknown): asserts value is Entity + assertPresent(value: unknown): asserts value + fluent(): this +} + +/** Abstract declarations remain distinct from concrete classes. */ +export abstract class AbstractEntity implements Entity { + abstract readonly id: string +} + +/** Recursive declaration edges retain their declaration target. */ +export interface Recursive extends Box { + readonly next?: Recursive +} + +/** + * @deprecated + */ +export interface TagOnly { + readonly value: string +} + +/** Description without terminal punctuation */ +export interface Unpunctuated { + readonly value: string +} + +/** Every supported TypeNode shape is reachable from this declaration. */ +export interface SyntaxZoo { + anyValue: any + bigintValue: bigint + parenthesized: (Entity | null) + literals: 1 | 1n | -2 | -2n | false | `fixed` + readonly uniqueToken: unique symbol + intersection: Entity & { active: boolean } + array: string[] + tuple: [head: string, count?: number, ...tail: boolean[]] + unnamedTuple: [string?, ...number[]] + readonlyTuple: readonly [string, number] + object: { + readonly value?: string + 'quoted-name': number + 1: boolean + ['computed']: symbol + invoke?(input: number): void + } + callback: ( + this: Entity, + value: Value, + optional?: string, + ...rest: number[] + ) => Promise + constCallback: (value: Value) => Value + factory: new (value: Value) => Value + abstractFactory: abstract new (id: string) => AbstractEntity + indexed: Payload['name'] + inferred: Result<() => string> + constrainedInfer: StringResult<['value']> + topic: Topic<'ready'> + route: Route<'source', 'target'> + query: typeof phaseOrder + instantiatedQuery: typeof genericFactory + imported: import('zod').ZodType + importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType + importedModule: typeof import('zod') + process: NodeJS.Process + callable: Callable + guards: Guards + variance: Variance> + plainMap: PlainMap + remapped: Remapped + added: Added + abstractEntity: AbstractEntity + recursive: Recursive + tagOnly: TagOnly + unpunctuated: Unpunctuated +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json b/packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json new file mode 100644 index 0000000000..cfc5aa31ba --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/write/package.json b/packages/typert/generator/tests/fixtures/type-model/packages/write/package.json new file mode 100644 index 0000000000..73fd01b963 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/write/package.json @@ -0,0 +1,11 @@ +{ + "name": "@fixture/write", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + } + } +} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts new file mode 100644 index 0000000000..290e3944a3 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts @@ -0,0 +1,18 @@ +import { Service } from 'cordis' + +/** Service whose public annotations are intentionally absent. */ +export class WritableService extends Service { + value = 1 + + echo(input = 'value') { + return input + } +} + +declare module 'cordis' { + interface Context { + writable: WritableService + } +} + +export default WritableService diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json b/packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json new file mode 100644 index 0000000000..cfc5aa31ba --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json new file mode 100644 index 0000000000..3885bac238 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "composite": true, + "noEmit": true, + "baseUrl": ".", + "allowImportingTsExtensions": true, + "ignoreDeprecations": "6.0", + "types": ["node"], + "paths": { + "cordis": ["./cordis.d.ts"], + "@fixture/host": ["./packages/host/src/index.ts"], + "@fixture/host/*": ["./packages/host/src/*"], + "@fixture/client": ["./packages/client/src/index.ts"], + "@fixture/write": ["./packages/write/src/index.ts"] + }, + "skipLibCheck": true + } +} diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.client.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.client.json new file mode 100644 index 0000000000..a340c4b52c --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.client.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/client" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.host.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.host.json new file mode 100644 index 0000000000..905490d4f1 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.host.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/host" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.json new file mode 100644 index 0000000000..9a9766fcf4 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.base.json", + "files": ["cordis.d.ts"] +} diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.write.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.write.json new file mode 100644 index 0000000000..88608d3712 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.write.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/write" } + ] +} diff --git a/packages/typert/generator/tests/renderer.spec.ts b/packages/typert/generator/tests/renderer.spec.ts new file mode 100644 index 0000000000..ae79a1c30c --- /dev/null +++ b/packages/typert/generator/tests/renderer.spec.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import type { + KeywordTypeName, + MemberModel, + TypeDeclarationModel, + TypeGraph, + TypeNodeModel, +} from '../src/model.ts' +import { childTypeNodeIds } from '../src/model.ts' +import { TypeGraphRenderError, TypeGraphRenderer } from '../src/renderer.ts' + +const location = { file: 'fixture.ts', line: 1, column: 1 } as const +const documentation = { tags: [] } as const + +describe('TypeGraphRenderer defensive and optional shapes', () => { + it('enumerates direct child edges for every type node kind', () => { + const signature = { typeParameters: [], parameters: [], returns: 'leaf' } as const + const cases: readonly (readonly [TypeNodeModel, readonly string[]])[] = [ + [keyword('keyword', 'string'), []], + [{ id: 'literal', kind: 'literal', value: 1, text: '1' }, []], + [{ id: 'parenthesized', kind: 'parenthesized', type: 'leaf' }, ['leaf']], + [{ id: 'reference', kind: 'reference', name: 'Ref', target: { kind: 'standard', name: 'Ref' }, arguments: ['left', 'right'] }, ['left', 'right']], + [{ id: 'union', kind: 'union', types: ['left', 'right'] }, ['left', 'right']], + [{ id: 'intersection', kind: 'intersection', types: ['left', 'right'] }, ['left', 'right']], + [{ id: 'array', kind: 'array', element: 'leaf' }, ['leaf']], + [{ id: 'tuple', kind: 'tuple', elements: [{ type: 'leaf', optional: false, rest: false }] }, ['leaf']], + [{ id: 'object', kind: 'object', members: [] }, []], + [{ id: 'function', kind: 'function', signature }, []], + [{ id: 'constructor', kind: 'constructor', abstract: false, signature }, []], + [{ id: 'indexed', kind: 'indexed-access', object: 'left', index: 'right' }, ['left', 'right']], + [{ id: 'operator', kind: 'operator', operator: 'keyof', type: 'leaf' }, ['leaf']], + [{ id: 'conditional', kind: 'conditional', check: 'check', extends: 'extends', whenTrue: 'yes', whenFalse: 'no' }, ['check', 'extends', 'yes', 'no']], + [{ id: 'infer-full', kind: 'infer', parameter: { id: 'infer', name: 'Value', const: false, constraint: 'constraint', default: 'fallback' } }, ['constraint', 'fallback']], + [{ id: 'infer-empty', kind: 'infer', parameter: { id: 'infer', name: 'Value', const: false } }, []], + [{ id: 'mapped-full', kind: 'mapped', parameter: { id: 'key', name: 'Key', const: false, constraint: 'constraint', default: 'fallback' }, nameType: 'name', value: 'value', readonly: 'preserve', optional: 'preserve' }, ['constraint', 'fallback', 'name', 'value']], + [{ id: 'mapped-empty', kind: 'mapped', parameter: { id: 'key', name: 'Key', const: false }, readonly: 'preserve', optional: 'preserve' }, []], + [{ id: 'template', kind: 'template-literal', head: '', spans: [{ type: 'leaf', text: '' }] }, ['leaf']], + [{ id: 'query', kind: 'type-query', expression: 'value', arguments: ['leaf'] }, ['leaf']], + [{ id: 'import', kind: 'import-type', module: 'fixture', arguments: ['leaf'], typeof: false }, ['leaf']], + [{ id: 'predicate-full', kind: 'predicate', asserts: false, parameter: 'value', type: 'leaf' }, ['leaf']], + [{ id: 'predicate-empty', kind: 'predicate', asserts: true, parameter: 'value' }, []], + [{ id: 'this', kind: 'this' }, []], + ] + + for (const [node, expected] of cases) expect(childTypeNodeIds(node)).toEqual(expected) + }) + + it('renders optional source shapes and traverses every optional closure edge', () => { + const dependency = declaration('dependency', 'Dependency', 'interface') + const graph: TypeGraph = { + declarations: [ + dependency, + declaration('empty-enum', 'EmptyEnum', 'enum'), + declaration('root', 'Root', 'interface', { + members: [property('root-member', 'rootValue', 'imported')], + }), + ], + nodes: [ + keyword('string', 'string'), + { id: 'union', kind: 'union', types: ['string', 'string'] }, + { id: 'array', kind: 'array', element: 'union' }, + { + id: 'tuple', + kind: 'tuple', + elements: [ + { type: 'string', optional: false, rest: false }, + { type: 'string', optional: true, rest: false }, + { type: 'array-of-string', optional: false, rest: true }, + ], + }, + { id: 'array-of-string', kind: 'array', element: 'string' }, + { + id: 'mapped', + kind: 'mapped', + parameter: { + id: 'key', + name: 'Key', + const: false, + constraint: 'string', + default: 'string', + }, + readonly: 'preserve', + optional: 'preserve', + }, + { + id: 'infer', + kind: 'infer', + parameter: { + id: 'inferred', + name: 'Value', + const: false, + constraint: 'string', + default: 'string', + }, + }, + { + id: 'imported', + kind: 'import-type', + module: '@fixture/dependency', + qualifier: 'Dependency', + arguments: ['mapped', 'infer'], + typeof: false, + target: { kind: 'declaration', symbol: 'dependency' }, + }, + { id: 'empty-object', kind: 'object', members: [] }, + ], + } + const renderer = new TypeGraphRenderer(graph) + + expect(renderer.renderType('array')).toBe('(string | string)[]') + expect(renderer.renderType('tuple')).toBe('[string, string?, ...string[]]') + expect(renderer.renderType('mapped')).toBe('{ [Key in string]: unknown }') + expect(renderer.renderType('empty-object')).toBe('{}') + expect(renderer.renderDeclaration('empty-enum')).toBe('export enum EmptyEnum {\n}') + expect(renderer.declarationClosureForMembers(['root-member']).map(item => item.name)) + .toEqual(['Dependency']) + }) + + it('fails loudly for every broken graph edge and impossible discriminant', () => { + const missingConstraint: TypeNodeModel = { + id: 'mapped', + kind: 'mapped', + parameter: { id: 'key', name: 'Key', const: false }, + readonly: 'preserve', + optional: 'preserve', + } + const alias = declaration('alias', 'Alias', 'alias') + const renderer = new TypeGraphRenderer({ + declarations: [alias], + nodes: [missingConstraint], + }) + + expect(() => renderer.node('missing')).toThrow(TypeGraphRenderError) + expect(() => renderer.declaration('missing')).toThrow('missing declaration') + expect(() => renderer.member('missing')).toThrow('missing member') + expect(renderer.declarationClosureForTypes(['mapped'])).toEqual([]) + expect(() => renderer.renderType('mapped')).toThrow('has no constraint') + expect(() => renderer.renderDeclaration('alias')).toThrow('has no type node') + + const invalidNode = { id: 'invalid', kind: 'future-node' } as unknown as TypeNodeModel + const invalidMember = { + ...property('invalid-member', 'value', 'mapped'), + kind: 'future-member', + } as unknown as MemberModel + const invalidRenderer = new TypeGraphRenderer({ + declarations: [declaration('invalid-root', 'InvalidRoot', 'interface', { members: [invalidMember] })], + nodes: [invalidNode], + }) + expect(() => invalidRenderer.renderType('invalid')).toThrow('unsupported model variant') + expect(() => invalidRenderer.renderMember(invalidMember)).toThrow('unsupported model variant') + expect(() => invalidRenderer.declarationClosureForTypes(['invalid'])).toThrow('unsupported model variant') + }) +}) + +function keyword(id: string, name: KeywordTypeName): TypeNodeModel { + return { id, kind: 'keyword', name } +} + +function property(id: string, name: string, type: string): MemberModel { + return { + ...documentation, + id, + kind: 'property', + name, + type, + optional: false, + readonly: false, + async: false, + abstract: false, + static: false, + visibility: 'public', + location, + text: `${name}: unknown`, + } +} + +function declaration( + id: string, + name: string, + kind: TypeDeclarationModel['kind'], + options: { readonly members?: readonly MemberModel[] } = {}, +): TypeDeclarationModel { + return { + ...documentation, + id, + package: '@fixture/renderer', + name, + kind, + abstract: false, + exported: true, + location, + text: `export ${kind === 'alias' ? 'type' : kind} ${name}`, + typeParameters: [], + extends: [], + implements: [], + members: options.members ?? [], + } +} diff --git a/packages/typert/generator/tests/schema-emitter.spec.ts b/packages/typert/generator/tests/schema-emitter.spec.ts new file mode 100644 index 0000000000..7457c4b85f --- /dev/null +++ b/packages/typert/generator/tests/schema-emitter.spec.ts @@ -0,0 +1,730 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { FaceModelEmitter, TypertEmitError } from '../src/emitter.ts' +import type { + FaceModel, + KeywordTypeName, + MemberModel, + SignatureModel, + TypeDeclarationModel, + TypeNodeModel, +} from '../src/model.ts' + +const temporaryRoots: string[] = [] +const location = { file: 'fixture.ts', line: 1, column: 1 } as const +const documentation = { tags: [] } as const + +const ZOD_NODE_SUPPORT = { + keyword: 'supported', + literal: 'supported', + parenthesized: 'supported', + reference: 'supported', + union: 'supported', + intersection: 'supported', + array: 'supported', + tuple: 'supported', + object: 'supported', + function: 'unsupported', + constructor: 'unsupported', + 'indexed-access': 'unsupported', + operator: 'unsupported', + conditional: 'unsupported', + infer: 'unsupported', + mapped: 'unsupported', + 'template-literal': 'unsupported', + 'type-query': 'unsupported', + 'import-type': 'unsupported', + predicate: 'unsupported', + this: 'unsupported', +} as const satisfies Record + +interface SchemaCase { + readonly name: string + readonly nodes: readonly TypeNodeModel[] + readonly accepted: readonly unknown[] + readonly rejected: readonly unknown[] +} + +const supportedCases: readonly SchemaCase[] = [ + keywordCase('any', [undefined], []), + keywordCase('unknown', [{ arbitrary: true }], []), + keywordCase('never', [], [undefined]), + keywordCase('string', ['value'], [1]), + keywordCase('number', [1], ['1']), + keywordCase('bigint', [1n], [1]), + keywordCase('boolean', [true], ['true']), + keywordCase('symbol', [Symbol('value')], ['symbol']), + keywordCase('undefined', [undefined], [null]), + keywordCase('void', [undefined], [null]), + keywordCase('object', [{ value: true }, [], () => undefined], [null, 1]), + { + name: 'literal', + nodes: [{ id: 'root', kind: 'literal', value: 'ready', text: "'ready'" }], + accepted: ['ready'], + rejected: ['waiting'], + }, + { + name: 'numeric literal', + nodes: [{ id: 'root', kind: 'literal', value: -2, text: '-2' }], + accepted: [-2], + rejected: [2], + }, + { + name: 'bigint literal', + nodes: [{ id: 'root', kind: 'literal', value: -2n, text: '-2n' }], + accepted: [-2n], + rejected: [-2], + }, + { + name: 'boolean literal', + nodes: [{ id: 'root', kind: 'literal', value: false, text: 'false' }], + accepted: [false], + rejected: [true], + }, + { + name: 'null literal', + nodes: [{ id: 'root', kind: 'literal', value: null, text: 'null' }], + accepted: [null], + rejected: [undefined], + }, + { + name: 'no-substitution template literal', + nodes: [{ id: 'root', kind: 'literal', value: 'fixed', text: '`fixed`' }], + accepted: ['fixed'], + rejected: ['other'], + }, + { + name: 'parenthesized', + nodes: [ + { id: 'root', kind: 'parenthesized', type: 'child' }, + keyword('child', 'string'), + ], + accepted: ['value'], + rejected: [1], + }, + { + name: 'standard reference', + nodes: [{ + id: 'root', + kind: 'reference', + name: 'Date', + target: { kind: 'standard', name: 'Date' }, + arguments: [], + }], + accepted: [new Date(0)], + rejected: ['1970-01-01'], + }, + { + name: 'standard Array reference', + nodes: [ + { id: 'root', kind: 'reference', name: 'Array', target: { kind: 'standard', name: 'Array' }, arguments: ['element'] }, + keyword('element', 'string'), + ], + accepted: [['value']], + rejected: [[1]], + }, + { + name: 'standard ReadonlyArray reference', + nodes: [ + { + id: 'root', + kind: 'reference', + name: 'ReadonlyArray', + target: { kind: 'standard', name: 'ReadonlyArray' }, + arguments: ['element'], + }, + keyword('element', 'number'), + ], + accepted: [[1]], + rejected: [['1']], + }, + { + name: 'standard Record reference', + nodes: [ + { + id: 'root', + kind: 'reference', + name: 'Record', + target: { kind: 'standard', name: 'Record' }, + arguments: ['key', 'value'], + }, + keyword('key', 'string'), + keyword('value', 'number'), + ], + accepted: [{ one: 1 }], + rejected: [{ one: '1' }], + }, + { + name: 'union', + nodes: [ + { id: 'root', kind: 'union', types: ['left', 'right'] }, + keyword('left', 'string'), + keyword('right', 'number'), + ], + accepted: ['value', 1], + rejected: [true], + }, + { + name: 'empty union', + nodes: [{ id: 'root', kind: 'union', types: [] }], + accepted: [], + rejected: [undefined], + }, + { + name: 'single union', + nodes: [ + { id: 'root', kind: 'union', types: ['child'] }, + keyword('child', 'string'), + ], + accepted: ['value'], + rejected: [1], + }, + { + name: 'intersection', + nodes: [ + { id: 'root', kind: 'intersection', types: ['left', 'right'] }, + { id: 'left', kind: 'object', members: [property('name', 'string')] }, + { id: 'right', kind: 'object', members: [property('count', 'number')] }, + keyword('string', 'string'), + keyword('number', 'number'), + ], + accepted: [{ name: 'value', count: 1 }], + rejected: [{ name: 'value' }], + }, + { + name: 'empty intersection', + nodes: [{ id: 'root', kind: 'intersection', types: [] }], + accepted: [undefined, { value: true }], + rejected: [], + }, + { + name: 'array', + nodes: [ + { id: 'root', kind: 'array', element: 'element' }, + keyword('element', 'string'), + ], + accepted: [['one', 'two']], + rejected: [['one', 2]], + }, + { + name: 'tuple with optional and rest elements', + nodes: [ + { + id: 'root', + kind: 'tuple', + elements: [ + { name: 'head', type: 'string', optional: false, rest: false }, + { name: 'count', type: 'number', optional: true, rest: false }, + { name: 'tail', type: 'rest-array', optional: false, rest: true }, + ], + }, + keyword('string', 'string'), + keyword('number', 'number'), + { id: 'rest-array', kind: 'array', element: 'boolean' }, + keyword('boolean', 'boolean'), + ], + accepted: [['value'], ['value', 1, true, false]], + rejected: [[1], ['value', 1, 'false']], + }, + { + name: 'fixed tuple', + nodes: [ + { + id: 'root', + kind: 'tuple', + elements: [{ type: 'string', optional: false, rest: false }], + }, + keyword('string', 'string'), + ], + accepted: [['value']], + rejected: [[], [1]], + }, + { + name: 'tuple with standard reference rest', + nodes: [ + { + id: 'root', + kind: 'tuple', + elements: [{ type: 'rest', optional: false, rest: true }], + }, + { + id: 'rest', + kind: 'reference', + name: 'ReadonlyArray', + target: { kind: 'standard', name: 'ReadonlyArray' }, + arguments: ['string'], + }, + keyword('string', 'string'), + ], + accepted: [[], ['value']], + rejected: [[1]], + }, + { + name: 'object', + nodes: [ + { + id: 'root', + kind: 'object', + members: [ + property('name', 'string', { readonly: true }), + property('count', 'number', { optional: true }), + ], + }, + keyword('string', 'string'), + keyword('number', 'number'), + ], + accepted: [{ name: 'value' }, { name: 'value', count: 1 }], + rejected: [{ name: 1 }], + }, +] + +const unsupportedNodeCases: readonly { readonly kind: TypeNodeModel['kind']; readonly nodes: readonly TypeNodeModel[] }[] = [ + { kind: 'function', nodes: [{ id: 'root', kind: 'function', signature: signature('child') }, keyword('child', 'string')] }, + { kind: 'constructor', nodes: [{ id: 'root', kind: 'constructor', abstract: false, signature: signature('child') }, keyword('child', 'string')] }, + { kind: 'indexed-access', nodes: [{ id: 'root', kind: 'indexed-access', object: 'child', index: 'child' }, keyword('child', 'string')] }, + { kind: 'operator', nodes: [{ id: 'root', kind: 'operator', operator: 'keyof', type: 'child' }, keyword('child', 'string')] }, + { + kind: 'conditional', + nodes: [{ id: 'root', kind: 'conditional', check: 'child', extends: 'child', whenTrue: 'child', whenFalse: 'child' }, keyword('child', 'string')], + }, + { kind: 'infer', nodes: [{ id: 'root', kind: 'infer', parameter: { id: 'parameter', name: 'Value', const: false } }] }, + { + kind: 'mapped', + nodes: [{ + id: 'root', + kind: 'mapped', + parameter: { id: 'parameter', name: 'Key', const: false, constraint: 'child' }, + value: 'child', + readonly: 'preserve', + optional: 'preserve', + }, keyword('child', 'string')], + }, + { + kind: 'template-literal', + nodes: [{ id: 'root', kind: 'template-literal', head: 'prefix-', spans: [{ type: 'child', text: '' }] }, keyword('child', 'string')], + }, + { kind: 'type-query', nodes: [{ id: 'root', kind: 'type-query', expression: 'value', arguments: [] }] }, + { kind: 'import-type', nodes: [{ id: 'root', kind: 'import-type', module: 'external', arguments: [], typeof: false }] }, + { kind: 'predicate', nodes: [{ id: 'root', kind: 'predicate', asserts: false, parameter: 'value', type: 'child' }, keyword('child', 'string')] }, + { kind: 'this', nodes: [{ id: 'root', kind: 'this' }] }, +] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('SchemaEmitter supported projection matrix', () => { + it.each(supportedCases)('$name', async ({ nodes, accepted, rejected }) => { + const schema = await loadSchema(emit(nodes)) + for (const value of accepted) expect(schema.safeParse(value).success).toBe(true) + for (const value of rejected) expect(schema.safeParse(value).success).toBe(false) + }) + + it('supports recursive declarations and inherited object shapes', async () => { + const recursive = declaration('Root', 'interface', { + members: [ + property('value', 'string'), + property('next', 'self', { optional: true }), + ], + }) + const recursiveSchema = await loadSchema(emit([ + keyword('string', 'string'), + { + id: 'self', + kind: 'reference', + name: 'Root', + target: { kind: 'declaration', symbol: 'Root' }, + arguments: [], + }, + ], recursive)) + expect(recursiveSchema.safeParse({ value: 'one', next: { value: 'two' } }).success).toBe(true) + expect(recursiveSchema.safeParse({ value: 'one', next: { value: 2 } }).success).toBe(false) + + const inherited = declaration('Root', 'interface', { + extends: ['base-reference'], + members: [property('current', 'number')], + }) + const base = declaration('Base', 'interface', { members: [property('base', 'string')] }) + const inheritedSchema = await loadSchema(emit([ + { id: 'base-reference', kind: 'reference', name: 'Base', target: { kind: 'declaration', symbol: 'Base' }, arguments: [] }, + keyword('string', 'string'), + keyword('number', 'number'), + ], inherited, [base])) + expect(inheritedSchema.safeParse({ base: 'value', current: 1 }).success).toBe(true) + expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false) + }) + + it('classifies every TypeNode kind and executes every supported kind', () => { + const expected = Object.entries(ZOD_NODE_SUPPORT) + .filter(([, support]) => support === 'supported') + .map(([kind]) => kind) + .sort() + expect(distinct(supportedCases.map(candidate => candidate.nodes[0]?.kind ?? 'missing'))).toEqual(expected) + }) +}) + +describe('SchemaEmitter unsupported projection matrix', () => { + it.each(unsupportedNodeCases)('rejects $kind nodes explicitly', ({ kind, nodes }) => { + expect(() => emit(nodes)).toThrow(new TypertEmitError( + `typert Zod emitter: root: type node ${kind} has no Zod projection`, + )) + }) + + it.each([ + ['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }], + ['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }], + ['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }], + ] as const)('rejects %s references explicitly', (kind, target) => { + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Value', + target, + arguments: [], + }])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`) + }) + + it('rejects unsupported standard references, generic declarations, and enums', () => { + const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel + expect(() => emit([intrinsic])) + .toThrow('keyword intrinsic has no Zod projection') + + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Promise', + target: { kind: 'standard', name: 'Promise' }, + arguments: [], + }])).toThrow('standard type Promise has no Zod projection') + + const generic = declaration('Generic', 'interface', { + typeParameters: [{ id: 'parameter', name: 'Value', const: false }], + }) + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Generic', + target: { kind: 'declaration', symbol: 'Generic' }, + arguments: [], + }], undefined, [generic])).toThrow('generic declarations require a schema-factory projection') + + const enumeration = declaration('Enumeration', 'enum', { + enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }], + }) + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Enumeration', + target: { kind: 'declaration', symbol: 'Enumeration' }, + arguments: [], + }], undefined, [enumeration])).toThrow('enum declarations have no Zod projection') + }) + + it('rejects incomplete collection references and invalid tuple rest types', () => { + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Array', + target: { kind: 'standard', name: 'Array' }, + arguments: [], + }])).toThrow('array reference has no element type') + + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Record', + target: { kind: 'standard', name: 'Record' }, + arguments: [keyword('key', 'string').id], + }, keyword('key', 'string')])).toThrow('Record requires key and value types') + + expect(() => emit([ + { id: 'root', kind: 'tuple', elements: [{ type: 'rest', optional: false, rest: true }] }, + { + id: 'rest', + kind: 'reference', + name: 'Array', + target: { kind: 'standard', name: 'Array' }, + arguments: [], + }, + ])).toThrow('tuple rest array has no element type') + + expect(() => emit([ + { id: 'root', kind: 'tuple', elements: [{ type: 'rest', optional: false, rest: true }] }, + keyword('rest', 'string'), + ])).toThrow('tuple rest element must retain an array type') + }) + + it('rejects incomplete schema roots and non-function event signatures', () => { + const incompleteAlias = declaration('Root', 'alias') + expect(() => emit([], incompleteAlias)).toThrow('alias has no modeled type') + + const missingSymbolFace = schemaFace([keyword('root', 'string')], 'missing') + expect(() => new FaceModelEmitter(missingSymbolFace).emit('@fixture/schema')) + .toThrow('referenced declaration is outside the selected schema closure') + + const eventFace: FaceModel = { + ...schemaFace([], 'Root', []), + graph: { declarations: [], nodes: [keyword('event', 'string')] }, + packages: [{ + name: '@fixture/schema', + root: '.', + exports: [], + services: [], + events: [{ + ...documentation, + name: 'fixture/event', + signature: 'event', + text: "'fixture/event'(): string", + location, + }], + objects: [], + schemas: [], + }], + } + expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema')) + .toThrow('event fixture/event is not a function type') + expect(() => new FaceModelEmitter(eventFace).emit('@fixture/missing')) + .toThrow('package @fixture/missing is not modeled') + }) + + it('emits undocumented events without an optional mode', () => { + const returns = keyword('returns', 'void') + const event: TypeNodeModel = { + id: 'event', + kind: 'function', + signature: { typeParameters: [], parameters: [], returns: 'returns' }, + } + const face: FaceModel = { + face: 'host', + graph: { declarations: [], nodes: [returns, event] }, + packages: [{ + name: '@fixture/events', + root: '.', + exports: [], + services: [], + events: [{ + ...documentation, + name: 'fixture/event', + signature: 'event', + text: "'fixture/event'(): void", + location, + }], + objects: [], + schemas: [], + }], + } + + const artifact = new FaceModelEmitter(face).emit('@fixture/events') + expect(artifact.js).toContain('"name": "fixture/event"') + expect(artifact.js).not.toContain('"mode"') + }) + + it('skips non-instance data members and emits collision-safe schema identifiers', async () => { + const hiddenMembers = declaration('Root', 'interface', { + members: [ + { ...property('static', 'string'), static: true }, + { ...property('private', 'string'), visibility: 'private' }, + ], + }) + const hiddenSchema = await loadSchema(emit([keyword('string', 'string')], hiddenMembers)) + expect(hiddenSchema.safeParse({ arbitrary: true }).success).toBe(true) + + const first = { ...declaration('first', 'interface'), name: 'Same' } + const second = { ...declaration('second', 'interface'), name: 'Same' } + const face = schemaFace([ + { id: 'first-reference', kind: 'reference', name: 'Same', target: { kind: 'declaration', symbol: 'first' }, arguments: [] }, + { id: 'second-reference', kind: 'reference', name: 'Same', target: { kind: 'declaration', symbol: 'second' }, arguments: [] }, + ], 'first', [first, second]) + const packageModel = face.packages[0] + if (packageModel === undefined) throw new Error('schema face has no package') + const collisionFace: FaceModel = { + ...face, + packages: [{ + ...packageModel, + schemas: [ + { ...documentation, export: { subpath: '.', name: '1 bad', symbol: 'first', aliases: ['1 bad'] }, symbol: 'first', type: 'first-reference' }, + { ...documentation, export: { subpath: './secondary', name: 'Same', symbol: 'second', aliases: ['Same'] }, symbol: 'second', type: 'second-reference' }, + ], + }], + } + const artifact = new FaceModelEmitter(collisionFace).emit('@fixture/schema') + expect(artifact.js).toContain('const Same$schema2 =') + expect(artifact.js).toContain('export const _1_bad = Same$schema') + expect(artifact.dts).toContain("from '@fixture/schema/secondary'") + }) + + it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)( + 'rejects %s members on data-schema objects', + (kind) => { + expect(() => emit([ + { id: 'root', kind: 'object', members: [signatureMember(kind)] }, + keyword('child', 'string'), + ])).toThrow(`${kind} member member is not data-schema projectable`) + }, + ) + + it('classifies and rejects every unsupported TypeNode kind', () => { + const expected = Object.entries(ZOD_NODE_SUPPORT) + .filter(([, support]) => support === 'unsupported') + .map(([kind]) => kind) + .sort() + expect(distinct(unsupportedNodeCases.map(candidate => candidate.kind))).toEqual(expected) + }) +}) + +function keywordCase(name: KeywordTypeName, accepted: readonly unknown[], rejected: readonly unknown[]): SchemaCase { + return { name: `keyword ${name}`, nodes: [keyword('root', name)], accepted, rejected } +} + +function keyword(id: string, name: KeywordTypeName): TypeNodeModel { + return { id, kind: 'keyword', name } +} + +function signature(returns: string): SignatureModel { + return { typeParameters: [], parameters: [], returns } +} + +function property( + name: string, + type: string, + options: { readonly optional?: boolean; readonly readonly?: boolean } = {}, +): MemberModel { + return { + ...documentation, + id: `member:${name}`, + kind: 'property', + name, + type, + optional: options.optional ?? false, + readonly: options.readonly ?? false, + async: false, + abstract: false, + static: false, + visibility: 'public', + location, + text: `${name}: unknown`, + } +} + +function signatureMember(kind: Exclude): MemberModel { + return { + ...documentation, + id: `member:${kind}`, + kind, + name: 'member', + signature: signature('child'), + optional: false, + readonly: false, + async: false, + abstract: false, + static: false, + visibility: 'public', + location, + text: `${kind} member`, + } +} + +function declaration( + name: string, + kind: TypeDeclarationModel['kind'], + options: Partial> = {}, +): TypeDeclarationModel { + return { + ...documentation, + id: name, + package: '@fixture/schema', + name, + kind, + abstract: options.abstract ?? false, + exported: true, + location, + text: `export ${kind === 'alias' ? 'type' : kind} ${name}`, + typeParameters: options.typeParameters ?? [], + extends: options.extends ?? [], + implements: options.implements ?? [], + members: options.members ?? [], + ...(options.type === undefined ? {} : { type: options.type }), + ...(options.enumMembers === undefined ? {} : { enumMembers: options.enumMembers }), + } +} + +function emit( + nodes: readonly TypeNodeModel[], + rootDeclaration = declaration('Root', 'alias', { type: 'root' }), + dependencies: readonly TypeDeclarationModel[] = [], +): string { + const schemaReference: TypeNodeModel = { + id: 'schema-reference', + kind: 'reference', + name: 'Root', + target: { kind: 'declaration', symbol: 'Root' }, + arguments: [], + } + const face: FaceModel = { + face: 'host', + graph: { + declarations: [rootDeclaration, ...dependencies], + nodes: [schemaReference, ...nodes], + }, + packages: [{ + name: '@fixture/schema', + root: '.', + exports: [{ subpath: '.', name: 'Root', symbol: 'Root', aliases: ['Root'] }], + services: [], + events: [], + objects: [], + schemas: [{ + ...documentation, + export: { subpath: '.', name: 'Root', symbol: 'Root', aliases: ['Root'] }, + symbol: 'Root', + type: 'schema-reference', + }], + }], + } + return new FaceModelEmitter(face).emit('@fixture/schema').js +} + +function schemaFace( + nodes: readonly TypeNodeModel[], + symbol: string, + declarations: readonly TypeDeclarationModel[] = [declaration('Root', 'alias', { type: 'root' })], +): FaceModel { + return { + face: 'host', + graph: { declarations, nodes }, + packages: [{ + name: '@fixture/schema', + root: '.', + exports: [{ subpath: '.', name: 'Root', symbol, aliases: ['Root'] }], + services: [], + events: [], + objects: [], + schemas: [{ + ...documentation, + export: { subpath: '.', name: 'Root', symbol, aliases: ['Root'] }, + symbol, + type: 'root', + }], + }], + } +} + +async function loadSchema(source: string): Promise<{ safeParse(value: unknown): { success: boolean } }> { + const root = mkdtempSync(join(import.meta.dirname, '.generated-schema-')) + temporaryRoots.push(root) + const path = join(root, 'schema.mjs') + writeFileSync(path, source) + const generated = await import(`${pathToFileURL(path).href}?test=${Date.now()}-${String(temporaryRoots.length)}`) as { + Root: { safeParse(value: unknown): { success: boolean } } + } + return generated.Root +} + +function distinct(values: readonly string[]): string[] { + return [...new Set(values)].sort() +} diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts new file mode 100644 index 0000000000..29193e66c1 --- /dev/null +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' +import { EVENT_API, SERVICE_API, TYPE_API } from '@deepseek-ai/dsh-tool-cordis/src/api-catalog.ts' +import { WorkspaceAnalyzer } from '../src/analyzer.ts' +import { FaceModelEmitter } from '../src/emitter.ts' + +const workspaceRoot = resolve(import.meta.dirname, '../../../..') +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('model-driven dsh-tools generation', () => { + it('round-trips the complete service and event structure through the runtime registry', { timeout: 30_000 }, async () => { + const workspace = new WorkspaceAnalyzer({ + root: workspaceRoot, + faces: ['host'], + packages: ['@deepseek-ai/dsh-tools'], + }).analyze() + const host = workspace.faces.find(candidate => candidate.face === 'host') + if (host === undefined) throw new Error('dsh-tools has no analyzed host face') + const artifact = new FaceModelEmitter(host).emit('@deepseek-ai/dsh-tools') + + const root = mkdtempSync(join(import.meta.dirname, '.generated-tools-')) + temporaryRoots.push(root) + const modulePath = join(root, 'host.mjs') + writeFileSync(modulePath, artifact.js) + const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as { + TYPERT: TypertContribution + } + + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const dispose = ctx.typert.register(generated.TYPERT) + const record = ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host') + const service = record?.model.services.find(candidate => candidate.key === 'tools') + expect(service).toBeDefined() + expect({ + key: service?.key, + summary: service?.summary, + methods: service?.members + .filter(member => member.kind === 'method' && !member.name.startsWith('[')) + .map(member => ({ + signature: member.signature, + jsDoc: member.jsDoc ?? '', + })), + }).toEqual(SERVICE_API.find(candidate => candidate.key === 'tools')) + expect(record?.model.events.filter(event => event.name.startsWith('tools/')).map(event => ({ + name: event.name, + mode: event.mode, + signature: event.signature, + jsDoc: event.jsDoc ?? '', + summary: event.summary, + }))).toEqual(EVENT_API.filter(event => event.name.startsWith('tools/'))) + expect(service?.types.find(type => type.name === 'ToolDefinition')).toEqual( + TYPE_API.find(type => type.name === 'ToolDefinition'), + ) + + dispose() + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() + }) +}) diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts new file mode 100644 index 0000000000..9b4057beee --- /dev/null +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -0,0 +1,106 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const generated = vi.hoisted(() => vi.fn(() => [ + { + package: '@deepseek-ai/dsh-tools', + packageRoot: 'packages/core/tools', + face: 'host' as const, + exports: [], + js: 'export const host = true\n', + dts: 'export declare const host: true\n', + }, + { + package: '@deepseek-ai/dsh-tools', + packageRoot: 'packages/core/tools', + face: 'client' as const, + exports: [], + js: 'export const client = true\n', + dts: 'export declare const client: true\n', + }, +])) + +vi.mock('../src/workspace.ts', () => ({ + WorkspaceTypertGenerator: class { + generate = generated + }, +})) + +const { typertPlugin } = await import('../src/tsdown-plugin.ts') +const roots: string[] = [] + +afterEach(() => { + generated.mockClear() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('typertPlugin', () => { + it('skips outputs that do not identify a Typert contributor', async () => { + const plugin = typertPlugin() + expect(plugin.name).toBe('dsh-typert-generator') + plugin.writeBundle({}) + + const root = await workspace() + const orphan = join(root, 'orphan', 'lib') + await mkdir(orphan, { recursive: true }) + plugin.writeBundle({ dir: orphan }) + + const unnamed = await packageOutput(root, 'unnamed', {}) + plugin.writeBundle({ dir: unnamed }) + const other = await packageOutput(root, 'other', { name: '@fixture/other' }) + plugin.writeBundle({ dir: other }) + + expect(generated).not.toHaveBeenCalled() + expect(() => { plugin.writeBundle({ dir: join(root, '..', 'outside', 'lib') }) }) + .toThrow('cannot find workspace root') + }) + + it('writes every generated face beside a nested package bundle', async () => { + const root = await workspace() + const output = await packageOutput(root, 'tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }, 'lib/dev') + const clientOutput = await packageOutput(root, 'client-tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './client/typert': './lib/typert.client.js' }, + }) + + const plugin = typertPlugin() + plugin.writeBundle({ dir: output }) + plugin.writeBundle({ dir: clientOutput }) + + expect(generated).toHaveBeenCalledOnce() + expect(generated).toHaveBeenCalledWith() + const packageLib = join(root, 'packages', 'tools', 'lib') + expect(readFileSync(join(packageLib, 'typert.host.js'), 'utf8')).toBe('export const host = true\n') + expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n') + expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n') + expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true) + expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8')) + .toBe('export const client = true\n') + }) +}) + +async function workspace(): Promise { + const root = mkdtempSync(join(tmpdir(), 'dsh-typert-tsdown-')) + roots.push(root) + writeFileSync(join(root, 'tsconfig.host.json'), '{}\n') + return root +} + +async function packageOutput( + root: string, + directory: string, + manifest: Record, + output = 'lib', +): Promise { + const packageRoot = join(root, 'packages', directory) + const result = join(packageRoot, output) + await mkdir(result, { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), `${JSON.stringify(manifest)}\n`) + return result +} diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts new file mode 100644 index 0000000000..923c254d0f --- /dev/null +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -0,0 +1,1246 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { TypertAnalysisError, WorkspaceAnalyzer } from '../src/analyzer.ts' +import { FaceModelEmitter } from '../src/emitter.ts' +import type { + KeywordTypeName, + MemberModel, + TypeDeclarationModel, + TypeNodeModel, + TypeOperatorName, + TypeTargetModel, +} from '../src/model.ts' +import { TypeGraphRenderer } from '../src/renderer.ts' +import { WorkspaceTypertGenerator } from '../src/workspace.ts' + +const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') +const temporaryRoots: string[] = [] +const parseConfigHost: ts.ParseConfigFileHost = { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(formatDiagnostic(diagnostic)) + }, +} + +const TYPE_NODE_KINDS = { + keyword: true, + literal: true, + parenthesized: true, + reference: true, + union: true, + intersection: true, + array: true, + tuple: true, + object: true, + function: true, + constructor: true, + 'indexed-access': true, + operator: true, + conditional: true, + infer: true, + mapped: true, + 'template-literal': true, + 'type-query': true, + 'import-type': true, + predicate: true, + this: true, +} as const satisfies Record + +const TYPE_TARGET_KINDS = { + declaration: true, + 'type-parameter': true, + 'cross-face': true, + external: true, + standard: true, +} as const satisfies Record + +const KEYWORD_TYPE_NAMES = { + any: true, + bigint: true, + boolean: true, + never: true, + number: true, + object: true, + string: true, + symbol: true, + undefined: true, + unknown: true, + void: true, +} as const satisfies Record + +const TYPE_OPERATOR_NAMES = { + keyof: true, + readonly: true, + unique: true, +} as const satisfies Record + +const DECLARATION_KINDS = { + interface: true, + class: true, + alias: true, + enum: true, +} as const satisfies Record + +const MEMBER_KINDS = { + property: true, + method: true, + getter: true, + setter: true, + call: true, + construct: true, + index: true, +} as const satisfies Record + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { + it('builds independent face models with an explicit cross-face type graph', () => { + const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + + expect(model.faces.map(face => face.face)).toEqual(['host', 'client']) + expect(model.crossFaceLinks).toContainEqual({ + fromFace: 'client', + fromPackage: '@fixture/client', + toFace: 'host', + toPackage: '@fixture/host', + subpath: '.', + name: 'Agent', + }) + expect(model.crossFaceLinks).toContainEqual({ + fromFace: 'client', + fromPackage: '@fixture/client', + toFace: 'host', + toPackage: '@fixture/host', + subpath: '.', + name: 'HostAgent', + }) + expect(model.crossFaceLinks).toContainEqual({ + fromFace: 'client', + fromPackage: '@fixture/client', + toFace: 'host', + toPackage: '@fixture/host', + subpath: '.', + name: 'Box', + }) + const clientPackage = model.faces.find(face => face.face === 'client')?.packages[0] + expect(clientPackage).toMatchObject({ objects: [], schemas: [] }) + expect(clientPackage?.exports).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'ReexportedBox', aliases: ['ReexportedBox', 'Box'] }), + expect.objectContaining({ name: 'ReexportedZodType', aliases: ['ReexportedZodType', 'ZodType'] }), + ])) + const host = model.faces.find(face => face.face === 'host') + expect(host?.graph.nodes).toContainEqual(expect.objectContaining({ + kind: 'conditional', + })) + expect(host?.graph.nodes).toContainEqual(expect.objectContaining({ + kind: 'mapped', + })) + expect(host?.graph.nodes.some(node => node.kind === 'reference' + && node.target.kind === 'external' + && node.target.module === 'zod' + && node.target.name === 'ZodType')).toBe(true) + expect(host?.graph.nodes.some(node => node.kind === 'reference' + && node.target.kind === 'external' + && node.target.module === '@types/node' + && node.target.name === 'Process')).toBe(true) + const agent = host?.graph.declarations.find(declaration => declaration.name === 'Agent') + expect(agent?.implements).toHaveLength(1) + expect(agent).toMatchObject({ + exported: true, + location: { file: 'packages/host/src/index.ts' }, + }) + expect(agent?.text).toContain('export class Agent member.name)).toEqual(['id', 'state', 'label', 'label', 'run']) + const service = host?.packages[0]?.services.find(candidate => candidate.key === 'demo') + const members = new Map(host?.graph.declarations + .flatMap(declaration => declaration.members) + .map(member => [member.id, member.name])) + expect(service?.members.map(member => members.get(member))).toEqual([ + 'inspect', + 'acceptsExternal', + 'setPhase', + 'inspectSyntax', + 'inspectAsync', + 'destructure', + ]) + expect(service?.location).toMatchObject({ file: 'packages/host/src/index.ts' }) + const inspect = host?.graph.declarations + .flatMap(declaration => declaration.members) + .find(member => member.name === 'inspect') + expect(inspect?.text).toBe( + 'inspect(agent: Agent<{ ready: true }>, flags: Flags): Present', + ) + expect(host?.packages[0]?.services.filter(candidate => candidate.key === 'demo')).toHaveLength(1) + expect(host?.packages[0]?.events.filter(candidate => candidate.name === 'demo/ready')).toHaveLength(1) + expect(host?.packages[0]?.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'demo/unmodeled' }), + expect.objectContaining({ name: 'demo/serial-property', mode: 'serial' }), + ])) + expect(host?.packages[0]?.events.find(candidate => candidate.name === 'demo/unmodeled')) + .not.toHaveProperty('mode') + expect(host?.packages[0]?.events.find(candidate => candidate.name === 'demo/ready')).toMatchObject({ + location: { file: 'packages/host/src/index.ts' }, + text: "'demo/ready'(agent: Agent<{ ready: true }>, payload: Box): void", + }) + expect(model).toMatchSnapshot() + }) + + it('merges bounded package programs into the same face model', () => { + const options = { + root: fixtureRoot, + packages: ['@fixture/host', '@fixture/client'], + } as const + const direct = new WorkspaceAnalyzer(options).analyze() + const batched = new WorkspaceAnalyzer(options).analyzeInBatches(1) + + expect(batched).toEqual(direct) + }) + + it('indexes authored top-level exports without promoting them to graph roots', () => { + const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() + const agent = declarations.find(declaration => declaration.name === 'Agent') + + expect(agent).toMatchObject({ + face: 'host', + package: '@fixture/host', + name: 'Agent', + kind: 'class', + }) + expect(agent?.location).toMatchObject({ file: 'packages/host/src/index.ts' }) + expect(agent?.text).toContain('export class Agent declaration.name === 'IgnoredDeclaration')).toBe(false) + }) + + it('covers every modeled discriminant with source-authored fixture syntax', () => { + const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + const nodes = model.faces.flatMap(face => face.graph.nodes) + const declarations = model.faces.flatMap(face => face.graph.declarations) + const members = declarations.flatMap(declaration => declaration.members) + const objectMembers = nodes.flatMap(node => node.kind === 'object' ? node.members : []) + const allMembers = [...members, ...objectMembers] + const targets = nodes.flatMap(node => node.kind === 'reference' ? [node.target] : []) + + expect(distinct(nodes.map(node => node.kind))).toEqual(Object.keys(TYPE_NODE_KINDS).sort()) + expect(distinct(targets.map(target => target.kind))).toEqual(Object.keys(TYPE_TARGET_KINDS).sort()) + expect(distinct(nodes.flatMap(node => node.kind === 'keyword' ? [node.name] : []))) + .toEqual(Object.keys(KEYWORD_TYPE_NAMES).sort()) + expect(distinct(nodes.flatMap(node => node.kind === 'operator' ? [node.operator] : []))) + .toEqual(Object.keys(TYPE_OPERATOR_NAMES).sort()) + expect(distinct(declarations.map(declaration => declaration.kind))).toEqual(Object.keys(DECLARATION_KINDS).sort()) + expect(distinct(members.map(member => member.kind))).toEqual(Object.keys(MEMBER_KINDS).sort()) + expect(allMembers.some(member => member.optional)).toBe(true) + expect(allMembers.some(member => member.readonly)).toBe(true) + expect(allMembers.some(member => member.async)).toBe(true) + expect(distinct(allMembers.map(member => String(member.abstract)))).toEqual(['false', 'true']) + expect(allMembers.every(member => !member.static && member.visibility === 'public')).toBe(true) + + const signatures = [ + ...members.flatMap(member => 'signature' in member ? [member.signature] : []), + ...nodes.flatMap(node => node.kind === 'function' || node.kind === 'constructor' ? [node.signature] : []), + ] + const typeParameters = declarations.flatMap(declaration => [ + ...declaration.typeParameters, + ...declaration.members.flatMap(member => 'signature' in member ? member.signature.typeParameters : []), + ...signatures.flatMap(signature => signature.typeParameters), + ]) + expect(distinct(typeParameters.map(parameter => String(parameter.const)))).toEqual(['false', 'true']) + expect(distinct(typeParameters.flatMap(parameter => parameter.variance === undefined ? [] : [parameter.variance]))) + .toEqual(['in', 'in-out', 'out']) + + const parameters = signatures.flatMap(signature => signature.parameters) + expect(parameters.some(parameter => parameter.optional)).toBe(true) + expect(parameters.some(parameter => parameter.rest)).toBe(true) + expect(parameters.some(parameter => parameter.receiver)).toBe(true) + + const tuples = nodes.filter(node => node.kind === 'tuple') + expect(tuples.some(tuple => tuple.elements.some(element => element.optional))).toBe(true) + expect(tuples.some(tuple => tuple.elements.some(element => element.rest))).toBe(true) + + const mapped = nodes.filter(node => node.kind === 'mapped') + expect(distinct(mapped.map(node => node.readonly))).toEqual(['add', 'preserve', 'remove']) + expect(distinct(mapped.map(node => node.optional))).toEqual(['add', 'preserve', 'remove']) + expect(mapped.some(node => node.nameType !== undefined)).toBe(true) + const genericHeritage = declarations + .flatMap(declaration => [...declaration.extends, ...declaration.implements]) + .map(id => nodes.find(node => node.id === id)) + .find(node => node?.kind === 'reference' && node.arguments.length > 0) + expect(genericHeritage).toEqual(expect.objectContaining({ + kind: 'reference', + name: 'Box', + arguments: [expect.any(String)], + })) + + expect(parameters).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: '{ name }', binding: 'object' }), + expect.objectContaining({ name: '[suffix]', binding: 'array' }), + ])) + expect(parameters.some(parameter => parameter.binding === 'identifier')).toBe(true) + + const imports = nodes.filter(node => node.kind === 'import-type') + expect(distinct(imports.map(node => String(node.typeof)))).toEqual(['false', 'true']) + expect(imports.some(node => node.qualifier !== undefined && node.arguments.length > 0)).toBe(true) + expect(imports.some(node => node.qualifier === undefined && node.arguments.length === 0)).toBe(true) + expect(imports.some(node => node.attributes === "{ with: { 'resolution-mode': 'import' } }")).toBe(true) + expect(imports.some(node => node.module === '@fixture/host' + && node.qualifier === 'Agent' + && node.target?.kind === 'cross-face' + && node.target.name === 'Agent')).toBe(true) + + const literals = nodes.filter(node => node.kind === 'literal') + expect(distinct(literals.map(node => node.value === null ? 'null' : typeof node.value))) + .toEqual(['bigint', 'boolean', 'null', 'number', 'string']) + expect(literals).toEqual(expect.arrayContaining([ + expect.objectContaining({ value: 1n, text: '1n' }), + expect.objectContaining({ value: -2n, text: '-2n' }), + expect.objectContaining({ value: 'fixed', text: '`fixed`' }), + ])) + + const queries = nodes.filter(node => node.kind === 'type-query') + expect(distinct(queries.map(node => String(node.arguments.length)))).toEqual(['0', '1']) + expect(queries).toContainEqual(expect.objectContaining({ + expression: 'genericFactory', + arguments: [expect.any(String)], + })) + + const templates = nodes.filter(node => node.kind === 'template-literal') + expect(templates.some(node => node.spans.length === 2 + && node.spans.map(span => span.text).join('|') === '/to/|/end')).toBe(true) + + const constructors = nodes.filter(node => node.kind === 'constructor') + expect(distinct(constructors.map(node => String(node.abstract)))).toEqual(['false', 'true']) + + const predicates = nodes.filter(node => node.kind === 'predicate') + expect(distinct(predicates.map(node => String(node.asserts)))).toEqual(['false', 'true']) + expect(predicates.some(node => node.type === undefined)).toBe(true) + expect(predicates.some(node => node.type !== undefined)).toBe(true) + expect(predicates.some(node => node.parameter === 'this')).toBe(true) + + const enumMembers = declarations.flatMap(declaration => declaration.enumMembers ?? []) + expect(enumMembers.some(member => member.initializer === undefined)).toBe(true) + expect(enumMembers.some(member => member.initializer !== undefined)).toBe(true) + }) + + it('retains an omitted mapped value when the owning project permits implicit any', () => { + const root = copyFixture('typert-implicit-mapped-value-') + const sourcePath = join(root, 'packages/host/src/index.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert schema */', + 'export type ImplicitMap = { [Key in keyof Value] }', + '', + ].join('\n')) + const configPath = join(root, 'packages/host/tsconfig.json') + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { compilerOptions: Record } + config.compilerOptions.noImplicitAny = false + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + + const nodes = new WorkspaceAnalyzer({ root }).analyze().faces + .flatMap(face => face.graph.nodes) + const mapped = nodes.find(node => node.kind === 'mapped' && node.value === undefined) + expect(mapped).toEqual(expect.objectContaining({ kind: 'mapped' })) + expect(mapped).not.toHaveProperty('value') + }) + + it('fails in check mode and writes inferred public annotations in write mode', () => { + const root = copyFixture('typert-type-model-') + const options = { + root, + hostConfig: 'tsconfig.write.json', + clientConfig: 'missing.client.json', + packages: ['@fixture/write'], + } as const + + expect(() => new WorkspaceAnalyzer({ ...options, mode: 'check' }).analyze()) + .toThrow(TypertAnalysisError) + + const model = new WorkspaceAnalyzer({ ...options, mode: 'write' }).analyze() + const source = readFileSync(join(root, 'packages/write/src/index.ts'), 'utf8') + expect(source).toContain('value: number = 1') + expect(source).toContain("echo(input: string = 'value'): string") + expect(model.faces[0]?.packages[0]?.services[0]?.key).toBe('writable') + const echo = model.faces[0]?.graph.declarations + .flatMap(declaration => declaration.members) + .find(member => member.name === 'echo') + if (echo?.kind !== 'method') throw new Error('write fixture has no echo method') + expect(echo.signature.parameters[0]?.initializer).toBe("'value'") + }) + + it('rejects relative imports across face boundaries', () => { + const root = copyFixture('typert-relative-face-') + const sourcePath = join(root, 'packages/client/src/index.ts') + const source = readFileSync(sourcePath, 'utf8') + .replace("from '@fixture/host'", "from '../../host/src/index.ts'") + writeFileSync(sourcePath, source) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + /crosses a package or face without an explicit import/, + ) + }) + + it('rejects package subpaths absent from package.json exports', () => { + const root = copyFixture('typert-private-export-') + writeFileSync( + join(root, 'packages/host/src/private.ts'), + 'export interface PrivateHost { readonly value: string }\n', + ) + const sourcePath = join(root, 'packages/client/src/index.ts') + const source = readFileSync(sourcePath, 'utf8') + .replace( + "import type { HostAgent, Payload } from '@fixture/host'", + "import type { HostAgent, Payload } from '@fixture/host'\nimport type { PrivateHost } from '@fixture/host/private'", + ) + .replace( + 'export class ClientBridge extends Service {', + 'export class ClientBridge extends Service {\n leak(value: PrivateHost): void { void value }', + ) + writeFileSync(sourcePath, source) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'cross-face reference PrivateHost is not exported by @fixture/host at ./private', + ) + }) + + it('rejects cross-face re-exports outside package.json exports', () => { + const root = copyFixture('typert-private-reexport-') + writeFileSync( + join(root, 'packages/host/src/private.ts'), + 'export interface PrivateHost { readonly value: string }\n', + ) + const sourcePath = join(root, 'packages/client/src/index.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + "export type { PrivateHost } from '@fixture/host/private'", + '', + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'cross-face re-export PrivateHost is not exported by @fixture/host at ./private', + ) + }) + + it('rejects cross-face namespace re-exports until the model has a namespace target', () => { + const root = copyFixture('typert-namespace-reexport-') + const sourcePath = join(root, 'packages/client/src/index.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + "export type * as HostNamespace from '@fixture/host'", + '', + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'cross-face namespace re-exports are not supported', + ) + }) + + it('ignores cross-face namespace exports that are not package exports', () => { + const root = copyFixture('typert-private-namespace-reexport-') + writeFileSync( + join(root, 'packages/client/src/internal.ts'), + "export type * as HiddenHostNamespace from '@fixture/host'\n", + ) + const sourcePath = join(root, 'packages/client/src/index.ts') + writeFileSync(sourcePath, [ + "import './internal.ts'", + readFileSync(sourcePath, 'utf8'), + ].join('\n')) + + expect(new WorkspaceAnalyzer({ root }).analyze().faces + .find(face => face.face === 'client')?.packages[0]?.services.map(service => service.key)) + .toContain('clientBridge') + }) + + it('records public symbols from explicit cross-face star re-exports', () => { + const root = copyFixture('typert-star-reexport-') + const sourcePath = join(root, 'packages/client/src/index.ts') + writeFileSync( + sourcePath, + readFileSync(sourcePath, 'utf8') + .replace("export type { Box as ReexportedBox } from '@fixture/host'", "export type * from '@fixture/host'"), + ) + + const model = new WorkspaceAnalyzer({ root }).analyze() + expect(model.crossFaceLinks).toContainEqual({ + fromFace: 'client', + fromPackage: '@fixture/client', + toFace: 'host', + toPackage: '@fixture/host', + subpath: '.', + name: 'Box', + }) + }) + + it('expands explicit same-face package exports through declaration targets', () => { + const root = copyFixture('typert-same-face-') + addSameFacePackage(root, '@fixture/host/models', 'Payload') + + const model = new WorkspaceAnalyzer({ root }).analyze() + const host = model.faces.find(face => face.face === 'host') + const payload = host?.graph.declarations.find(declaration => declaration.name === 'Payload') + expect(host?.packages.map(packageModel => packageModel.name)).toContain('@fixture/consumer') + expect(host?.graph.nodes.some(node => node.id.includes('packages/consumer/src/index.ts') + && node.kind === 'reference' + && node.name === 'Payload' + && node.target.kind === 'declaration' + && node.target.symbol === payload?.id)).toBe(true) + }) + + it('resolves explicit same-face package re-exports to their declaration owner', () => { + const root = copyFixture('typert-same-face-reexport-') + const packageRoot = join(root, 'packages/barrel') + mkdirSync(join(packageRoot, 'src'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/barrel', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + }, null, 2)) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + references: [{ path: '../host' }], + }, null, 2)) + writeFileSync( + join(packageRoot, 'src/index.ts'), + "export type { Payload } from '@fixture/host/models'\n", + ) + const basePath = join(root, 'tsconfig.base.json') + const base = JSON.parse(readFileSync(basePath, 'utf8')) as { + compilerOptions: { paths: Record } + } + base.compilerOptions.paths['@fixture/barrel'] = ['./packages/barrel/src/index.ts'] + writeFileSync(basePath, `${JSON.stringify(base, null, 2)}\n`) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/barrel' }) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) + addSameFacePackage(root, '@fixture/barrel', 'Payload') + const consumerConfigPath = join(root, 'packages/consumer/tsconfig.json') + const consumerConfig = JSON.parse(readFileSync(consumerConfigPath, 'utf8')) as { + references: { path: string }[] + } + consumerConfig.references.push({ path: '../barrel' }) + writeFileSync(consumerConfigPath, `${JSON.stringify(consumerConfig, null, 2)}\n`) + + const model = new WorkspaceAnalyzer({ root }).analyze() + const host = model.faces.find(face => face.face === 'host') + const payload = host?.graph.declarations.find(declaration => declaration.name === 'Payload') + expect(host?.graph.nodes.some(node => node.id.includes('packages/consumer/src/index.ts') + && node.kind === 'reference' + && node.name === 'Payload' + && node.target.kind === 'declaration' + && node.target.symbol === payload?.id)).toBe(true) + }) + + it('rejects same-face package imports outside package.json exports', () => { + const root = copyFixture('typert-private-package-') + writeFileSync( + join(root, 'packages/host/src/private.ts'), + 'export interface PrivateHost { readonly value: string }\n', + ) + addSameFacePackage(root, '@fixture/host/private', 'PrivateHost') + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'package reference PrivateHost is not exported by @fixture/host at ./private', + ) + }) + + it('rejects relative imports across same-face package boundaries', () => { + const root = copyFixture('typert-relative-package-') + addSameFacePackage(root, '@fixture/host/models', 'Payload') + const sourcePath = join(root, 'packages/consumer/src/index.ts') + writeFileSync( + sourcePath, + readFileSync(sourcePath, 'utf8') + .replace("'@fixture/host/models'", "'../../host/src/models.ts'"), + ) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'reference to Payload crosses a package without an explicit package import', + ) + }) + + it('rejects TypeScript projects with source diagnostics before modeling them', () => { + const root = copyFixture('typert-invalid-project-') + const sourcePath = join(root, 'packages/host/src/index.ts') + writeFileSync(sourcePath, `${readFileSync(sourcePath, 'utf8')}\nconst invalidFixture: string = 1\n`) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + /packages\/host\/src\/index\.ts:\d+:\d+: TypeScript TS2322/, + ) + }) + + it('retains every authored part of a merged interface', () => { + const root = copyFixture('typert-merged-declaration-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert object */', + 'export interface Merged extends Entity { readonly left: Value }', + 'export interface Merged { readonly right: Value }', + '/** @typert object */', + 'export interface MergedInput { consume(value: Value): void }', + 'export interface MergedInput { consumeAgain(value: Value): void }', + '', + ].join('\n')) + + const model = new WorkspaceAnalyzer({ root }).analyze() + const merged = model.faces + .flatMap(face => face.graph.declarations) + .find(declaration => declaration.name === 'Merged') + expect(merged?.members.map(member => member.name)).toEqual(['left', 'right']) + expect(merged?.parts?.map(part => part.members.length)).toEqual([1, 1]) + expect(merged?.parts?.map(part => part.typeParameters.length)).toEqual([1, 1]) + expect(merged?.parts?.map(part => part.extends.length)).toEqual([1, 0]) + expect(merged?.parts?.map(part => part.package)).toEqual(['@fixture/host', '@fixture/host']) + const mergedInput = model.faces + .flatMap(face => face.graph.declarations) + .find(declaration => declaration.name === 'MergedInput') + expect(mergedInput?.typeParameters[0]?.variance).toBe('in') + }) + + it('rejects merged declarations that include a part outside the registered face', () => { + const root = copyFixture('typert-external-merge-') + writeFileSync(join(root, 'external-augmentation.ts'), [ + 'export {}', + 'declare global {', + ' interface ExternalMerged { readonly augmented?: string }', + '}', + '', + ].join('\n')) + const modelsPath = join(root, 'packages/host/src/models.ts') + writeFileSync(modelsPath, [ + readFileSync(modelsPath, 'utf8'), + 'declare global {', + ' interface ExternalMerged { readonly local?: string }', + '}', + 'export interface SyntaxZoo { readonly externalMerged: ExternalMerged }', + '', + ].join('\n')) + const sourcePath = join(root, 'packages/host/src/index.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + "import '../../../external-augmentation.ts'", + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'merged interface ExternalMerged contains a declaration outside this face', + ) + }) + + it('keeps unscoped global npm declarations as true external targets', () => { + const root = copyFixture('typert-unscoped-external-') + const externalRoot = join(root, 'node_modules/unscoped-global') + mkdirSync(externalRoot, { recursive: true }) + writeFileSync(join(externalRoot, 'package.json'), JSON.stringify({ + name: 'unscoped-global', + version: '1.0.0', + types: './index.d.ts', + })) + writeFileSync(join(externalRoot, 'index.d.ts'), [ + 'export {}', + 'declare global { interface UnscopedGlobal { readonly value: string } }', + '', + ].join('\n')) + const packageConfigPath = join(root, 'packages/host/tsconfig.json') + for (const configPath of [packageConfigPath, join(root, 'tsconfig.host.json')]) { + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + compilerOptions?: Record + } + config.compilerOptions ??= {} + config.compilerOptions.typeRoots = [ + configPath === packageConfigPath ? '../../node_modules' : './node_modules', + resolve('node_modules/@types'), + ] + config.compilerOptions.types = ['unscoped-global', 'node'] + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + } + const modelsPath = join(root, 'packages/host/src/models.ts') + writeFileSync(modelsPath, [ + readFileSync(modelsPath, 'utf8'), + 'export interface SyntaxZoo { readonly unscopedGlobal: UnscopedGlobal }', + '', + ].join('\n')) + + const packageConfig = ts.getParsedCommandLineOfConfigFile( + join(root, 'packages/host/tsconfig.json'), + {}, + parseConfigHost, + ) as ts.ParsedCommandLine + const aggregateConfig = ts.getParsedCommandLineOfConfigFile( + join(root, 'tsconfig.host.json'), + {}, + parseConfigHost, + ) as ts.ParsedCommandLine + const diagnosticProgram = ts.createProgram({ + rootNames: packageConfig.fileNames, + options: aggregateConfig.options, + }) + expect(diagnosticProgram.getSourceFiles().map(source => source.fileName)) + .toContain(join(externalRoot, 'index.d.ts')) + + const targets = new WorkspaceAnalyzer({ root }).analyze().faces + .flatMap(face => face.graph.nodes) + .flatMap(node => node.kind === 'reference' ? [node.target] : []) + expect(targets).toContainEqual({ + kind: 'external', + module: 'unscoped-global', + subpath: '.', + name: 'UnscopedGlobal', + }) + }) + + it('skips ambient imports without physical module files while walking exported sources', () => { + const root = copyFixture('typert-ambient-import-') + const declarationsPath = join(root, 'cordis.d.ts') + writeFileSync(declarationsPath, [ + readFileSync(declarationsPath, 'utf8'), + "declare module 'fixture-ambient' {}", + '', + ].join('\n')) + const sourcePath = join(root, 'packages/host/src/index.ts') + writeFileSync(sourcePath, [ + "import 'fixture-ambient'", + readFileSync(sourcePath, 'utf8'), + ].join('\n')) + + expect(new WorkspaceAnalyzer({ root }).analyze().faces + .find(face => face.face === 'host')?.packages[0]?.services.map(service => service.key)) + .toContain('demo') + }) + + it('rejects declaration merges without a lossless model', () => { + const root = copyFixture('typert-merged-enum-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert schema */', + "export enum MergedEnum { Left = 'left' }", + "export enum MergedEnum { Right = 'right' }", + '', + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'merged EnumDeclaration declaration MergedEnum is not supported', + ) + }) + + it('rejects merged interfaces with conflicting authored variance', () => { + const root = copyFixture('typert-merged-variance-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert object */', + 'export interface MergedVariance { consume(value: Value): void }', + 'export interface MergedVariance { produce(): Value }', + '', + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'merged interface MergedVariance has incompatible variance modifiers', + ) + }) + + it('handles empty selections and rejects malformed aggregate configs', () => { + const empty = mkdtempSync(join(import.meta.dirname, '.typert-empty-workspace-')) + temporaryRoots.push(empty) + expect(new WorkspaceAnalyzer({ root: empty }).analyze()).toEqual({ faces: [], crossFaceLinks: [] }) + + writeFileSync(join(empty, 'empty.d.ts'), 'export {}\n') + writeFileSync(join(empty, 'tsconfig.host.json'), '{ "files": ["empty.d.ts"] }\n') + expect(new WorkspaceAnalyzer({ root: empty }).analyze()).toEqual({ faces: [], crossFaceLinks: [] }) + + writeFileSync(join(empty, 'tsconfig.host.json'), '{ invalid json') + expect(() => new WorkspaceAnalyzer({ root: empty }).analyze()).toThrow(TypertAnalysisError) + + writeFileSync(join(empty, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: { target: 'invalid' } })) + expect(() => new WorkspaceAnalyzer({ root: empty }).analyze()).toThrow(TypertAnalysisError) + + expect(new WorkspaceAnalyzer({ root: fixtureRoot, packages: ['@fixture/absent'] }).analyze()) + .toEqual({ faces: [], crossFaceLinks: [] }) + }) + + it('ignores empty Cordis augmentations during package discovery', () => { + const root = copyFixture('typert-empty-augmentation-') + const hostRoot = join(root, 'packages/host') + const manifestPath = join(hostRoot, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + manifest.exports = { + '.': { types: './lib/types/index.d.ts', default: './lib/index.js' }, + './typert': { types: './lib/typert.host.d.ts', default: './lib/typert.host.js' }, + } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(hostRoot, 'src/index.ts'), [ + 'export {}', + "declare module 'cordis' {", + ' interface Context {}', + ' interface Events {}', + ' interface Ignored {}', + '}', + '', + ].join('\n')) + + expect(new WorkspaceAnalyzer({ root }).discoverPackages().map(item => item.package)) + .not.toContain('@fixture/host') + }) + + it('ignores aggregate references that are not named workspace packages', () => { + const root = copyFixture('typert-registration-filter-') + mkdirSync(join(root, 'outside'), { recursive: true }) + writeFileSync(join(root, 'outside/tsconfig.json'), '{}\n') + mkdirSync(join(root, 'packages/no-manifest'), { recursive: true }) + writeFileSync(join(root, 'packages/no-manifest/tsconfig.json'), '{}\n') + mkdirSync(join(root, 'packages/no-name'), { recursive: true }) + writeFileSync(join(root, 'packages/no-name/tsconfig.json'), '{}\n') + writeFileSync(join(root, 'packages/no-name/package.json'), '{}\n') + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push( + { path: './outside' }, + { path: './packages/no-manifest' }, + { path: './packages/no-name/tsconfig.json' }, + ) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) + + const model = new WorkspaceAnalyzer({ root }).analyze() + expect(model.faces.find(face => face.face === 'host')?.packages.map(item => item.name)) + .toEqual(['@fixture/host']) + }) + + it('accepts package export forms while skipping artifact-only rows and unexported packages', { timeout: 180_000 }, () => { + const root = copyFixture('typert-export-forms-') + const hostRoot = join(root, 'packages/host') + writeFileSync(join(hostRoot, 'src/runtime.ts'), 'export interface RuntimeOnly { value: string }\n') + writeFileSync(join(hostRoot, 'src/direct.ts'), 'export interface Direct { value: string }\n') + writeFileSync(join(hostRoot, 'src/empty.ts'), '\n') + const manifestPath = join(hostRoot, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + manifest.exports = { + '.': { types: './lib/types/index.d.ts', default: './lib/index.js' }, + './models': { types: './lib/types/models.d.ts', default: './lib/models.js' }, + './array': [null, { browser: './lib/runtime.js' }], + './fallback': { browser: null, development: './lib/runtime.js' }, + './direct': './src/direct.ts', + './empty': { types: './lib/types/empty.d.ts' }, + './none': [null, false], + './empty-conditions': {}, + './package.json': './package.json', + './typert': './lib/typert.host.js', + './client/typert': './lib/typert.client.js', + './wildcard': './lib/*.js', + './data': './lib/data.json', + ignored: './lib/index.js', + } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + + const model = new WorkspaceAnalyzer({ root }).analyze() + const exports = model.faces.find(face => face.face === 'host')?.packages[0]?.exports ?? [] + expect(exports.some(item => item.subpath === './array' && item.name === 'RuntimeOnly')).toBe(true) + expect(exports.some(item => item.subpath === './fallback' && item.name === 'RuntimeOnly')).toBe(true) + expect(exports.some(item => item.subpath === './direct' && item.name === 'Direct')).toBe(true) + expect(exports.some(item => item.subpath === './empty')).toBe(false) + + manifest.exports = './lib/index.js' + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root }).analyze().faces[0]?.packages[0]?.exports.length).toBeGreaterThan(0) + + manifest.exports = { types: './lib/types/index.d.ts', default: './lib/index.js' } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root }).analyze().faces[0]?.packages[0]?.exports.length).toBeGreaterThan(0) + + manifest.exports = [null, './lib/index.js'] + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root }).analyze().faces[0]?.packages[0]?.exports.length).toBeGreaterThan(0) + + manifest.exports = {} + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root, packages: ['@fixture/host'] }).analyze().faces.flatMap(face => face.packages)) + .toEqual([]) + + delete manifest.exports + manifest.types = './lib/types/index.d.ts' + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root }).analyze().faces[0]?.packages[0]?.exports.length).toBeGreaterThan(0) + + delete manifest.types + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + expect(new WorkspaceAnalyzer({ root, packages: ['@fixture/host'] }).analyze().faces.flatMap(face => face.packages)) + .toEqual([]) + }) + + it('rejects package exports whose source entry is missing', () => { + const root = copyFixture('typert-missing-export-source-') + const manifestPath = join(root, 'packages/host/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + manifest.exports = { '.': './lib/missing.js' } + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + + expect(() => new WorkspaceAnalyzer({ root, packages: ['@fixture/host'] }).analyze()) + .toThrow('resolves to missing source') + }) + + it('recognizes all supported typert annotation spellings', () => { + const root = copyFixture('typert-annotation-modes-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert */', + 'export interface DefaultSchema { value: string }', + '/** @typert type */', + 'export interface TypeSchema { value: string }', + '/** @typert ignored */', + 'export interface IgnoredSchema { value: string }', + '', + ].join('\n')) + + const host = new WorkspaceAnalyzer({ root }).analyze().faces.find(face => face.face === 'host') + expect(host?.packages[0]?.schemas.map(schema => schema.export.name)) + .toEqual(expect.arrayContaining(['DefaultSchema', 'Payload', 'TypeSchema'])) + expect(host?.packages[0]?.schemas.map(schema => schema.export.name)).not.toContain('IgnoredSchema') + }) + + it('rejects an exported Context service that is not a class or interface', () => { + const root = copyFixture('typert-invalid-service-') + const sourcePath = join(root, 'packages/host/src/index.ts') + const source = readFileSync(sourcePath, 'utf8') + .replace( + "export { AgentPhase } from './models.ts'", + "export { AgentPhase } from './models.ts'\nexport type InvalidService = { value: string }", + ) + .replace(' demo: DemoService', ' demo: DemoService\n invalidService: InvalidService') + writeFileSync(sourcePath, source) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()) + .toThrow('does not resolve to an exported class or interface') + }) + + it('rejects tagged anonymous declarations that cannot be named losslessly', () => { + const root = copyFixture('typert-anonymous-declaration-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert object */', + 'export default class { readonly value: string = "value" }', + '', + ].join('\n')) + + expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow( + 'anonymous ClassDeclaration cannot be represented as a named type declaration', + ) + }) + + it('retains merged generic interfaces without constraints or defaults', () => { + const root = copyFixture('typert-plain-merged-interface-') + const sourcePath = join(root, 'packages/host/src/models.ts') + writeFileSync(sourcePath, [ + readFileSync(sourcePath, 'utf8'), + '/** @typert object */', + 'export interface PlainMerged { left: Value }', + 'export interface PlainMerged { right: Value }', + '', + ].join('\n')) + + const declaration = new WorkspaceAnalyzer({ root }).analyze().faces + .flatMap(face => face.graph.declarations) + .find(item => item.name === 'PlainMerged') + expect(declaration?.typeParameters).toEqual([ + expect.objectContaining({ name: 'Value', const: false }), + ]) + expect(declaration?.typeParameters[0]).not.toHaveProperty('constraint') + expect(declaration?.typeParameters[0]).not.toHaveProperty('default') + }) +}) + +describe('TypeGraphRenderer', { timeout: 60_000 }, () => { + it('retains every source-authored SyntaxZoo property type through rendering', () => { + const host = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze().faces + .find(face => face.face === 'host') + if (host === undefined) throw new Error('fixture has no host face') + const declaration = host.graph.declarations.find(candidate => candidate.name === 'SyntaxZoo') + if (declaration === undefined) throw new Error('fixture has no SyntaxZoo declaration') + + const sourcePath = join(fixtureRoot, 'packages/host/src/models.ts') + const source = ts.createSourceFile( + sourcePath, + readFileSync(sourcePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + const sourceDeclaration = source.statements + .find(statement => ts.isInterfaceDeclaration(statement) && statement.name.text === 'SyntaxZoo') + if (sourceDeclaration === undefined || !ts.isInterfaceDeclaration(sourceDeclaration)) { + throw new Error('fixture source has no SyntaxZoo declaration') + } + const sourceTypes = new Map(sourceDeclaration.members.flatMap((member) => { + if (!ts.isPropertySignature(member) || member.type === undefined || !ts.isIdentifier(member.name)) return [] + return [[member.name.text, printType(member.type, source)] as const] + })) + const renderer = new TypeGraphRenderer(host.graph) + const renderedTypes = new Map(declaration.members.flatMap((member) => { + if (member.kind !== 'property') return [] + return [[member.name, canonicalType(renderer.renderType(member.type))] as const] + })) + + expect([...renderedTypes.keys()]).toEqual([...sourceTypes.keys()]) + for (const [name, sourceType] of sourceTypes) { + expect(renderedTypes.get(name), name).toBe(canonicalType(sourceType)) + } + }) + + it('renders every analyzed declaration as compilable TypeScript', () => { + const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + const root = mkdtempSync(join(import.meta.dirname, '.rendered-model-')) + temporaryRoots.push(root) + const externalTypes = join(root, 'external.d.ts') + writeFileSync(externalTypes, [ + 'declare module \'@fixture/host\' {', + ' export class Agent {}', + '}', + '', + ].join('\n')) + const rootNames: string[] = [externalTypes] + + for (const face of model.faces) { + const renderer = new TypeGraphRenderer(face.graph) + const path = join(root, `${face.face}.d.ts`) + const prelude = face.face === 'host' + ? [ + 'declare class Service {}', + 'interface ZodType {}', + 'declare namespace NodeJS { interface Process {} }', + "declare const phaseOrder: readonly ['idle', 'running']", + 'declare function genericFactory(): Value', + ] + : [ + 'declare class Service {}', + 'declare class Agent {}', + 'declare enum AgentPhase {}', + 'declare class HostAgent {}', + 'declare class HostDefault {}', + 'declare namespace Host { class Agent {} }', + 'interface Payload { name: string; count?: number }', + ] + writeFileSync(path, [ + ...prelude, + ...face.graph.declarations.map(declaration => renderer.renderDeclaration(declaration.id)), + '', + ].join('\n\n')) + rootNames.push(path) + } + + const program = ts.createProgram({ + rootNames, + options: { + strict: true, + noEmit: true, + skipLibCheck: false, + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }, + }) + expect(ts.getPreEmitDiagnostics(program).map(formatDiagnostic)).toEqual([]) + }) +}) + +describe('WorkspaceTypertGenerator', { timeout: 60_000 }, () => { + it('emits host and client faces through their exact root-level public artifacts', () => { + const artifacts = new WorkspaceTypertGenerator(fixtureRoot).generate() + expect(artifacts.map(artifact => ({ package: artifact.package, face: artifact.face }))).toEqual([ + { package: '@fixture/host', face: 'host' }, + { package: '@fixture/client', face: 'client' }, + ]) + expect(artifacts.every(artifact => artifact.dts.includes('export declare const TYPERT: unknown'))).toBe(true) + }) + + it('rejects a public Typert subpath that points outside the root-level face artifact', () => { + const root = copyFixture('typert-artifact-path-') + const manifestPath = join(root, 'packages/client/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + exports: Record + } + const clientExport = manifest.exports['./client/typert'] + if (clientExport === undefined) throw new Error('fixture has no client Typert export') + clientExport.types = './lib/types/typert.client.d.ts' + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + + expect(() => new WorkspaceTypertGenerator(root).generate()).toThrow( + '@fixture/client must export ./client/typert as', + ) + }) + + it('rejects absent Typert exports and package file entries', () => { + const noSubpathRoot = copyFixture('typert-missing-artifact-export-') + const noSubpathManifest = join(noSubpathRoot, 'packages/client/package.json') + const noSubpath = JSON.parse(readFileSync(noSubpathManifest, 'utf8')) as Record + noSubpath.exports = './lib/index.js' + writeFileSync(noSubpathManifest, `${JSON.stringify(noSubpath, null, 2)}\n`) + expect(() => new WorkspaceTypertGenerator(noSubpathRoot).generate()).toThrow( + '@fixture/client must export ./client/typert as', + ) + + const invalidSubpathRoot = copyFixture('typert-invalid-artifact-export-') + const invalidSubpathManifest = join(invalidSubpathRoot, 'packages/client/package.json') + const invalidSubpath = JSON.parse(readFileSync(invalidSubpathManifest, 'utf8')) as { + exports: Record + } + invalidSubpath.exports['./client/typert'] = null + writeFileSync(invalidSubpathManifest, `${JSON.stringify(invalidSubpath, null, 2)}\n`) + expect(() => new WorkspaceTypertGenerator(invalidSubpathRoot).generate()).toThrow( + '@fixture/client must export ./client/typert as', + ) + + const noFilesRoot = copyFixture('typert-missing-artifact-files-') + const noFilesManifest = join(noFilesRoot, 'packages/client/package.json') + const noFiles = JSON.parse(readFileSync(noFilesManifest, 'utf8')) as Record + delete noFiles.files + writeFileSync(noFilesManifest, `${JSON.stringify(noFiles, null, 2)}\n`) + expect(() => new WorkspaceTypertGenerator(noFilesRoot).generate()).toThrow( + '@fixture/client package files must include lib/typert.client.js', + ) + }) +}) + +function distinct(values: readonly string[]): string[] { + return [...new Set(values)].sort() +} + +function formatDiagnostic(diagnostic: ts.Diagnostic): string { + return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') +} + +function printType(node: ts.TypeNode, source: ts.SourceFile): string { + return ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, source) +} + +function canonicalType(text: string): string { + const source = ts.createSourceFile( + 'canonical-type.ts', + `type Canonical = ${text}\n`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + const declaration = source.statements[0] + if (declaration === undefined || !ts.isTypeAliasDeclaration(declaration)) { + throw new Error(`cannot parse rendered type ${text}`) + } + return printType(declaration.type, source) +} + +function copyFixture(prefix: string): string { + const root = mkdtempSync(join(import.meta.dirname, `.${prefix}`)) + temporaryRoots.push(root) + cpSync(fixtureRoot, root, { recursive: true }) + return root +} + +function addSameFacePackage(root: string, specifier: string, importedName: string): void { + const packageRoot = join(root, 'packages/consumer') + mkdirSync(join(packageRoot, 'src'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/consumer', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + }, null, 2)) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + references: [{ path: '../host' }], + }, null, 2)) + writeFileSync(join(packageRoot, 'src/index.ts'), [ + `import type { ${importedName} } from '${specifier}'`, + '/** @typert schema */', + `export interface ConsumerSchema { readonly value: ${importedName} }`, + '', + ].join('\n')) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/consumer' }) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) +} + +describe('FaceModelEmitter', { timeout: 60_000 }, () => { + it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { + const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + const host = model.faces.find(face => face.face === 'host') + if (host === undefined) throw new Error('fixture has no host face') + const artifact = new FaceModelEmitter(host).emit('@fixture/host') + + expect(artifact.js).toMatchSnapshot() + expect(artifact.dts).toMatchSnapshot() + + const root = mkdtempSync(join(import.meta.dirname, '.generated-model-')) + temporaryRoots.push(root) + const modulePath = join(root, 'host.mjs') + writeFileSync(modulePath, artifact.js) + const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as { + Payload: { safeParse(value: unknown): { success: boolean } } + TYPERT: { + package: string + face: string + schemas: { name: string; schema: unknown }[] + model: { services: { key: string; members: { signature: string }[] }[] } + } + } + expect(generated.Payload.safeParse({ name: 'ready', count: 2 }).success).toBe(true) + expect(generated.Payload.safeParse({ name: 'ready', count: 'two' }).success).toBe(false) + expect(generated.TYPERT).toMatchObject({ package: '@fixture/host', face: 'host' }) + expect(generated.TYPERT.schemas[0]?.schema).toBe(generated.Payload) + const demo = generated.TYPERT.model.services.find(service => service.key === 'demo') + expect(demo).toMatchObject({ key: 'demo' }) + expect(demo?.members.map(member => member.signature)).toContain( + 'inspect(agent: Agent<{ ready: true }>, flags: Flags): Present', + ) + + const declarationPath = join(root, 'host.d.ts') + const consumerPath = join(root, 'consumer.ts') + const sourceStubPath = join(root, 'source.d.ts') + writeFileSync(declarationPath, artifact.dts) + writeFileSync(consumerPath, [ + 'import { Payload } from \'./host.js\'', + 'import type { Payload as SourcePayload } from \'@fixture/host\'', + 'import type { z } from \'zod\'', + 'const precise: z.ZodType = Payload', + 'void precise', + '', + ].join('\n')) + writeFileSync(sourceStubPath, [ + 'declare module \'@fixture/host\' {', + ' export interface Payload { name: string; count?: number }', + '}', + '', + ].join('\n')) + const program = ts.createProgram({ + rootNames: [consumerPath, declarationPath, sourceStubPath], + options: { + strict: true, + noEmit: true, + skipLibCheck: false, + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }, + }) + const diagnostics = ts.getPreEmitDiagnostics(program) + expect(diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) + }) +}) diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/typert/generator/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/typert/loader/README.i18n.yaml b/packages/typert/loader/README.i18n.yaml new file mode 100644 index 0000000000..892e6515eb --- /dev/null +++ b/packages/typert/loader/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/typert/loader/README.md +README.md: ab9293de1630fdbe8c560bb9e6d00c272cc34161 +README.zh.md: 7ececd07ac9a12bc04dca8206e348c25adc4ee76 diff --git a/packages/typert/loader/README.md b/packages/typert/loader/README.md new file mode 100644 index 0000000000..ab9293de16 --- /dev/null +++ b/packages/typert/loader/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-typert-loader + +English | [中文](README.zh.md) + +Node-only Loader integration for generated Typert artifacts. The plugin requires `ctx.loader` and `ctx.typert`; it does not provide the registry itself. + +During activation it scans existing Loader entries. It then follows Cordis `internal/plugin` lifecycle notifications, resolves each entry package's `package.json`, imports `./typert` when exported, validates its `TYPERT` manifest, and registers the contribution until the entry or this plugin unmounts. An import that settles after either owner is gone is discarded. + +`packages` lists additional package artifacts to register for plugins nested behind another Loader entry. Cordis fibers do not retain those nested plugins' npm specifiers, so this boundary is explicit; every configured package must resolve from the config tree and export `./typert`. + +Packages without the export are skipped. Package resolution and imported manifests are cached for the process lifetime, so adding an export requires a restart. A malformed artifact fails activation when already mounted; a later failure is logged without preventing unrelated packages from registering. + +## Model Experience + +None, as the loader only feeds [`ctx.typert`](../registry/README.md); consumers own any model-visible projection. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + +- Discovery imports only the host face; client runtimes need a separate composition owner before equivalent discovery is added. +- Loader entries are discovered automatically. Nested or non-Loader plugins require an explicit `packages` entry or direct `ctx.typert.register()` ownership. diff --git a/packages/typert/loader/README.zh.md b/packages/typert/loader/README.zh.md new file mode 100644 index 0000000000..7ececd07ac --- /dev/null +++ b/packages/typert/loader/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-typert-loader + +[English](README.md) | 中文 + +生成的 Typert 产物所用的 Loader 集成,仅支持 Node。该插件需要 `ctx.loader` 和 `ctx.typert`;它本身不提供注册表。 + +激活时,该插件会扫描现有的 Loader 配置项。随后它会监听 Cordis `internal/plugin` 生命周期通知,解析每个配置项所属包(package)的 `package.json`,在其导出 `./typert` 时导入该子路径,校验其 `TYPERT` manifest(元数据清单),并注册该贡献项,直到配置项或本插件卸载。如果导入操作在配置项或本插件卸载后才结束,系统会丢弃其结果。 + +`packages` 用于列出需要为嵌套在另一 Loader 配置项下的插件额外注册的包产物。Cordis fiber 不会保留这些嵌套插件的 npm 包说明符,因此这里通过显式配置划定边界;配置中列出的每个包都必须能从配置树解析,并导出 `./typert`。 + +未导出该子路径的包会被跳过。包解析结果和已导入的 manifest 会在整个进程生命周期内缓存,因此新增该导出后必须重启进程。如果已经挂载的产物格式错误,插件激活会失败;后续失败只会记录到日志,不会阻止无关包完成注册。 + +## 模型体验 + +无。loader 只向 [`ctx.typert`](../registry/README.md) 提供注册项;任何模型可见投影均由消费方负责。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与暂缓工作 + +- 发现机制只会导入宿主侧产物;若要为客户端运行时添加等价的发现机制,需要先有独立的组合所有者。 +- Loader 配置项会自动发现。嵌套插件或非 Loader 插件需要显式加入 `packages`,或由组合所有者直接负责调用 `ctx.typert.register()`。 diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json new file mode 100644 index 0000000000..5826b96b5a --- /dev/null +++ b/packages/typert/loader/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-typert-loader", + "description": "Loader integration for generated Typert package contributions", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts new file mode 100644 index 0000000000..9485a76f05 --- /dev/null +++ b/packages/typert/loader/src/index.ts @@ -0,0 +1,351 @@ +/** + * Typert Loader integration: automatic registration for mounted plugin packages. + * + * When a loader entry mounts, this plugin resolves the entry's package.json; a + * package exporting `./typert` has its host face imported and its + * `TYPERT` manifest registered into `ctx.typert`, and the registration is + * withdrawn when the entry unmounts. Explicit `packages` cover plugins nested + * behind another Loader entry, whose Cordis fibers carry no resolvable package + * specifier. Packages without the export are skipped silently when discovered + * from Loader entries; an explicit package or declared artifact that is broken + * fails loud — aggregated into this plugin's activation throw for existing + * entries, contained to a logged error per package in steady state. + * + * Scanning is incremental per entry name, mirroring the client-modules node + * half: every cordis `internal/plugin` emission marks the fiber's entry name + * dirty and a microtask flush reconciles each dirty name against the live + * loader entries; the activation pass seeds the same dirty set with all + * current entries. Package verdicts and imported manifests are cached per + * package name and never expire — plugin-set changes take effect on restart. + * + * Manual `ctx.typert.register()` remains the escape hatch for contributions + * that do not ride a `./typert` artifact (hand-written contract schemas, + * tests, non-loader compositions). + * + * @module @deepseek-ai/dsh-typert-loader + */ + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/dsh-typert-registry' +import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' + +/** The package.json exports key naming a package's host-face typert artifact. */ +export const TYPERT_HOST_EXPORT = './typert' + +/** Cordis plugin name. */ +export const name = 'typert-loader' +/** Services required before registration: the registry this plugin feeds and the Loader it observes. */ +export const inject = ['typert', 'loader'] + +/** Additional package artifacts whose owning plugins are nested behind another Loader entry. */ +export interface Config { + /** Exact npm package names that must resolve and export `./typert`. */ + packages?: string[] +} + +/** Validate explicit package names and default to Loader-entry discovery only. */ +export const Config: z = z.object({ + packages: z.array(z.string().min(1)).default([]), +}) + +type ResolvedConfig = Required + +const MEMBER_KINDS = new Set(['property', 'method', 'getter', 'setter', 'call', 'construct', 'index']) + +/** Resolve the `./typert` export to a relative path, accepting the string and one-level conditional forms. */ +function typertExportOf(pkgName: string, exportsField: unknown): string | undefined { + if (typeof exportsField !== 'object' || exportsField === null) return undefined + const target = (exportsField as Record)[TYPERT_HOST_EXPORT] + if (target === undefined) return undefined + if (typeof target === 'string') return target + if (typeof target === 'object' && target !== null) { + const fallback = (target as Record).default + if (typeof fallback === 'string') return fallback + } + throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] has an unsupported shape`) +} + +/** + * Narrow a dynamically imported typert module's `TYPERT` export to a + * contribution owned by `pkgName`. This is the module/file boundary: the + * manifest crosses from a build artifact into the typed registry, so every + * field is checked and every failure names the package and the defect. + * @param pkgName - the package whose typert face was imported. + * @param exported - the module's `TYPERT` export. + * @returns the validated contribution. + */ +export function validateTypertManifest(pkgName: string, exported: unknown): TypertContribution { + if (typeof exported !== 'object' || exported === null) { + throw new Error(`typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but its module has no TYPERT manifest object`) + } + const manifest = exported as Record + if (manifest.package !== pkgName) { + throw new Error( + `typert-loader: ${pkgName} TYPERT manifest names package ${JSON.stringify(manifest.package)} — the manifest must be owned by the package that exports it`, + ) + } + if (manifest.face !== 'host') { + throw new Error(`typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but TYPERT.face is not "host"`) + } + if (!Array.isArray(manifest.schemas)) { + throw new Error(`typert-loader: ${pkgName} TYPERT.schemas must be an array`) + } + for (const value of manifest.schemas as unknown[]) { + if (typeof value !== 'object' || value === null) { + throw new Error(`typert-loader: ${pkgName} TYPERT.schemas contains a non-object schema`) + } + const schema = value as Record + requireString(pkgName, schema, 'name', 'schema') + if (typeof schema.schema !== 'object' || schema.schema === null || !('_zod' in schema.schema)) { + throw new Error(`typert-loader: ${pkgName} TYPERT schema "${schema.name as string}" is not a zod v4 schema instance`) + } + } + const model = requireObject(pkgName, manifest.model, 'TYPERT.model') + const services = requireArray(pkgName, model.services, 'TYPERT.model.services') + const events = requireArray(pkgName, model.events, 'TYPERT.model.events') + const objects = requireArray(pkgName, model.objects, 'TYPERT.model.objects') + for (const value of services) { + const service = requireObject(pkgName, value, 'service') + requireDocumentation(pkgName, service, 'service') + requireString(pkgName, service, 'key', 'service') + requireString(pkgName, service, 'exportName', 'service') + requireMembers(pkgName, service.members, `service "${service.key as string}"`) + requireTypes(pkgName, service.types, `service "${service.key as string}"`) + } + for (const value of events) { + const event = requireObject(pkgName, value, 'event') + requireDocumentation(pkgName, event, 'event') + requireString(pkgName, event, 'name', 'event') + requireString(pkgName, event, 'signature', `event "${event.name as string}"`) + if (event.mode !== undefined && typeof event.mode !== 'string') { + throw new Error(`typert-loader: ${pkgName} event "${event.name as string}" mode must be a string`) + } + } + for (const value of objects) { + const object = requireObject(pkgName, value, 'object') + requireDocumentation(pkgName, object, 'object') + requireString(pkgName, object, 'name', 'object') + requireString(pkgName, object, 'exportName', 'object') + requireMembers(pkgName, object.members, `object "${object.name as string}"`) + requireTypes(pkgName, object.types, `object "${object.name as string}"`) + } + return manifest as unknown as TypertContribution +} + +function requireObject(pkgName: string, value: unknown, subject: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`typert-loader: ${pkgName} ${subject} must be an object`) + } + return value as Record +} + +function requireArray(pkgName: string, value: unknown, subject: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`typert-loader: ${pkgName} ${subject} must be an array`) + return value +} + +function requireString(pkgName: string, value: Record, key: string, subject: string): void { + if (typeof value[key] !== 'string' || value[key].length === 0) { + throw new Error(`typert-loader: ${pkgName} ${subject} has a missing or empty ${key}`) + } +} + +function requireDocumentation(pkgName: string, value: Record, subject: string): void { + requireArray(pkgName, value.tags, `${subject}.tags`) + for (const key of ['description', 'summary', 'jsDoc'] as const) { + if (value[key] !== undefined && typeof value[key] !== 'string') { + throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`) + } + } +} + +function requireMembers(pkgName: string, value: unknown, subject: string): void { + for (const item of requireArray(pkgName, value, `${subject}.members`)) { + const member = requireObject(pkgName, item, `${subject} member`) + requireString(pkgName, member, 'name', `${subject} member`) + requireString(pkgName, member, 'signature', `${subject} member`) + if (typeof member.kind !== 'string' || !MEMBER_KINDS.has(member.kind)) { + throw new Error(`typert-loader: ${pkgName} ${subject} member "${member.name as string}" has invalid kind`) + } + } +} + +function requireTypes(pkgName: string, value: unknown, subject: string): void { + for (const item of requireArray(pkgName, value, `${subject}.types`)) { + const type = requireObject(pkgName, item, `${subject} type`) + requireString(pkgName, type, 'name', `${subject} type`) + requireString(pkgName, type, 'declaration', `${subject} type`) + } +} + +/** + * Scan current Loader entries during activation, then follow entry mounts and + * unmounts for this plugin's lifetime. + * @param ctx - plugin context carrying `typert` and `loader`. + * @param config - explicit package artifacts in addition to Loader entries. + */ +export async function apply(ctx: Context, config: Config): Promise { + // Resolution anchor: the config tree's baseUrl (the cordis.yml directory, + // whose package declares every composed plugin as a dependency). This + // package's own URL would miss sibling packages under pnpm's isolated + // node_modules. + if (ctx.baseUrl === undefined) { + throw new Error('typert-loader: ctx.baseUrl is unset — the loader needs the config-tree anchor to resolve plugin packages') + } + const require = createRequire(ctx.baseUrl) + const configured = new Set((config as ResolvedConfig).packages) + + // Registered contributions by entry name; the disposer withdraws the entry's registration. + const registered = new Map void>() + // In-flight import/register tasks by entry name. + const pending = new Map>() + // Artifact paths by package name. Negative verdicts (unresolvable specifier — + // loader builtins, subpath rows — or no typert export) are cached as null and + // never expire: plugin-set changes take effect on restart. + const artifactPath = new Map() + // Imported+validated manifests by package name (one import per package per process). + const manifests = new Map>() + const dirty = new Set() + let flushQueued = false + let active = true + ctx.effect(function* () { + yield () => { + active = false + dirty.clear() + } + }, 'typert loader lifetime') + + const resolveArtifact = (pkgName: string): string | null => { + const cached = artifactPath.get(pkgName) + if (cached !== undefined) return cached + let pkgPath: string + try { + pkgPath = require.resolve(`${pkgName}/package.json`) + } catch (cause) { + if (configured.has(pkgName)) { + throw new Error( + `typert-loader: configured package "${pkgName}" cannot be resolved from the config tree — add it to the composition package dependencies or remove it from packages`, + { cause }, + ) + } + // Not a resolvable package root: loader builtins (cordis:include) and + // subpath entries land here — permanently not a typert contributor. + artifactPath.set(pkgName, null) + return null + } + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record + const rel = typertExportOf(pkgName, pkg.exports) + if (rel === undefined && configured.has(pkgName)) { + throw new Error(`typert-loader: configured package "${pkgName}" does not export "${TYPERT_HOST_EXPORT}"`) + } + const resolved = rel === undefined ? null : join(dirname(pkgPath), rel) + artifactPath.set(pkgName, resolved) + return resolved + } + + const loadManifest = (pkgName: string, path: string): Promise => { + let loading = manifests.get(pkgName) + if (loading === undefined) { + loading = import(pathToFileURL(path).href).then( + (mod: Record) => validateTypertManifest(pkgName, mod.TYPERT), + (cause: unknown) => { + throw new Error( + `typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but importing ${path} failed: ${String(cause)}`, + ) + }, + ) + manifests.set(pkgName, loading) + } + return loading + } + + const qualifies = (entryName: string): boolean => { + if (configured.has(entryName)) return true + for (const entry of ctx.loader.entries()) { + if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) return true + } + return false + } + + /** Reconcile one entry name against the live loader entries; a mount returns its async task. */ + const processOne = (entryName: string): Promise | undefined => { + if (!qualifies(entryName)) { + const dispose = registered.get(entryName) + if (dispose !== undefined) { + registered.delete(entryName) + dispose() + } + return undefined + } + if (registered.has(entryName) || pending.has(entryName)) return undefined + const path = resolveArtifact(entryName) + if (path === null) return undefined + const task = loadManifest(entryName, path).then((manifest) => { + // The entry may have unmounted (or already re-registered) while the import was in flight. + if (!active || !qualifies(entryName) || registered.has(entryName)) return + registered.set(entryName, ctx.typert.register(manifest)) + }) + pending.set(entryName, task) + // Two-armed settle: a bare .finally() would mint a second, unhandled rejection. + const settle = (): void => { pending.delete(entryName) } + void task.then(settle, settle) + return task + } + + const flush = (onError: (error: Error) => void): Promise[] => { + const tasks: Promise[] = [] + for (const entryName of [...dirty]) { + dirty.delete(entryName) + try { + const task = processOne(entryName) + if (task !== undefined) tasks.push(task.catch((error: unknown) => { onError(toError(error)) })) + } catch (error) { + // Steady state: one broken package must not poison the others; the + // activation pass aggregates these into a loud throw instead. + onError(toError(error)) + } + } + return tasks + } + + // Subscribe before seeding so an entry arriving mid-activation lands in the + // same dirty set (Set idempotence makes the overlap harmless). An entry-less + // fiber is a child plugin or a manual mount — never a loader row; O(1) drop. + ctx.on('internal/plugin', (fiber) => { + const entryName = fiber.entry?.options.name + if (entryName === undefined) return + dirty.add(entryName) + if (flushQueued) return + flushQueued = true + queueMicrotask(() => { + flushQueued = false + if (!active) return + for (const task of flush((err) => { ctx.logger.error(err) })) void task + }) + }) + + // Activation pass: the initial scan IS the incremental path over the current + // entries; a malformed typert contributor among the already-loaded entries + // aggregates into one loud throw (FAILED loader fiber; the boot sweep reports it). + for (const packageName of configured) dirty.add(packageName) + for (const entry of ctx.loader.entries()) dirty.add(entry.options.name) + const failures: Error[] = [] + await Promise.all(flush((err) => { failures.push(err) })) + if (failures.length > 0) { + throw new AggregateError( + failures, + `typert-loader: ${String(failures.length)} typert contributor(s) failed to register:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, + ) + } +} + +/** Normalize an arbitrary import or manifest failure to an Error. */ +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/typert/loader/src/invariant.ts b/packages/typert/loader/src/invariant.ts new file mode 100644 index 0000000000..393324e7e9 --- /dev/null +++ b/packages/typert/loader/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-typert-loader`. + * @module @deepseek-ai/dsh-typert-loader/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-typert-loader' + +/** Cordis companion plugin name. */ +export const name = 'typert-loader-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the Loader entry lifecycle directly owns each exact + * registry disposer, and integration tests observe registration and removal. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts new file mode 100644 index 0000000000..4fc6f09438 --- /dev/null +++ b/packages/typert/loader/tests/loader.spec.ts @@ -0,0 +1,462 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import * as typertLoader from '@deepseek-ai/dsh-typert-loader' +import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + Reflect.deleteProperty(globalThis, '__dshTypertLoaderGate') + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Write a fake installed package under the fixture root's node_modules. */ +async function writePackage( + base: string, + pkgName: string, + options: { + typertExport?: boolean + typertTarget?: unknown + typertSource?: string + pluginSource?: string + omitExports?: boolean + } = {}, +): Promise { + const dir = join(base, 'node_modules', ...pkgName.split('/')) + await mkdir(dir, { recursive: true }) + const exportsField: Record = { '.': './index.js', './package.json': './package.json' } + if (options.typertExport !== false && options.typertSource !== undefined) { + exportsField['./typert'] = options.typertTarget ?? './typert.host.js' + } + await writeFile(join(dir, 'package.json'), JSON.stringify({ + name: pkgName, + type: 'module', + ...(options.omitExports ? { main: './index.js' } : { exports: exportsField }), + })) + await writeFile(join(dir, 'index.js'), options.pluginSource ?? 'export function apply() {}\n') + if (options.typertSource !== undefined) { + await writeFile(join(dir, 'typert.host.js'), options.typertSource) + } +} + +function typertSource(pkgName: string, entryName: string): string { + return [ + 'import { z } from \'zod\'', + `export const ${entryName} = z.object({ id: z.string() })`, + 'export const TYPERT = {', + ` package: '${pkgName}',`, + ' face: \'host\',', + ` schemas: [{ name: '${entryName}', schema: ${entryName} }],`, + ' model: { services: [], events: [], objects: [] },', + '}', + '', + ].join('\n') +} + +/** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */ +async function boot(): Promise { + context = new Context() + context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href + await context.plugin(TypertRegistry) + await context.plugin(Loader) + // zod must be resolvable from the fixture packages; link the workspace copy. + await mkdir(join(root as string, 'node_modules'), { recursive: true }) + return context +} + +async function linkZod(base: string): Promise { + const { symlink } = await import('node:fs/promises') + const target = join(base, 'node_modules', 'zod') + const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '') + await mkdir(join(base, 'node_modules'), { recursive: true }) + await symlink(source, target, 'dir') +} + +function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType { + return ctx.plugin(typertLoader, config) +} + +// Fixture setup writes fake installed packages and boots a real Loader; the +// default 5s deadline is too tight on slow CI filesystems. +const LOADER_TEST_TIMEOUT = { timeout: 60_000 } + +describe('typert loader', () => { + it('registers an explicit package without a Loader entry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/nested', { typertSource: typertSource('@fixture/nested', 'Nested') }) + const ctx = await boot() + + const fiber = mountTypertLoader(ctx, { packages: ['@fixture/nested'] }) + await fiber + expect(ctx.typert.get('@fixture/nested#Nested')).toBeDefined() + + await fiber.dispose() + expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() + }) + + it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await writePackage(root, '@fixture/plain') + const ctx = await boot() + + let failure: unknown + try { + await mountTypertLoader(ctx, { packages: ['@fixture/missing', '@fixture/plain'] }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as Error).message).toContain('configured package "@fixture/missing" cannot be resolved') + expect((failure as Error).message).toContain('configured package "@fixture/plain" does not export "./typert"') + }) + + it('auto-registers a mounted package exporting ./typert and withdraws it on unmount', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/with-typert', { typertSource: typertSource('@fixture/with-typert', 'Thing') }) + await writePackage(root, '@fixture/plain') + const ctx = await boot() + + const id = await ctx.loader.create({ name: '@fixture/with-typert' }) + const plainId = await ctx.loader.create({ name: '@fixture/plain' }) + await ctx.loader.await() + await mountTypertLoader(ctx) + await ctx.loader.await() + + const record = ctx.typert.get('@fixture/with-typert#Thing') + expect(record).toMatchObject({ package: '@fixture/with-typert', face: 'host', name: 'Thing' }) + expect(record?.schema.safeParse({ id: 'x' }).success).toBe(true) + // The plain package is silently skipped. + expect(ctx.typert.list().map(r => r.key)).toEqual(['@fixture/with-typert#Thing']) + + const mounted = [...ctx.loader.entries()].find(entry => entry.options.name === '@fixture/with-typert') + if (mounted?.fiber === undefined) throw new Error('fixture loader entry has no fiber') + ctx.emit('internal/plugin', mounted.fiber) + ctx.emit('internal/plugin', mounted.fiber) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(ctx.typert.list()).toHaveLength(1) + + ctx.loader.remove(id) + await ctx.loader.await() + // The unmount reconciliation rides a queued microtask flush. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() + ctx.loader.remove(plainId) + await ctx.loader.await() + await new Promise(resolve => setTimeout(resolve, 20)) + + await ctx.loader.create({ name: '@fixture/with-typert' }) + await ctx.loader.await() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeDefined() + }) + + it('follows entries mounted after activation', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/late', { typertSource: typertSource('@fixture/late', 'Late') }) + const ctx = await boot() + await mountTypertLoader(ctx) + + expect(ctx.typert.get('@fixture/late#Late')).toBeUndefined() + await ctx.loader.create({ name: '@fixture/late' }) + await ctx.loader.await() + // The microtask flush and the dynamic import need a turn to settle. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(ctx.typert.get('@fixture/late#Late')).toBeDefined() + }) + + it('drops an in-flight manifest when the loader is disposed before import settles', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + let markStarted: (() => void) | undefined + const started = new Promise((resolve) => { markStarted = resolve }) + let releaseImport: (() => void) | undefined + const wait = new Promise((resolve) => { releaseImport = resolve }) + Reflect.set(globalThis, '__dshTypertLoaderGate', { + started: (): void => { markStarted?.() }, + wait, + }) + await writePackage(root, '@fixture/pending', { + typertSource: [ + 'import { z } from \'zod\'', + 'globalThis.__dshTypertLoaderGate.started()', + 'await globalThis.__dshTypertLoaderGate.wait', + 'export const Pending = z.object({ id: z.string() })', + 'export const TYPERT = {', + ' package: \'@fixture/pending\',', + ' face: \'host\',', + ' schemas: [{ name: \'Pending\', schema: Pending }],', + ' model: { services: [], events: [], objects: [] },', + '}', + '', + ].join('\n'), + }) + const ctx = await boot() + const loaderFiber = mountTypertLoader(ctx) + await loaderFiber + await ctx.loader.create({ name: '@fixture/pending' }) + await ctx.loader.await() + await started + + const mounted = [...ctx.loader.entries()].find(entry => entry.options.name === '@fixture/pending') + if (mounted?.fiber === undefined) throw new Error('fixture loader entry has no fiber') + ctx.emit('internal/plugin', mounted.fiber) + await Promise.resolve() + + let queued: (() => void) | undefined + const queue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((callback) => { queued = callback }) + ctx.emit('internal/plugin', mounted.fiber) + queue.mockRestore() + + await loaderFiber.dispose() + queued?.() + releaseImport?.() + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(ctx.typert.getPackage('@fixture/pending')).toBeUndefined() + }) + + it('fails activation loud when an already-mounted contributor is malformed', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/broken', { + typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n', + }) + const ctx = await boot() + await ctx.loader.create({ name: '@fixture/broken' }) + await ctx.loader.await() + + await expect(mountTypertLoader(ctx)).rejects.toThrow(/typert contributor\(s\) failed to register/) + }) + + it('fails loud when the declared typert module cannot be imported', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/no-module', { + typertSource: 'import { missing } from \'./nope.js\'\nexport const TYPERT = missing\n', + }) + const ctx = await boot() + await ctx.loader.create({ name: '@fixture/no-module' }) + await ctx.loader.await() + + await expect(mountTypertLoader(ctx)).rejects.toThrow(/importing .* failed/) + }) + + it('accepts conditional artifact exports and skips packages with no exports field', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/conditional', { + typertSource: typertSource('@fixture/conditional', 'Conditional'), + typertTarget: { default: './typert.host.js' }, + }) + await writePackage(root, '@fixture/no-exports', { omitExports: true }) + const ctx = await boot() + await ctx.loader.create({ name: '@fixture/conditional' }) + await ctx.loader.create({ name: '@fixture/no-exports' }) + await ctx.loader.await() + + await mountTypertLoader(ctx) + + expect(ctx.typert.get('@fixture/conditional#Conditional')).toBeDefined() + expect(ctx.typert.getPackage('@fixture/no-exports')).toBeUndefined() + }) + + it('aggregates unsupported package export shapes during activation', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/export-shape', { + typertSource: typertSource('@fixture/export-shape', 'Shape'), + typertTarget: { default: 1 }, + }) + await writePackage(root, '@fixture/export-primitive', { + typertSource: typertSource('@fixture/export-primitive', 'Primitive'), + typertTarget: 1, + }) + const ctx = await boot() + await ctx.loader.create({ name: '@fixture/export-shape' }) + await ctx.loader.create({ name: '@fixture/export-primitive' }) + await ctx.loader.await() + + await expect(mountTypertLoader(ctx)).rejects.toThrow('unsupported shape') + }) + + it('caches a negative verdict for loader entries without a package root', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + const ctx = await boot() + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier !== 'virtual-plugin') throw new Error(`unexpected fixture import ${specifier}`) + return { apply() {} } + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'virtual-plugin' }) + await ctx.loader.await() + + await mountTypertLoader(ctx) + + expect(ctx.typert.getPackage('virtual-plugin')).toBeUndefined() + }) + + it('requires a config-tree resolution anchor', LOADER_TEST_TIMEOUT, async () => { + context = new Context() + await context.plugin(TypertRegistry) + await context.plugin(Loader) + + await expect(mountTypertLoader(context)).rejects.toThrow('ctx.baseUrl is unset') + }) + + it('contains steady-state registration failures and normalizes non-Error throws', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/steady-failure', { + typertSource: typertSource('@fixture/steady-failure', 'Steady'), + }) + const ctx = await boot() + await mountTypertLoader(ctx) + const logged = vi.spyOn(ctx.logger, 'error').mockImplementation(() => undefined) + vi.spyOn(ctx.typert, 'register').mockImplementation(() => { throw 'register failed' }) + + await ctx.loader.create({ name: '@fixture/steady-failure' }) + await ctx.loader.await() + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined() + }) +}) + +describe('validateTypertManifest', () => { + const zodish = { _zod: {} } + + it('accepts a well-formed manifest and rejects each malformed field loudly', () => { + expect(validateTypertManifest('pkg', { + package: 'pkg', + face: 'host', + schemas: [{ name: 'A', schema: zodish }], + model: { services: [], events: [], objects: [] }, + }).schemas).toHaveLength(1) + + expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object') + expect(() => validateTypertManifest('pkg', { package: 'other' })).toThrow('must be owned by the package') + expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'client' })).toThrow('TYPERT.face is not "host"') + expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: 'x' })).toThrow('schemas must be an array') + expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [null] })).toThrow('non-object schema') + expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [{ name: '', schema: zodish }] })) + .toThrow('missing or empty name') + expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [{ name: 'A', schema: {} }] })) + .toThrow('not a zod v4 schema instance') + expect(() => validateTypertManifest('pkg', { + package: 'pkg', + face: 'host', + schemas: [], + model: { services: [{ key: 'tools', exportName: 'ToolRegistry', tags: [], members: 'x', types: [] }], events: [], objects: [] }, + })).toThrow('service "tools".members must be an array') + }) + + it('validates service, event, object, member, type, and documentation records', () => { + const complete = completeManifest(zodish) + expect(validateTypertManifest('pkg', complete)).toBe(complete) + + expect(() => validateTypertManifest('pkg', { ...complete, model: [] })) + .toThrow('TYPERT.model must be an object') + expect(() => validateTypertManifest('pkg', { ...complete, model: { ...complete.model, services: [null] } })) + .toThrow('service must be an object') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, services: [{ ...complete.model.services[0], tags: 'bad' }] }, + })).toThrow('service.tags must be an array') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, services: [{ ...complete.model.services[0], description: 1 }] }, + })).toThrow('service.description must be a string') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, services: [{ ...complete.model.services[0], key: '' }] }, + })).toThrow('service has a missing or empty key') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, services: [{ ...complete.model.services[0], members: [null] }] }, + })).toThrow('member must be an object') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { + ...complete.model, + services: [{ ...complete.model.services[0], members: [{ name: 'member', signature: 'member(): void', kind: 1 }] }], + }, + })).toThrow('has invalid kind') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { + ...complete.model, + services: [{ ...complete.model.services[0], members: [{ name: 'member', signature: 'member(): void', kind: 'future' }] }], + }, + })).toThrow('has invalid kind') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, services: [{ ...complete.model.services[0], types: [null] }] }, + })).toThrow('type must be an object') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { + ...complete.model, + services: [{ ...complete.model.services[0], types: [{ name: 'Type', declaration: '' }] }], + }, + })).toThrow('type has a missing or empty declaration') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, events: [{ ...complete.model.events[0], mode: 1 }] }, + })).toThrow('mode must be a string') + expect(() => validateTypertManifest('pkg', { ...complete, model: { ...complete.model, objects: [null] } })) + .toThrow('object must be an object') + expect(() => validateTypertManifest('pkg', { + ...complete, + model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, + })).toThrow('object has a missing or empty exportName') + }) +}) + +function completeManifest(zodish: object) { + const member = { name: 'member', signature: 'member(): void', kind: 'method' } + const type = { name: 'Value', declaration: 'export interface Value {}' } + return { + package: 'pkg', + face: 'host', + schemas: [{ name: 'Schema', schema: zodish }], + model: { + services: [{ + key: 'service', + exportName: 'Service', + description: 'Service description.', + summary: 'Service description.', + jsDoc: '/** Service description. */', + tags: [], + members: [member], + types: [type], + }], + events: [ + { name: 'event/with-mode', mode: 'emit', signature: "'event/with-mode'(): void", tags: [] }, + { name: 'event/without-mode', signature: "'event/without-mode'(): void", tags: [] }, + ], + objects: [{ + name: 'Object', + exportName: 'Object', + tags: [], + members: [member], + types: [type], + }], + }, + } +} diff --git a/packages/typert/loader/tsconfig.json b/packages/typert/loader/tsconfig.json new file mode 100644 index 0000000000..3e64878280 --- /dev/null +++ b/packages/typert/loader/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../registry" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml new file mode 100644 index 0000000000..a9a449649f --- /dev/null +++ b/packages/typert/registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/typert/registry/README.md +README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 +README.zh.md: 6ef8b22805e21a379b4c2fac4bf9fbae447c41a1 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md new file mode 100644 index 0000000000..83c03ab284 --- /dev/null +++ b/packages/typert/registry/README.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-typert-registry + +English | [中文](README.zh.md) + +Runtime registry for generated Typert artifacts. A contribution carries one package face's business reflection and optional live Zod schemas; `ctx.typert` registers both atomically and withdraws them with the calling Cordis fiber. TypeScript analysis and code generation live in [`dsh-typert-generator`](../generator/README.md). + +Package reflection is keyed by `#`. Schemas are keyed by `#` and retain the producer's Zod instance. JSON Schema is computed on demand at the consumer edge. + +## Public API + +- `TypertRegistry` is the default plugin and provides `ctx.typert`. +- `register(contribution)` rejects malformed identities and duplicate package-face or schema keys before committing anything, then returns the exact Cordis effect disposer. +- `get(key)`, `resolve(key)`, and `list(filter?)` query live schemas. `resolve()` distinguishes a malformed key, an absent package, and a package that contributes no schema under that name. +- `getPackage(packageName, face?)` and `listPackages(filter?)` query generated service, event, and object reflection; the default face is `host`. +- `toJSONSchema(key, params?)` projects a live schema with `z.toJSONSchema()` without caching the result. +- `typertKey()` and `typertPackageKey()` compose the two stable identity forms. + +The `@deepseek-ai/dsh-typert-registry/types` subpath contains the pure contribution and record contracts. [`dsh-typert-loader`](../loader/README.md) discovers and registers generated host artifacts in Loader compositions; direct `ctx.typert.register()` supports other composition owners. + +## Model Experience + +None, as the registry contributes no prompt, tool, or session event; consumers such as `cordis_inspect` own any model-visible projection. + +#### KV Cache effect + +No direct effect. A consumer that places reflection in a request owns the resulting prefix change. + +## Known Limitations and Deferred Work + +- The registry stores generated reflection but does not merge host and client graphs or resolve TypeScript references. Those are analyzer and emitter concerns. +- Schema keys omit the face because host and client run in separate contexts. Registering same-named schemas from both faces into one context is rejected as a duplicate. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md new file mode 100644 index 0000000000..6ef8b22805 --- /dev/null +++ b/packages/typert/registry/README.zh.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-typert-registry + +[English](README.md) | 中文 + +生成的 Typert 产物所用的运行时注册表。每个注册项包含某个包(package)在一个 face 上的业务反射信息,以及可选的运行时 Zod schema;`ctx.typert` 会以原子方式同时注册两者,并在发起调用的 Cordis fiber 释放时一并移除它们。TypeScript 分析和代码生成由 [`dsh-typert-generator`](../generator/README.md) 负责。 + +包反射信息以 `#` 为键。schema 以 `#` 为键,并保留生成方的 Zod 实例。系统按需在消费方边界计算 JSON Schema。 + +## 公开 API + +- `TypertRegistry` 是默认插件,并提供 `ctx.typert`。 +- `register(contribution)` 会在提交任何内容之前拒绝格式错误的标识,以及重复的包与 face 组合键或 schema 键,随后返回 Cordis effect 提供的同一资源释放函数。 +- `get(key)`、`resolve(key)` 和 `list(filter?)` 查询当前有效的 schema。`resolve()` 能区分格式错误的键、未注册的包,以及已注册但未以该名称提供 schema 的包。 +- `getPackage(packageName, face?)` 和 `listPackages(filter?)` 查询生成的服务、事件和对象反射信息;默认 face 为 `host`。 +- `toJSONSchema(key, params?)` 使用 `z.toJSONSchema()` 投影当前有效的 schema,且不缓存结果。 +- `typertKey()` 和 `typertPackageKey()` 构造两种稳定的标识形式。 + +`@deepseek-ai/dsh-typert-registry/types` 子路径包含注册项和记录的纯类型契约。[`dsh-typert-loader`](../loader/README.md) 会在 Loader 组合中发现并注册生成的宿主侧产物;其他组合所有者可以直接调用 `ctx.typert.register()`。 + +## 模型体验 + +无。注册表不会提供提示词、工具或会话事件;所有模型可见投影均由 `cordis_inspect` 等消费方负责。 + +#### KV Cache 影响 + +无直接影响。将反射信息放入请求的消费方负责由此产生的前缀变化。 + +## 已知限制与暂缓工作 + +- 注册表存储生成的反射信息,但不会合并宿主侧与客户端侧的图,也不会解析 TypeScript 引用;这些由分析器和产物输出器负责。 +- schema 键不包含 face,因为宿主侧和客户端侧在不同的上下文中运行。若在同一上下文中注册来自两个 face 的同名 schema,系统会将其作为重复项拒绝。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json new file mode 100644 index 0000000000..986ac55a37 --- /dev/null +++ b/packages/typert/registry/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-typert-registry", + "description": "Runtime registry for generated package reflection and Zod schemas", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/typert/registry/src/index.ts b/packages/typert/registry/src/index.ts new file mode 100644 index 0000000000..91a8383594 --- /dev/null +++ b/packages/typert/registry/src/index.ts @@ -0,0 +1,219 @@ +/** + * Runtime registry for generated Typert contributions. It owns live Zod + * instances and generated package reflection, but performs no TypeScript + * analysis or schema generation. + * @module @deepseek-ai/dsh-typert-registry + */ + +import { Context, Service } from 'cordis' +import { z } from 'zod' +import type { + TypertContribution, + TypertFace, + TypertPackageFilter, + TypertPackageRecord, + TypertSchemaFilter, + TypertSchemaRecord, +} from './types.ts' + +export type { + TypertContribution, + TypertDocTag, + TypertDocumentation, + TypertEventModel, + TypertFace, + TypertMemberModel, + TypertObjectModel, + TypertPackageFilter, + TypertPackageModel, + TypertPackageRecord, + TypertSchema, + TypertSchemaFilter, + TypertSchemaRecord, + TypertServiceModel, + TypertTypeModel, +} from './types.ts' + +declare module 'cordis' { + interface Context { + typert: TypertRegistry + } +} + +/** + * Compose the global key of one generated schema. + * @param packageName - contributing npm package. + * @param name - schema export name. + * @returns `#`. + */ +export function typertKey(packageName: string, name: string): string { + return `${packageName}#${name}` +} + +/** + * Compose the identity of one package-face model. + * @param packageName - contributing npm package. + * @param face - independently compiled face. + * @returns `#`. + */ +export function typertPackageKey(packageName: string, face: TypertFace): string { + return `${packageName}#${face}` +} + +/** + * Registry of generated schemas and package reflection. + * @typert service + */ +export class TypertRegistry extends Service { + private readonly schemas = new Map() + private readonly packages = new Map() + + constructor(ctx: Context) { + super(ctx, 'typert') + } + + /** + * Register one generated contribution atomically for the calling fiber. + * Duplicate package-face identities or schema keys reject the whole batch. + * @param contribution - generated schemas and package metadata. + * @returns the exact effect disposer that removes this contribution. + */ + register(contribution: TypertContribution): () => void { + const packageRecord = this.validatePackage(contribution) + const schemaRecords = this.validateSchemas(contribution) + const { schemas, packages } = this + const dispose = this.ctx.effect(function* () { + packages.set(packageRecord.key, packageRecord) + for (const record of schemaRecords) schemas.set(record.key, record) + yield () => { + packages.delete(packageRecord.key) + for (const record of schemaRecords) schemas.delete(record.key) + } + }, 'typert.register()') + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity + return dispose + } + + /** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ + get(key: string): TypertSchemaRecord | undefined { + return this.schemas.get(key) + } + + /** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ + resolve(key: string): TypertSchemaRecord { + const record = this.schemas.get(key) + if (record !== undefined) return record + const hash = key.indexOf('#') + if (hash <= 0 || hash === key.length - 1) { + throw new Error(`typert: invalid schema key "${key}" — expected "#"`) + } + const packageName = key.slice(0, hash) + if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { + throw new Error( + `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, + ) + } + throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) + } + + /** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ + list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { + return [...this.schemas.values()].filter(record => matches(record, filter)) + } + + /** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ + getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { + return this.packages.get(typertPackageKey(packageName, face)) + } + + /** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ + listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { + return [...this.packages.values()].filter(record => matches(record, filter)) + } + + /** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { + return z.toJSONSchema(this.resolve(key).schema, params) + } + + private validatePackage(contribution: TypertContribution): TypertPackageRecord { + validateSegment('package name', contribution.package) + const face: unknown = contribution.face + if (face !== 'host' && face !== 'client') { + throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) + } + const key = typertPackageKey(contribution.package, contribution.face) + if (this.packages.has(key)) { + throw new Error(`typert: package face "${key}" is already registered`) + } + return { + package: contribution.package, + face, + key, + model: contribution.model, + } + } + + private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { + const records: TypertSchemaRecord[] = [] + const batch = new Set() + for (const schema of contribution.schemas) { + validateSegment('schema name', schema.name) + const key = typertKey(contribution.package, schema.name) + if (batch.has(key) || this.schemas.has(key)) { + throw new Error(`typert: schema "${key}" is already registered`) + } + batch.add(key) + records.push({ + ...schema, + package: contribution.package, + face: contribution.face, + key, + }) + } + return records + } +} + +function matches( + record: { readonly package: string; readonly face: TypertFace }, + filter: { readonly package?: string; readonly face?: TypertFace }, +): boolean { + return (filter.package === undefined || record.package === filter.package) + && (filter.face === undefined || record.face === filter.face) +} + +function validateSegment(subject: string, value: string): void { + if (value.length === 0 || value.includes('#')) { + throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) + } +} + +export default TypertRegistry diff --git a/packages/typert/registry/src/invariant.ts b/packages/typert/registry/src/invariant.ts new file mode 100644 index 0000000000..73b01a6742 --- /dev/null +++ b/packages/typert/registry/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-typert-registry`. + * @module @deepseek-ai/dsh-typert-registry/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-typert-registry' + +/** Cordis companion plugin name. */ +export const name = 'typert-registry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: schema and package-reflection records mutate together + * inside register/dispose, with no independent event or second data source to + * cross-check; duplicate identities fail at the owning operation boundary. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts new file mode 100644 index 0000000000..2fb29f5024 --- /dev/null +++ b/packages/typert/registry/src/types.ts @@ -0,0 +1,112 @@ +/** + * Pure generated-artifact and runtime-registry types. The registry stores Zod + * schemas separately from generated package reflection metadata. + * @module @deepseek-ai/dsh-typert-registry/types + */ + +import type { z } from 'zod' + +/** Independently compiled side that produced a contribution. */ +export type TypertFace = 'host' | 'client' + +/** Structured JSDoc tag retained by generated runtime metadata. */ +export interface TypertDocTag { + readonly name: string + readonly argument?: string + readonly comment?: string + readonly text: string +} + +/** Source documentation retained on reflected package elements. */ +export interface TypertDocumentation { + readonly description?: string + readonly summary?: string + readonly tags: readonly TypertDocTag[] + readonly jsDoc?: string +} + +/** One generated public member signature. */ +export interface TypertMemberModel { + readonly kind: 'property' | 'method' | 'getter' | 'setter' | 'call' | 'construct' | 'index' + readonly name: string + readonly signature: string + readonly summary?: string + readonly jsDoc?: string +} + +/** One named type declaration referenced by a reflected business surface. */ +export interface TypertTypeModel { + readonly name: string + readonly declaration: string +} + +/** Runtime reflection metadata for one Cordis service. */ +export interface TypertServiceModel extends TypertDocumentation { + readonly key: string + readonly exportName: string + readonly members: readonly TypertMemberModel[] + readonly types: readonly TypertTypeModel[] +} + +/** Runtime reflection metadata for one Cordis event. */ +export interface TypertEventModel extends TypertDocumentation { + readonly name: string + readonly mode?: string + readonly signature: string +} + +/** Runtime reflection metadata for one explicitly exported reference object. */ +export interface TypertObjectModel extends TypertDocumentation { + readonly name: string + readonly exportName: string + readonly members: readonly TypertMemberModel[] + readonly types: readonly TypertTypeModel[] +} + +/** Generated business reflection for one package on one face. */ +export interface TypertPackageModel { + readonly services: readonly TypertServiceModel[] + readonly events: readonly TypertEventModel[] + readonly objects: readonly TypertObjectModel[] +} + +/** One generated live Zod schema. */ +export interface TypertSchema { + readonly name: string + readonly schema: z.ZodType +} + +/** One generated package contribution registered and withdrawn atomically. */ +export interface TypertContribution { + readonly package: string + readonly face: TypertFace + readonly schemas: readonly TypertSchema[] + readonly model: TypertPackageModel +} + +/** A live schema plus its contribution identity. */ +export interface TypertSchemaRecord extends TypertSchema { + readonly package: string + readonly face: TypertFace + readonly key: string +} + +/** A live generated package model plus its stable identity. */ +export interface TypertPackageRecord { + readonly package: string + readonly face: TypertFace + readonly key: string + readonly model: TypertPackageModel +} + +/** Filter for schema enumeration. */ +export interface TypertSchemaFilter { + readonly package?: string + readonly face?: TypertFace +} + +/** Filter for package-model enumeration. */ +export interface TypertPackageFilter { + readonly package?: string + readonly face?: TypertFace +} diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts new file mode 100644 index 0000000000..06eb9c107e --- /dev/null +++ b/packages/typert/registry/tests/typert.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import TypertRegistry, { + typertKey, + typertPackageKey, + type TypertContribution, +} from '@deepseek-ai/dsh-typert-registry' + +async function makeCtx(): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + return ctx +} + +function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): TypertContribution { + return { + package: '@deepseek-ai/dsh-tools', + face: 'host', + schemas: [{ name: 'ToolInput', schema }], + model: { + services: [{ + key: 'tools', + exportName: 'ToolRegistry', + summary: 'Tool registry and execution pipeline.', + tags: [], + members: [{ + kind: 'method', + name: 'register', + signature: 'register(definition: ToolDefinition): () => void', + }], + types: [{ name: 'ToolDefinition', declaration: 'export interface ToolDefinition {}' }], + }], + events: [{ + name: 'tools/change', + mode: 'emit', + signature: "'tools/change'(): void", + tags: [], + }], + objects: [], + }, + } +} + +describe('TypertRegistry', () => { + it('registers and queries generated schemas separately from package reflection', async () => { + const ctx = await makeCtx() + const contribution = toolsContribution() + ctx.typert.register(contribution) + + expect(typertKey('@deepseek-ai/dsh-tools', 'ToolInput')).toBe('@deepseek-ai/dsh-tools#ToolInput') + expect(typertPackageKey('@deepseek-ai/dsh-tools', 'host')).toBe('@deepseek-ai/dsh-tools#host') + expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toMatchObject({ + package: '@deepseek-ai/dsh-tools', + face: 'host', + name: 'ToolInput', + }) + expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')?.schema).toBe(contribution.schemas[0]?.schema) + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toMatchObject({ + key: '@deepseek-ai/dsh-tools#host', + model: { services: [{ key: 'tools' }] }, + }) + expect(ctx.typert.list()).toHaveLength(1) + expect(ctx.typert.listPackages({ face: 'host' })).toHaveLength(1) + }) + + it('withdraws schemas and package metadata through the exact contribution disposer', async () => { + const ctx = await makeCtx() + const dispose = ctx.typert.register(toolsContribution()) + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() + + dispose() + + expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined() + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() + expect(ctx.typert.listPackages()).toEqual([]) + }) + + it('follows the registering plugin fiber lifecycle', async () => { + const ctx = await makeCtx() + const fiber = ctx.plugin(Object.assign( + (child: Context) => { child.typert.register(toolsContribution()) }, + { inject: ['typert'] }, + )) + await fiber + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() + + await fiber.dispose() + + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() + expect(ctx.typert.list()).toEqual([]) + }) + + it('rejects duplicate package faces and schema keys before committing', async () => { + const ctx = await makeCtx() + const original = toolsContribution() + ctx.typert.register(original) + + expect(() => ctx.typert.register(toolsContribution(z.never()))).toThrow('package face') + expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')?.schema).toBe(original.schemas[0]?.schema) + + const duplicateBatch: TypertContribution = { + ...toolsContribution(), + package: '@fixture/duplicate', + schemas: [ + { name: 'Same', schema: z.string() }, + { name: 'Same', schema: z.number() }, + ], + } + expect(() => ctx.typert.register(duplicateBatch)).toThrow('schema "@fixture/duplicate#Same" is already registered') + expect(ctx.typert.getPackage('@fixture/duplicate')).toBeUndefined() + }) + + it('rejects malformed contribution identities and filters both registry views', async () => { + const ctx = await makeCtx() + ctx.typert.register(toolsContribution()) + + expect(() => ctx.typert.register({ ...toolsContribution(), package: '' })) + .toThrow('invalid package name') + expect(() => ctx.typert.register({ ...toolsContribution(), package: 'bad#package' })) + .toThrow('invalid package name') + expect(() => ctx.typert.register({ ...toolsContribution(), face: 'worker' as 'host' })) + .toThrow('invalid face') + expect(() => ctx.typert.register({ + ...toolsContribution(), + package: '@fixture/schema-name', + schemas: [{ name: 'bad#name', schema: z.string() }], + })).toThrow('invalid schema name') + + expect(ctx.typert.list({ package: '@fixture/absent' })).toEqual([]) + expect(ctx.typert.list({ face: 'client' })).toEqual([]) + expect(ctx.typert.listPackages({ package: '@fixture/absent' })).toEqual([]) + expect(ctx.typert.listPackages({ face: 'client' })).toEqual([]) + }) + + it('resolves required schemas and projects fresh JSON Schema documents', async () => { + const ctx = await makeCtx() + ctx.typert.register(toolsContribution()) + + expect(ctx.typert.resolve('@deepseek-ai/dsh-tools#ToolInput').name).toBe('ToolInput') + expect(() => ctx.typert.resolve('@deepseek-ai/dsh-tools#Missing')).toThrow('contributes no schema named "Missing"') + expect(() => ctx.typert.resolve('@fixture/absent#Value')).toThrow('has no registered contribution') + expect(() => ctx.typert.resolve('invalid')).toThrow('expected "#"') + const projected = ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput') + expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }) + expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected) + }) +}) diff --git a/packages/typert/registry/tsconfig.json b/packages/typert/registry/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/typert/registry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts new file mode 100644 index 0000000000..144513225b --- /dev/null +++ b/packages/typert/registry/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the registry and its invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index f22e974d58..114f0a47df 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -136,7 +136,7 @@ describe('CommandService', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) ctx.on('commands/change', () => { throw new Error('observer threw') }) - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment ctx.on('commands/change', () => Promise.reject(new Error('observer rejected'))) const afterFailures = vi.fn() ctx.on('commands/change', afterFailures) @@ -229,7 +229,7 @@ describe('CommandService', () => { ctx.commands.register({ name: 'reject-value', description: 'Reject a non-Error value', - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise untyped plugin normalization handler: () => Promise.reject('not an Error'), }) await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) @@ -239,7 +239,7 @@ describe('CommandService', () => { ctx.commands.register({ name: 'reject-hostile', description: 'Reject an unrenderable value', - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise hostile plugin normalization handler: () => Promise.reject(hostile), }) await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 7dbb7f3ac6..864f4ff0bf 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -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/tui/README.md -README.md: 1a46e7d0557939df77ca27cc4bd09842c9db72ac -README.zh.md: 45353bdc52446d6864f4e365eb4329201432a33b +README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4 +README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 1a46e7d055..88c4501d87 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -26,7 +26,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. -`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. +`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it. The footer sums the session's reported usage as `↑`, followed by `cache %` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. @@ -139,7 +139,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 45353bdc52..f03120e5a7 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -26,7 +26,7 @@ Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 -`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。 +`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。 Footer 将会话报告的用量汇总为 `↑`;任何输入计费后,后面会显示 `cache %`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。 @@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。 +提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。 #### Token 影响 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index ef5af67887..078332d617 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -366,7 +366,7 @@ export function createTuiChat( // the controller needs `appendNotice`/`overlayManager`, defined after that // closure. Declare here, assign once after those exist, and defer the first // `updatePromptValues()` call until after the assignment so no read precedes it. - // eslint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. + // oxlint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. let modelController!: ModelController const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status @@ -1043,11 +1043,10 @@ export function createTuiChat( requestRender() } - // Skill listing is async while `createTuiChat` is synchronous, so the - // TUI retains the last complete catalog for synchronous editor completion - // and refreshes it after registry invalidation. Disabled-for-model skills are - // absent from snapshots, so they never appear as completions; a user can - // still invoke one by typing its exact name. + // Skill listing is async while `createTuiChat` is synchronous, so the TUI + // retains the last complete invocation-neutral catalog for synchronous + // editor completion, filters it for user invocation, and refreshes it after + // registry invalidation. let skillCommands: SlashCommand[] = [] let skillCommandScan = 0 const refreshCommandAutocomplete = (): void => { @@ -1088,12 +1087,13 @@ export function createTuiChat( service.snapshot({ cwd, signal: skillAbort.signal }).then( (snapshot) => { if (disposed || scan !== skillCommandScan || !snapshot.complete) return + const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable) // The argument-hint slot shows in the menu but is never inserted on // selection, so it carries the skill's scope instead of an // instructions placeholder. `SkillSource` is open-ended; every // non-project source (user, custom, bundled, runtime, …) collapses // to `(user)`. - skillCommands = snapshot.skills.map(skill => ({ + skillCommands = invocable.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', @@ -1278,19 +1278,40 @@ export function createTuiChat( appendNotice('Skills are not available in this session.', 'warning') return } - skills.get(name, { cwd, signal: skillAbort.signal }).then( - (skill) => { + const lookup = { cwd, signal: skillAbort.signal } + const reportFailure = (error: unknown): void => { + if (disposed) return + appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error') + } + skills.list(lookup).then( + (summaries) => { if (disposed) return - if (skill === undefined) { + const summary = summaries.find(skill => skill.name === name) + if (summary === undefined) { appendNotice(`Unknown skill: ${name}`, 'warning') return } - deliver(renderSkillInvocation(skill, instructions)) - }, - (error: unknown) => { - if (disposed) return - appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error') + if (!summary.invocation.userInvocable) { + appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') + return + } + skills.get(name, lookup).then( + (skill) => { + if (disposed) return + if (skill === undefined) { + appendNotice(`Unknown skill: ${name}`, 'warning') + return + } + if (!skill.invocation.userInvocable) { + appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') + return + } + deliver(renderSkillInvocation(skill, instructions)) + }, + reportFailure, + ) }, + reportFailure, ) } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 7cbe566482..7d18540c2b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -18,7 +18,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' -import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider } from '@deepseek-ai/dsh-skill' +import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -3828,10 +3828,30 @@ describe('skill slash command', () => { if (skills === undefined) throw new Error('skills service not mounted') skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' }) - skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) + skills.register({ + name: 'user-only-skill', + description: 'User-only skill', + invocation: { modelInvocable: false, userInvocable: true }, + source: 'runtime', + content: 'User-only instructions body.', + }) + skills.register({ + name: 'model-only-skill', + description: 'Model-only skill', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + content: 'Model-only instructions body.', + }) + skills.register({ + name: 'trusted-only-skill', + description: 'Trusted-only skill', + invocation: { modelInvocable: false, userInvocable: false }, + source: 'runtime', + content: 'Trusted-only instructions body.', + }) } - it('labels slash completions by scope and hides model-disabled skills', async () => { + it('labels slash completions by scope and applies user invocation policy', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill') await tick() @@ -3839,8 +3859,10 @@ describe('skill slash command', () => { expect(result.terminal.output).toContain('(user)') expect(result.terminal.output).toContain('project-skill') expect(result.terminal.output).toContain('(project)') + expect(result.terminal.output).toContain('user-only-skill') expect(result.terminal.output).not.toContain('[instructions]') - expect(result.terminal.output).not.toContain('hidden-skill') + expect(result.terminal.output).not.toContain('model-only-skill') + expect(result.terminal.output).not.toContain('trusted-only-skill') await dispose(result) }) @@ -3894,6 +3916,7 @@ describe('skill slash command', () => { return [{ name: 'stable-skill', description: 'STABLE_COMPLETION_MARKER', + invocation: { modelInvocable: true, userInvocable: true }, source: 'test', provider: 'flaky-completion', rank: 1, @@ -3946,6 +3969,7 @@ describe('skill slash command', () => { skills: [{ name: 'latest-skill', description: 'LATEST_COMPLETION_MARKER', + invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime', }], @@ -3953,11 +3977,11 @@ describe('skill slash command', () => { }) await tick() pendingSnapshots[0]?.resolve({ - skills: [{ name: 'stale-first', description: 'STALE_FIRST', source: 'runtime', provider: 'runtime' }], + skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }], complete: true, }) pendingSnapshots[1]?.resolve({ - skills: [{ name: 'stale-second', description: 'STALE_SECOND', source: 'runtime', provider: 'runtime' }], + skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }], complete: true, }) await tick() @@ -3986,12 +4010,62 @@ describe('skill slash command', () => { await dispose(result) }) - it('invokes a model-disabled skill by its exact name', async () => { + it('invokes a user-only skill by its exact name', async () => { const result = await setup({ configureContext: withSkills }) - result.terminal.send('/skill:hidden-skill') + result.terminal.send('/skill:user-only-skill') result.terminal.send('\r') await tick() - expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nHidden instructions body.\n' }]]) + expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nUser-only instructions body.\n' }]]) + await dispose(result) + }) + + it('checks user policy before loading and rechecks the loaded definition', async () => { + const summaries: SkillSummary[] = [ + { + name: 'model-only-skill', + description: 'Model-only skill', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + provider: 'runtime', + }, + { + name: 'policy-race-skill', + description: 'Policy race skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }, + ] + const get = vi.fn((name: string) => Promise.resolve({ + name, + description: 'Policy race skill', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + provider: 'runtime', + content: 'Instructions must not be delivered.', + })) + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + snapshot: () => Promise.resolve({ skills: summaries, complete: true }), + list: () => Promise.resolve(summaries), + get, + } as never) + }, + }) + result.terminal.send('/skill:model-only-skill') + result.terminal.send('\r') + await tick() + result.terminal.send('/skill:policy-race-skill') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toEqual([]) + expect(get).toHaveBeenCalledTimes(1) + expect(get).toHaveBeenCalledWith('policy-race-skill', expect.objectContaining({ cwd: '/workspace' })) + expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.') + expect(result.terminal.output).toContain('Skill "policy-race-skill" is not available for user invocation.') + expect(result.terminal.output).not.toContain('Instructions must not be delivered.') await dispose(result) }) @@ -4047,6 +4121,7 @@ describe('skill slash command', () => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { snapshot: () => Promise.reject(new Error('list boom')), + list: () => Promise.reject(new Error('list boom')), get: () => Promise.reject(new Error('get boom')), } as never) }, @@ -4055,11 +4130,13 @@ describe('skill slash command', () => { result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('failed to load') - expect(result.terminal.output).toContain('get boom') + expect(result.terminal.output).toContain('list boom') await dispose(result) }) it('drops skill list and lookup results that settle after disposal', async () => { + let listCalls = 0 + let resolvePendingList: ((value: SkillSummary[]) => void) | undefined const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = [] const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = [] const result = await setup({ @@ -4067,13 +4144,31 @@ describe('skill slash command', () => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { snapshot: () => new Promise((resolve) => { pendingSnapshots.push(resolve) }), + list: () => { + listCalls += 1 + if (listCalls === 1 || listCalls === 2) { + const name = listCalls === 1 ? 'demo-skill' : 'error-skill' + return Promise.resolve([{ + name, + description: 'demo', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }]) + } + return new Promise((resolve) => { resolvePendingList = resolve }) + }, get: () => new Promise((resolve, reject) => { pendingGet.push({ resolve, reject }) }), } as never) }, }) + await tick() result.terminal.send('/skill:demo-skill') result.terminal.send('\r') await tick() + result.terminal.send('/skill:error-skill') + result.terminal.send('\r') + await tick() result.terminal.send('/skill:other-skill') result.terminal.send('\r') await tick() @@ -4083,16 +4178,36 @@ describe('skill slash command', () => { expect(pendingSnapshots).toHaveLength(1) for (const resolve of pendingSnapshots) { resolve({ - skills: [{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }], + skills: [{ + name: 'late', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }], complete: true, }) } - pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) + resolvePendingList?.([{ + name: 'other-skill', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }]) + pendingGet[0]?.resolve({ + name: 'demo-skill', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + content: 'late body', + }) pendingGet[1]?.reject(new Error('late failure')) await tick() expect(result.agent.sent).toEqual([]) - expect(result.terminal.output).not.toContain('late failure') expect(result.terminal.output).not.toContain('late body') + expect(result.terminal.output).not.toContain('late failure') }) }) @@ -4100,6 +4215,7 @@ describe('renderSkillInvocation', () => { const skill: SkillDefinition = { name: 'demo-skill', description: 'Demo skill', + invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime', content: 'Body text.', diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index d8d6f1e09d..0e6d900413 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -548,7 +548,7 @@ describe('dsh-workflow-workerthread', () => { // The rejection VALUE's own coercion throws: a warn built with bare // String(error) would itself throw, skipping the ChildDisposed ack // and wedging the script's finally until the grace/terminate path. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- the non-Error rejection IS the scenario under test + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection IS the scenario under test dispose: () => Promise.reject({ toString: () => { throw new Error('coercion trap') } }), }), } diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index 01413ab75f..1cdf15c26d 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -75,7 +75,7 @@ describe('dsh-workflow (interface)', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) const seen: string[] = [] // Runtime listeners may return thenables even though the declaration's observable result is void. - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment ctx.on('workflow/agent-start', async () => { throw new Error('async observer failed') }) ctx.on('workflow/agent-start', (_info, agent) => { seen.push(agent.label) }) const engine = ctx.workflows as StubEngine diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 362e622aab..61930c815d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@types/node': specifier: ^22.20.0 version: 22.20.0 + '@typescript-eslint/parser': + specifier: 8.61.0 + version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) @@ -45,7 +48,7 @@ importers: specifier: 4.17.1 version: 4.17.1 eslint: - specifier: ^10.4.1 + specifier: 10.5.0 version: 10.5.0(jiti@2.7.0) eslint-plugin-sonarjs: specifier: ^4.1.0 @@ -86,6 +89,12 @@ importers: micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 + oxlint: + specifier: 1.76.0 + version: 1.76.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: 7.0.2001 + version: 7.0.2001 publint: specifier: ^0.3.21 version: 0.3.21 @@ -98,15 +107,12 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 - typescript-eslint: - specifier: ^8.61.0 - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) vite-tsconfig-paths: specifier: ^6.1.1 version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/cli: dependencies: @@ -1166,6 +1172,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1360,6 +1369,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1446,6 +1458,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -4881,6 +4896,63 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/typert/generator: + dependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../cordis/tool-cordis + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../registry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + zod: + specifier: ^4.4.3 + version: 4.4.3 + + packages/typert/loader: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../registry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + zod: + specifier: ^4.4.3 + version: 4.4.3 + + packages/typert/registry: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/ui/app-boot: dependencies: js-yaml: @@ -7395,6 +7467,158 @@ packages: cpu: [x64] os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.76.0': + resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.76.0': + resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.76.0': + resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.76.0': + resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.76.0': + resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.76.0': + resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.76.0': + resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.76.0': + resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.76.0': + resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.76.0': + resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -8100,14 +8324,6 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.61.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.0': resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8131,30 +8347,26 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.61.0': resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.61.0': resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -9258,10 +9470,6 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -10041,6 +10249,23 @@ packages: oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} + hasBin: true + + oxlint@1.76.0: + resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -10634,13 +10859,6 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} - typescript-eslint@8.61.0: - resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - typescript-language-server@5.3.0: resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} engines: {node: '>=20'} @@ -12395,6 +12613,81 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2001': + optional: true + + '@oxlint/binding-android-arm-eabi@1.76.0': + optional: true + + '@oxlint/binding-android-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.76.0': + optional: true + + '@oxlint/binding-darwin-x64@1.76.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.76.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.76.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.76.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.76.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.76.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.76.0': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -13006,22 +13299,6 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.0 - eslint: 10.5.0(jiti@2.7.0) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.61.0 @@ -13036,8 +13313,8 @@ snapshots: '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: @@ -13052,20 +13329,14 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.5.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - transitivePeerDependencies: - - supports-color '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) @@ -13081,17 +13352,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - eslint: 10.5.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/visitor-keys@8.61.0': dependencies: '@typescript-eslint/types': 8.61.0 @@ -13133,7 +13393,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -14396,8 +14656,6 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} - immediate@3.0.6: {} immer@10.2.0: {} @@ -15364,6 +15622,38 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + oxlint-tsgolint@7.0.2001: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.76.0(oxlint-tsgolint@7.0.2001): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.76.0 + '@oxlint/binding-android-arm64': 1.76.0 + '@oxlint/binding-darwin-arm64': 1.76.0 + '@oxlint/binding-darwin-x64': 1.76.0 + '@oxlint/binding-freebsd-x64': 1.76.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 + '@oxlint/binding-linux-arm-musleabihf': 1.76.0 + '@oxlint/binding-linux-arm64-gnu': 1.76.0 + '@oxlint/binding-linux-arm64-musl': 1.76.0 + '@oxlint/binding-linux-ppc64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-gnu': 1.76.0 + '@oxlint/binding-linux-riscv64-musl': 1.76.0 + '@oxlint/binding-linux-s390x-gnu': 1.76.0 + '@oxlint/binding-linux-x64-gnu': 1.76.0 + '@oxlint/binding-linux-x64-musl': 1.76.0 + '@oxlint/binding-openharmony-arm64': 1.76.0 + '@oxlint/binding-win32-arm64-msvc': 1.76.0 + '@oxlint/binding-win32-ia32-msvc': 1.76.0 + '@oxlint/binding-win32-x64-msvc': 1.76.0 + oxlint-tsgolint: 7.0.2001 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -16031,17 +16321,6 @@ snapshots: typebox@1.1.38: {} - typescript-eslint@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.5.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - typescript-language-server@5.3.0: dependencies: vscode-jsonrpc: 5.0.1 @@ -16285,36 +16564,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.20.0 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - jsdom: 29.1.1 - transitivePeerDependencies: - - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -16345,6 +16594,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.20.0 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index f4f045b06d..e414b572d3 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -1,11 +1,6 @@ -/** - * AST walkers for the Cordis catalog generator: locate the Cordis module merge - * in a source file, enumerate its `interface Events` members, and resolve the - * `interface Context` service keys to their service classes. - */ +/** Locate the Cordis module merge used by the vendored core API projector. */ import ts from 'typescript' -import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts' /** The body of the cordis module merge in `sf`: `declare module 'cordis'` * (harness packages) or `declare module './context.ts'` (vendor core), or @@ -18,74 +13,3 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { } return null } - -/** Every `interface Events` method member of a cordis module merge, with the - * event name resolved from its (possibly string-literal) property name. */ -export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] { - const out: { name: string; member: ts.MethodSignature }[] = [] - for (const stmt of body.statements) { - if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue - for (const member of stmt.members) { - if (!ts.isMethodSignature(member)) continue - const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) - out.push({ name, member }) - } - } - return out -} - -/** The `ctx. → type name` map declared by a merge's `interface Context`. */ -function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map { - const keyToType = new Map() - for (const stmt of body.statements) { - if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue - for (const member of stmt.members) { - if (!ts.isPropertySignature(member) || !member.type) continue - keyToType.set(member.name.getText(sf), member.type.getText(sf)) - } - } - return keyToType -} - -/** One `ctx.` service class resolved from a Context merge. */ -export interface ServiceClass { - key: string - type: string - cls: ts.ClassDeclaration - abstract: boolean - /** Class-level JSDoc prose (empty string when missing — also reported). */ - doc: string -} - -/** - * Resolve each `ctx.` of a merge to the service class declared in the - * same file. A key whose type is not a class here (a Pick-mixin member, e.g. - * timer helpers) is skipped. A class without JSDoc prose is reported into - * `violations` (named `where` by the caller's gate). - * - * @param body — the cordis module merge body. - * @param sf — the source file containing the merge. - * @param rel — repo-relative path of `sf`, for violation pointers. - * @param violations — sink for JSDoc-completeness violations. - * @returns the resolved service classes, in Context-declaration order. - */ -export function serviceClasses( - body: ts.ModuleBlock, - sf: ts.SourceFile, - rel: string, - violations: string[], -): ServiceClass[] { - const text = sf.getFullText() - const out: ServiceClass[] = [] - for (const [key, type] of contextKeyMap(body, sf)) { - const cls = sf.statements.find( - (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, - ) - if (!cls) continue // a Pick-mixin member, not a class here - const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false - const doc = parseJsDoc(rawJsDoc(text, cls)).doc - if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) - out.push({ key, type, cls, abstract, doc }) - } - return out -} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 42198d4eea..0682f78640 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,11 +1,11 @@ { - "AGENTS.md": 1750, + "AGENTS.md": 1755, "docs/AGENTS.md": 1150, - "docs/architecture.md": 1800, + "docs/architecture.md": 1920, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 870 + "packages/README.md": 900 } diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 9aedf69aca..d037d27e4b 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -1,282 +1,9 @@ /** - * Generate the model-facing Cordis API data module from the same event/service - * collector as the documentation catalogs. It emits original declaration - * JSDoc, first-sentence summaries, raw signatures, transitive public type - * shapes, and inherited context entries, without source pointers; output is - * deterministic and `--check` verifies it. + * Compatibility entry point for the unified Typert-backed Cordis catalog + * projection. The generated API module retains this command in its banner, + * while all extraction, validation, and rendering live in one implementation. */ -import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import ts from 'typescript' -import { collectEvents, collectServices, INHERITED_SERVICES } from './gen-cordis-catalog.ts' +import { main } from './gen-cordis-catalog.ts' -const root = resolve(import.meta.dirname, '..') -const OUT = 'packages/cordis/tool-cordis/src/api-catalog.ts' - -/** Declarations longer than this render as a truncated stub — a shape the model cannot skim teaches nothing. */ -const MAX_DECL_CHARS = 1500 - -/** The first sentence of a (possibly multi-line) JSDoc prose block. */ -function firstSentence(doc: string): string { - const line = doc.split('\n', 1)[0] ?? '' - const match = /^(.*?[.!?])(?:\s|$)/.exec(line) - return (match?.[1] ?? line).trim() -} - -/** Render a string as a single-quoted, lint-clean TS literal. */ -function quote(value: string): string { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'` -} - -/** - * Reduce an exported class to its type shape: drop method/constructor bodies - * and property initializers so the catalog serves member signatures, not - * implementation. An abstract class (e.g. `Agent`) is a public type consumers - * program against, so it belongs in the type closure alongside interfaces. - */ -function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { - const isNonPublic = (member: ts.ClassElement): boolean => - (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => - m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false - const members = node.members.flatMap((member): ts.ClassElement[] => { - // A model-facing type shape carries only the public surface — drop private, - // protected, and #private members, and strip every kept member's body. - if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] - if (ts.isMethodDeclaration(member)) { - return [ts.factory.updateMethodDeclaration( - member, member.modifiers, member.asteriskToken, member.name, member.questionToken, - member.typeParameters, member.parameters, member.type, undefined)] - } - if (ts.isConstructorDeclaration(member)) { - return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] - } - if (ts.isGetAccessorDeclaration(member)) { - return [ts.factory.updateGetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, member.type, undefined)] - } - if (ts.isSetAccessorDeclaration(member)) { - return [ts.factory.updateSetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, undefined)] - } - if (ts.isPropertyDeclaration(member)) { - return [ts.factory.updatePropertyDeclaration( - member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] - } - return [member] - }) - return ts.factory.updateClassDeclaration( - node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) -} - -/** - * Collect exported interface, type-alias, and (body-stripped) class shapes; - * omit names declared in multiple packages rather than risk serving the wrong - * package's shape. - */ -function collectTypeDecls(scanRoot: string = root): Map { - const printer = ts.createPrinter({ removeComments: true }) - const decls = new Map() - const ambiguous = new Set() - for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { - const abs = resolve(scanRoot, rel) - const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) - for (const stmt of sf.statements) { - const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) - if (!named || stmt.name === undefined) continue - if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue - const name = stmt.name.text - if (decls.has(name)) { - ambiguous.add(name) - continue - } - const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt - const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') - decls.set(name, printed.length > MAX_DECL_CHARS - ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` - : printed) - } - } - for (const name of ambiguous) decls.delete(name) - return decls -} - -/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */ -function referencedTypes(seeds: string[], decls: Map): { name: string; declaration: string }[] { - const included = new Map() - let frontier = seeds - while (frontier.length > 0) { - const next: string[] = [] - for (const [name, declaration] of decls) { - if (included.has(name)) continue - const pattern = new RegExp(`\\b${name}\\b`) - if (frontier.some(text => pattern.test(text))) { - included.set(name, declaration) - next.push(declaration) - } - } - frontier = next - } - return [...included].map(([name, declaration]) => ({ name, declaration })).sort((a, b) => a.name.localeCompare(b.name)) -} - -/** Render the whole generated module (pure, deterministic given sorted collector output). */ -function render(): string { - const services = collectServices() - const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name)) - const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls()) - const lines: string[] = [ - '/**', - ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run', - ' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by', - ' * `pnpm run verify-cordis-api` in doc-sync).', - ' *', - ' * The machine-readable cordis API catalog `cordis_inspect` serves to the', - ' * model: harness services (summary + public method signatures/JSDoc),', - ' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by', - ' * the same AST walk as docs/cordis-catalog, so this data and the rendered', - ' * docs cannot diverge.', - ' *', - ' * @module @deepseek-ai/dsh-tool-cordis/api-catalog', - ' */', - '', - '/** One public service method and its source-owned contract. */', - 'export interface ServiceApiMethod {', - ' /** Public method signature with its body stripped. */', - ' signature: string', - ' /** Original method JSDoc, with only container indentation removed. */', - ' jsDoc: string', - '}', - '', - '/** One harness `ctx.` service: its one-line summary and public methods. */', - 'export interface ServiceApiEntry {', - ' /** The `ctx.` name, e.g. `tools`. */', - ' key: string', - ' /** First sentence of the service class JSDoc. */', - ' summary: string', - ' /** Public methods, bodies stripped, in source order. */', - ' methods: readonly ServiceApiMethod[]', - '}', - '', - '/** One harness event: its dispatch mode, exact signature, and one-line summary. */', - 'export interface EventApiEntry {', - ' /** The scoped event name, e.g. `agent/status`. */', - ' name: string', - ' /** The dispatch mode from the declaration\'s `@mode` tag. */', - ' mode: string', - ' /** The exact listener signature, whitespace-normalized. */', - ' signature: string', - ' /** Original event JSDoc, with only container indentation removed. */', - ' jsDoc: string', - ' /** First sentence of the event JSDoc. */', - ' summary: string', - '}', - '', - '/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */', - 'export interface InheritedApiEntry {', - ' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */', - ' name: string', - ' /** One-line summary of what the member does. */', - ' summary: string', - '}', - '', - '/** One named type shape the service signatures reference. */', - 'export interface TypeApiEntry {', - ' /** The exported type/interface name, e.g. `BashRunResult`. */', - ' name: string', - ' /** The full declaration text, comments stripped. */', - ' declaration: string', - '}', - '', - '/** Every harness `ctx.` service, sorted by key. */', - 'export const SERVICE_API: readonly ServiceApiEntry[] = [', - ] - for (const service of services) { - lines.push(' {') - lines.push(` key: ${quote(service.key)},`) - lines.push(` summary: ${quote(firstSentence(service.doc))},`) - if (service.methods.length === 0) { - lines.push(' methods: [],') - } else { - lines.push(' methods: [') - for (const method of service.methods) { - lines.push(' {') - lines.push(` signature: ${quote(method.signature)},`) - lines.push(` jsDoc: ${quote(method.jsDoc)},`) - lines.push(' },') - } - lines.push(' ],') - } - lines.push(' },') - } - lines.push( - ']', - '', - '/** Every harness event, sorted by name. */', - 'export const EVENT_API: readonly EventApiEntry[] = [', - ) - for (const event of events) { - lines.push(' {') - lines.push(` name: ${quote(event.name)},`) - lines.push(` mode: ${quote(event.mode)},`) - lines.push(` signature: ${quote(event.signature)},`) - lines.push(` jsDoc: ${quote(event.jsDoc)},`) - lines.push(` summary: ${quote(firstSentence(event.doc))},`) - lines.push(' },') - } - lines.push( - ']', - '', - '/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */', - 'export const TYPE_API: readonly TypeApiEntry[] = [', - ) - for (const type of types) { - lines.push(' {') - lines.push(` name: ${quote(type.name)},`) - lines.push(` declaration: ${quote(type.declaration)},`) - lines.push(' },') - } - lines.push( - ']', - '', - '/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */', - 'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [', - ) - for (const inherited of INHERITED_SERVICES) { - lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`) - } - lines.push(']', '') - return lines.join('\n') -} - -/** CLI entry: default writes the artifact, `--check` fails if the committed - * copy is stale. Guarded behind an entry-point check so importing this module - * for tests neither regenerates the committed file nor calls process.exit. */ -function main(): void { - const content = render() - if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". - committed = null - } - if (committed === content) { - console.log(`gen-cordis-api: ${OUT} is up to date.`) - process.exit(0) - } - console.error(`gen-cordis-api: ${OUT} is stale. Run \`pnpm run gen-cordis-api\` and commit ${OUT}.`) - process.exit(1) - } - - writeFileSync(resolve(root, OUT), content) - console.log(`gen-cordis-api: wrote ${OUT}.`) -} - -// Run only when invoked as a script, not when imported by a test. -if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { - main() -} +main() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fa2fbd4533..914f28e888 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -1,32 +1,25 @@ /** - * Generate the Cordis event and service catalogs from static declarations. - * The walk enforces event modes, JSDoc parameter/return completeness, and - * signature type-link coverage; inherited Cordis services come from the - * curated table below. `--check` verifies both committed artifacts. + * Generate committed Cordis artifacts from the Typert catalog projector and + * the independent vendored-core projector. */ -import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, resolve, sep } from 'node:path' -import ts from 'typescript' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { + projectCordisCatalog, + renderEvents, + renderServices, +} from '@deepseek-ai/dsh-typert-generator' +import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator' import { renderCordisCoreApiPages } from './cordis-core-api.ts' -import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' -import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts' const root = resolve(import.meta.dirname, '..') const OUT_EVENTS = 'docs/cordis-catalog/events.md' const OUT_SERVICES = 'docs/cordis-catalog/services.md' +const OUT_RUNTIME_API = 'packages/cordis/tool-cordis/src/api-catalog.ts' -/** The fenced-block info string for generated signature blocks (skipped by - * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ -const FENCE = 'ts cordis-catalog' - -/** - * One primary core-data-structures page per project type used by a generated - * signature. This stays curated because union names intentionally do not - * reuse the type-equivalence manifest's map-symbol entries and some symbols - * appear on more than one page. - */ -export const LINK_MAP: Record = { +/** One primary core-data-structures page per project type used by a generated signature. */ +export const LINK_MAP: Readonly> = { Agent: 'core.md', AgentCancelCause: 'core.md', AgentOptions: 'core.md', @@ -51,8 +44,8 @@ export const LINK_MAP: Record = { MessageSource: 'core.md', UserMessage: 'session.md', PromptDecision: 'core.md', - RequestErrorAction: 'core.md', RequestError: 'core.md', + RequestErrorAction: 'core.md', PreparedReferencedMessage: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', @@ -206,12 +199,13 @@ export const LINK_MAP: Record = { WorkflowStartRequest: 'workflow.md', } -/** TypeScript lib and pinned framework types that have no repository-owned data page. */ -const FOUNDATION_TYPE_NAMES = new Set([ +/** TypeScript lib and pinned framework types with no repository-owned data page. */ +export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'AbortSignal', 'AsyncIterable', 'Context', 'Error', + 'Map', 'Partial', 'Pick', 'Promise', @@ -219,7 +213,7 @@ const FOUNDATION_TYPE_NAMES = new Set([ ]) /** Project types deliberately documented outside the core-data catalog. */ -const TYPE_LINK_EXEMPTIONS: Readonly> = { +export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', @@ -244,6 +238,14 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts', ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts', CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts', + TypertContribution: 'registry contribution contract is owned by packages/typert/registry/README.md', + TypertFace: 'registry face identity is owned by packages/typert/registry/README.md', + TypertPackageFilter: 'registry package query filter is owned by packages/typert/registry/README.md', + TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md', + TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md', + TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md', + 'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API', + 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts', @@ -270,401 +272,51 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md', } -/** Collect named references from parameter, generic-constraint/default, and return types. */ -function signatureTypeNames(member: ts.MethodSignature | ts.MethodDeclaration, sf: ts.SourceFile): string[] { - const declared = new Set(member.typeParameters?.map(parameter => parameter.name.text) ?? []) - const referenced = new Set() - const visit = (node: ts.Node): void => { - if (ts.isTypeReferenceNode(node)) referenced.add(node.typeName.getText(sf)) - if (ts.isTypeQueryNode(node)) referenced.add(node.exprName.getText(sf)) - ts.forEachChild(node, visit) - } - for (const parameter of member.typeParameters ?? []) { - if (parameter.constraint) visit(parameter.constraint) - if (parameter.default) visit(parameter.default) - } - for (const parameter of member.parameters) { - if (parameter.type) visit(parameter.type) - } - if (member.type) visit(member.type) - return [...referenced].filter(name => !declared.has(name)).sort() +/** Repository data policy consumed by the Cordis catalog projector. */ +export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { + linkedTypePages: LINK_MAP, + foundationTypeNames: FOUNDATION_TYPE_NAMES, + typeLinkExemptions: TYPE_LINK_EXEMPTIONS, + inheritedEvents: [ + { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' }, + { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' }, + { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' }, + { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' }, + { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' }, + { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' }, + { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, + { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, + { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, + { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, + { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, + { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' }, + { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' }, + ], + inheritedServices: [ + { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' }, + { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' }, + { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, + { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, + { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' }, + { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' }, + { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' }, + { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' }, + { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' }, + ], } -/** Append fail-closed signature type-link violations with actionable ownership choices. */ -function checkTypeLinks( - where: string, - member: ts.MethodSignature | ts.MethodDeclaration, - sf: ts.SourceFile, - violations: string[], -): void { - for (const name of signatureTypeNames(member, sf)) { - if (Object.hasOwn(LINK_MAP, name) - || FOUNDATION_TYPE_NAMES.has(name) - || Object.hasOwn(TYPE_LINK_EXEMPTIONS, name)) continue - violations.push( - `${where} references unclassified type '${name}'. Add it to LINK_MAP with its core-data-structures page, ` - + 'to FOUNDATION_TYPE_NAMES if TypeScript or Cordis owns it, or to TYPE_LINK_EXEMPTIONS with ' - + 'the non-catalog documentation owner.', - ) - } -} - -/** Throw one aggregated diagnostic for every unclassified signature type. */ -function reportTypeLinkViolations(gate: string, violations: string[]): void { - if (violations.length === 0) return - throw new Error( - `${gate}: ${violations.length} signature type-link coverage violation(s):\n` - + violations.map(violation => ` ${violation}`).join('\n'), - ) -} - -/** One harness event, extracted from an `interface Events` block. */ -interface EventEntry { - /** Scoped name, e.g. `agent/request`. */ - name: string - /** The scope prefix, e.g. `agent` (everything before the first `/`). */ - scope: string - /** Full signature text (the method-signature member, JSDoc stripped). */ - signature: string - /** Original declaration JSDoc, dedented from its containing interface. */ - jsDoc: string - /** Dispatch mode from the `@mode` tag. */ - mode: Mode - /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ - doc: string - /** Source pointer `packages/…/file.ts:line` of the declaration. */ - source: string -} - -/** One public service method and the source contract attached to it. */ -interface ServiceMethodEntry { - /** Public method signature (body stripped). */ - signature: string - /** Original method JSDoc, dedented from its containing class. */ - jsDoc: string -} - -/** One harness service, extracted from an `interface Context` block. */ -interface ServiceEntry { - /** The `ctx.` name, e.g. `llm`. */ - key: string - /** The service class/interface name, e.g. `LlmService`. */ - type: string - /** Whether the service class is abstract (a seam interface). */ - abstract: boolean - /** Class-level JSDoc prose, one line per paragraph. */ - doc: string - /** Public methods (bodies stripped), in source order. */ - methods: ServiceMethodEntry[] - /** Source pointer of the class declaration. */ - source: string -} - -/** A terse inherited-tier entry (pinned vendor surface). */ -interface InheritedEntry { - name: string - summary: string - /** Source pointer `vendor/…:line`. */ - source: string -} - -// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts. - -/** The signature text of a method-signature member (everything but a body). */ -function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string { - const full = member.getText(sf) - const body = (member as { body?: ts.Node }).body - const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full - return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() -} - -/** - * Copy a node's original JSDoc while removing only the indentation imposed by - * its containing interface or class. +/** CLI entry: default writes every artifact; `--check` reports stale files. + * @returns nothing; writes files or reports freshness through the process. */ -function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string { - const raw = rawJsDoc(text, node) - if (!raw) return '' - const start = text.lastIndexOf(raw, node.getStart(sf)) - const { line } = sf.getLineAndCharacterOfPosition(start) - const lineStart = sf.getPositionOfLineAndCharacter(line, 0) - const indent = text.slice(lineStart, start) - return raw.split('\n') - .map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText) - .join('\n') -} - -/** Walk every harness `interface Events` block and extract its events, hard- - * erroring (aggregated) on any JSDoc-completeness violation: a missing/ - * contradicted `@mode`, missing description prose, or an undocumented payload - * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */ -export function collectEvents(scanRoot: string = root): EventEntry[] { - const entries: EventEntry[] = [] - const violations: string[] = [] - const typeLinkViolations: string[] = [] - for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { - const abs = resolve(scanRoot, rel) - const text = readFileSync(abs, 'utf8') - if (!text.includes('interface Events')) continue - const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) - const body = cordisModuleBody(sf) - if (!body) continue - for (const { name, member } of eventMembers(body, sf)) { - const signature = memberSignature(member, sf) - const raw = rawJsDoc(text, member) - const { doc, mode } = parseJsDoc(raw) - const src = pointer(rel, sf, member) - const where = `event '${name}' (${src})` - checkTypeLinks(where, member, sf, typeLinkViolations) - if (!mode) { - violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial|bail' to its JSDoc (see AGENTS.md).`) - } - // Conclusive structural check: a trailing `next: () => …` parameter is a - // waterfall. (emit vs parallel vs serial is not structurally - // distinguishable, so it is trusted from the tag.) - const last = member.parameters.at(-1) - const hasNext = !!last && last.name.getText(sf) === 'next' - if (mode && hasNext && mode !== 'waterfall') { - violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) - } - if (mode && !hasNext && mode === 'waterfall') { - violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) - } - if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) - // Payload parameters need a non-empty @param. The `this` receiver is not - // payload, and a waterfall's trailing `next` is covered by its mode. - const { params } = parseTags(raw) - checkParams(where, 'event', member.parameters, params, sf, - p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) - if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src }) - } - } - reportViolations('gen-cordis-catalog', violations) - reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations) - return entries -} - -/** Walk every harness `interface Context` block + its service class, hard- - * erroring (aggregated) on any JSDoc-completeness violation: a class or public - * method without JSDoc prose, an undocumented parameter, a stale `@param`, a - * missing `@returns` on a non-void method, or an inferred (unannotated) return - * type the pure-AST walk cannot classify. - * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ -export function collectServices(scanRoot: string = root): ServiceEntry[] { - const entries: ServiceEntry[] = [] - const violations: string[] = [] - const typeLinkViolations: string[] = [] - for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { - const abs = resolve(scanRoot, rel) - const text = readFileSync(abs, 'utf8') - if (!text.includes('interface Context')) continue - const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) - const body = cordisModuleBody(sf) - if (!body) continue - // Resolve each ctx key to its service class (shared walk) and emit an entry. - for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) { - const methods: ServiceMethodEntry[] = [] - for (const member of cls.members) { - if (!ts.isMethodDeclaration(member)) continue - // Only instance methods callable through `ctx.` are surface; - // private, protected, and static methods are not. - const nonPublic = member.modifiers?.some(m => - m.kind === ts.SyntaxKind.PrivateKeyword - || m.kind === ts.SyntaxKind.ProtectedKeyword - || m.kind === ts.SyntaxKind.StaticKeyword) - || ts.isPrivateIdentifier(member.name) - if (nonPublic) continue - const memberName = member.name.getText(sf) - if (memberName.startsWith('[')) continue // computed/symbol members - const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` - checkTypeLinks(where, member, sf, typeLinkViolations) - const raw = rawJsDoc(text, member) - methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) }) - if (!raw) { violations.push(`${where} has no JSDoc.`); continue } - if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) - const { params, returns } = parseTags(raw) - // Every parameter needs a non-empty @param (`this` receiver exempt), - // and a non-void ANNOTATED result needs a non-empty @returns — the - // shared checkers carry the exact contract. - checkParams(where, 'service', member.parameters, params, sf, - p => ts.isIdentifier(p.name) && p.name.text === 'this', violations) - checkReturns(where, member.type, returns, sf, violations) - } - entries.push({ - key, - type, - abstract, - doc: clsDoc, - methods, - source: pointer(rel, sf, cls), - }) - } - } - reportViolations('gen-cordis-catalog', violations) - reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations) - return entries.sort((a, b) => a.key.localeCompare(b.key)) -} - -/** - * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and - * hand-summarized because (a) it is pinned vendor source that changes only on a - * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members - * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would - * wrongly surface as services, and (c) the internal/* events carry no JSDoc to - * render. Source pointers are verified against vendor by `verify-md-links`' - * sibling check is N/A; keep them current on a vendor bump. - */ -const INHERITED_EVENTS: InheritedEntry[] = [ - { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' }, - { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' }, - { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' }, - { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' }, - { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' }, - { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' }, - { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' }, - { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' }, - { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, - { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, - { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, - { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, - { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, - { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' }, - { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' }, -] - -export const INHERITED_SERVICES: InheritedEntry[] = [ - { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' }, - { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' }, - { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' }, - { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, - { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, - { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' }, - { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' }, - { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' }, - { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' }, - { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' }, -] - -/** Render the cross-link "Types:" line for a signature, or '' if none apply. */ -function typeLinks(signature: string): string { - const seen = new Set() - for (const name of Object.keys(LINK_MAP)) { - if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name) - } - if (seen.size === 0) return '' - const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`) - return `Types: ${links.join(' · ')}` -} - -/** Render one harness event entry. */ -function renderEvent(e: EventEntry): string[] { - const out = [`### \`${e.name}\` — ${e.mode}`, ''] - if (e.doc) out.push(e.doc, '') - out.push('```' + FENCE, e.jsDoc, e.signature, '```', '') - const links = typeLinks(e.signature) - if (links) out.push(links, '') - out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') - return out -} - -/** Render one harness service entry. */ -function renderService(s: ServiceEntry): string[] { - const kind = s.abstract ? ' (abstract seam)' : '' - const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] - if (s.doc) out.push(s.doc, '') - if (s.methods.length) { - const declarations = s.methods.flatMap((method, index) => [ - ...(index > 0 ? [''] : []), - method.jsDoc, - method.signature, - ]) - out.push('```' + FENCE, ...declarations, '```', '') - const links = typeLinks(s.methods.map(method => method.signature).join('\n')) - if (links) out.push(links, '') - } - out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') - return out -} - -/** The shared generated-file banner comment. */ -const BANNER = [ - '', - '', -] - -/** The shared GENERATED + freshness-gate + fence notice paragraph. */ -const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.' - -/** Render the events catalog (pure, deterministic given sorted inputs). */ -export function renderEvents(events: EventEntry[]): string { - const lines: string[] = [ - ...BANNER, - '# Cordis Events Catalog', - '', - 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', - '', - GATE_NOTICE, - '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).', - '', - 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer).', - '', - ] - const scopes = [...new Set(events.map(e => e.scope))].sort() - for (const scope of scopes) { - lines.push(`## \`${scope}/*\``, '') - for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) { - lines.push(...renderEvent(e)) - } - } - lines.push( - '## Inherited events (cordis core + loader/hmr/timer)', - '', - 'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.', - '', - ) - for (const e of INHERITED_EVENTS) { - lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`) - } - lines.push('') - return lines.join('\n') -} - -/** Render the services catalog (pure, deterministic given sorted inputs). */ -export function renderServices(services: ServiceEntry[]): string { - const lines: string[] = [ - ...BANNER, - '# Cordis Services Catalog', - '', - 'Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', - '', - GATE_NOTICE, - '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).', - '', - ] - for (const s of services) lines.push(...renderService(s)) - lines.push( - '## Inherited `ctx` members (cordis core + loader/hmr/timer)', - '', - 'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.', - '', - ) - for (const s of INHERITED_SERVICES) { - lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`) - } - lines.push('') - return lines.join('\n') -} - -/** CLI entry: `--write` (default) writes both catalogs, `--check` fails if - * either is stale. Guarded behind an entry-point check so importing this module - * for tests neither regenerates the committed files nor calls process.exit. */ -function main(): void { +export function main(): void { + const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY) const outputs: [string, string][] = [ - [OUT_EVENTS, renderEvents(collectEvents())], - [OUT_SERVICES, renderServices(collectServices())], + [OUT_EVENTS, renderEvents([...model.events], CORDIS_CATALOG_POLICY)], + [OUT_SERVICES, renderServices([...model.services], CORDIS_CATALOG_POLICY)], + [OUT_RUNTIME_API, projector.renderRuntimeApi(model)], ...renderCordisCoreApiPages(), ] if (process.argv.includes('--check')) { @@ -674,9 +326,7 @@ function main(): void { try { committed = readFileSync(resolve(root, out), 'utf8') } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". + // Only ENOENT is expected; either read failure has the same remedy. committed = null } if (committed !== content) stale.push(out) @@ -697,7 +347,4 @@ function main(): void { console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`) } -// Run only when invoked as a script, not when imported by a test. -if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { - main() -} +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main() diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8846cca3eb..91aa8eb38c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -8,7 +8,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import ts from 'typescript' -import { collectEvents, collectServices } from './gen-cordis-catalog.ts' +import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator' +import { CORDIS_CATALOG_POLICY } from './gen-cordis-catalog.ts' +import type { EventEntry, ServiceEntry } from '@deepseek-ai/dsh-typert-generator' import { collectPackageGraph, escapeMermaidLabel as escLabel, @@ -58,6 +60,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'typert', 'goal', 'process', 'bash', @@ -128,6 +131,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['session', 'agent', 'scope', 'agent-loop'], note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.', }, + { + key: 'typert', + pkg: 'typert-registry', + title: 'Runtime type registry', + mode: 'core', + consumers: ['typert-loader'], + note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.', + }, { key: 'sessionPersistence', pkg: 'session-persistence', @@ -507,8 +518,8 @@ function tableCell(value: string): string { return value.replace(/\|/g, '\\|').replace(/\n/g, '
') } -function assertServiceRolesComplete(): void { - const discovered = new Set(collectServices().map(service => service.key)) +function assertServiceRolesComplete(services: readonly ServiceEntry[]): void { + const discovered = new Set(services.map(service => service.key)) const classified = new Set(SERVICE_ROLES.map(role => role.key)) const missing = [...discovered].filter(key => !classified.has(key)).sort() const stale = [...classified].filter(key => !discovered.has(key)).sort() @@ -520,8 +531,8 @@ function assertServiceRolesComplete(): void { } } -function renderCapabilitySeams(pkgs: Pkg[]): string { - assertServiceRolesComplete() +function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): string { + assertServiceRolesComplete(services) const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard' const nodes = new Map() @@ -970,8 +981,7 @@ function listenerPackages(listeners: Set, pkgsByShort: Map) return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ') } -function renderEventRelations(pkgs: Pkg[]): string { - const events = collectEvents() +function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): string { const relations = collectEventRelations() const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program' @@ -1160,10 +1170,11 @@ function renderToolPipeline(): string { function renderDocs(): GraphDoc[] { const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs') + const { model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY) const docs: GraphDoc[] = [ - { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) }, + { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs, model.services) }, ...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })), - { rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs) }, + { rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs, model.events) }, { rel: 'docs/agent-lifecycle.md', content: renderLifecycle() }, { rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() }, ] diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts new file mode 100644 index 0000000000..a28afcc8cf --- /dev/null +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' +import { describe, expect, it } from 'vitest' + +type Rules = Record + +interface Profile { + readonly count: number + readonly indexes: readonly number[] + readonly sha256: string +} + +// A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2 +// mapped @typescript-eslint/* to typescript/* and four extension rules to their +// Oxlint core equivalents. These fingerprints pin the resulting repository +// contract; they do not re-evaluate that deleted baseline or track its preset. +const profiles = { + source: { + count: 88, + indexes: [0, 1, 4, 5], + sha256: 'da1dfd77cb6eb66be93d8d3820f9b9b68b7aa391c24680f8851c0910298f9e3b', + }, + example: { + count: 87, + indexes: [0, 1, 2, 4, 5], + sha256: '6a2606053bc1ec1de3b02611de88ea51d201dac13a1f193e4934d33c08b95f08', + }, + test: { + count: 83, + indexes: [0, 3, 4, 5], + sha256: '7995e14926a36c40bd65c474637735222a95fb030395681685f03060e50a7b78', + }, +} as const satisfies Record + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value) +} + +function severity(value: unknown): 0 | 1 | 2 { + const level = isUnknownArray(value) ? value[0] : value + if (level === 'off' || level === 0) return 0 + if (level === 'warn' || level === 'warning' || level === 1) return 1 + if (level === 'error' || level === 2) return 2 + throw new Error(`unsupported lint severity: ${JSON.stringify(level)}`) +} + +function normalizedRules(rules: Rules): Rules { + return Object.fromEntries(Object.entries(rules) + .filter(([, value]) => severity(value) > 0) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => { + const options = isUnknownArray(value) ? value.slice(1) : [] + return [name, [severity(value), ...options]] + })) +} + +function mergedRules(overrides: readonly unknown[], indexes: readonly number[]): Rules { + const merged: Rules = {} + for (const index of indexes) { + const override = overrides[index] + if (!isRecord(override) || !isRecord(override.rules)) { + throw new Error(`.oxlintrc.json override ${index} must contain a rules object`) + } + Object.assign(merged, override.rules) + } + return normalizedRules(merged) +} + +describe('Oxlint repository rule fingerprint', () => { + const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url)) + const result = parseConfigFileTextToJson(path, readFileSync(path, 'utf8')) + if (result.error !== undefined) { + throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n')) + } + const parsed: unknown = result.config + if (!isRecord(parsed) || !Array.isArray(parsed.overrides)) { + throw new Error('.oxlintrc.json must contain an overrides array') + } + const overrides: readonly unknown[] = parsed.overrides + + it('pins the complete override shape', () => { + expect(overrides).toHaveLength(6) + }) + + it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => { + const rules = mergedRules(overrides, profile.indexes) + const fingerprint = createHash('sha256').update(JSON.stringify(rules)).digest('hex') + + expect(Object.keys(rules)).toHaveLength(profile.count) + expect(fingerprint).toBe(profile.sha256) + }) +}) diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts new file mode 100644 index 0000000000..727bc34bff --- /dev/null +++ b/scripts/oxlint-contract.spec.ts @@ -0,0 +1,250 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' +import { describe, expect, it } from 'vitest' + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url)) +const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value) +} + +function runStagedFormatter(paths: readonly string[]) { + return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + }) +} + +function runOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [oxlintCli, ...args], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', ...env }, + }) +} + +function normalizedOutput(result: ReturnType): string { + return `${result.stdout}${result.stderr}`.replaceAll('\\', '/') +} + +async function writeContractConfig(suffix: string): Promise { + const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`) + await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] })) + return path +} + +describe('Oxlint executable contract', () => { + it('discovers the owning TypeScript project for every file class', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const probes = [ + ['host package source', 'packages/fs/fs-policy/src', 'packages/fs/fs-policy/tsconfig.json'], + ['host package test', 'packages/fs/fs-policy/tests', 'tsconfig.host.json'], + ['client package source', 'packages/client/ui-primitives/src', 'packages/client/ui-primitives/tsconfig.json'], + ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json'], + ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], + ['website', 'website', 'tsconfig.host.json'], + ] as const + const source = `export function probePromise(): Promise { + return Promise.resolve() +} + +probePromise() +` + + try { + const paths: Array = [] + for (const [label, parent, tsconfig] of probes) { + const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`) + await writeFile(path, source) + paths.push([label, relative(repositoryRoot, path), tsconfig]) + } + const clientScript = 'scripts/client-bundle-purity.spec.ts' + + const result = runOxlint([ + '--config', + relative(repositoryRoot, configPath), + '--format', + 'unix', + ...paths.map(([, path]) => path), + clientScript, + ], { OXC_LOG: 'debug' }) + const output = normalizedOutput(result) + + expect(result.error).toBeUndefined() + expect(result.status, output).toBe(1) + for (const [label, path, tsconfig] of paths) { + expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`) + expect(output, `${label} project`).toContain( + `Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`, + ) + } + expect(output.match(/typescript\(no-floating-promises\)/g)).toHaveLength(probes.length) + expect(output, 'client aggregate script project').toContain( + `Got tsconfig for file ${join(repositoryRoot, clientScript).replaceAll('\\', '/')}: ${join(repositoryRoot, 'tsconfig.client.json').replaceAll('\\', '/')}`, + ) + expect(output).not.toContain('Unmatched file:') + } finally { + await Promise.all([ + ...probes.map(([, parent]) => rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`), { force: true })), + rm(configPath, { force: true }), + ]) + } + }, 20_000) + + it('runs JavaScript compatibility and nursery rules', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) + const source = `export function firstProbe(): number { + const first = 1 + const second = 2 + return first + second +} + +export function secondProbe(): number { + const first = 1 + const second = 2 + return first + second +} + +export function hasValue(value: string): boolean { + return value !== undefined +} + +export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +` + + try { + await writeFile(path, source) + const result = runOxlint([ + '--config', + relative(repositoryRoot, configPath), + '--format', + 'unix', + relative(repositoryRoot, path), + ]) + const output = normalizedOutput(result) + + expect(result.error).toBeUndefined() + expect(result.status, output).toBe(1) + expect(output).toContain('@stylistic(max-len)') + expect(output).toContain('sonarjs(no-identical-functions)') + expect(output).toContain('typescript(no-unnecessary-condition)') + } finally { + await Promise.all([ + rm(path, { force: true }), + rm(configPath, { force: true }), + ]) + } + }, 20_000) + + it('keeps formatter rules aligned with Oxlint validation', async () => { + const oxlintPath = join(repositoryRoot, '.oxlintrc.json') + const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8')) + if (result.error !== undefined) { + throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n')) + } + const parsed = result.config as unknown + if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) { + throw new Error('.oxlintrc.json must contain an overrides array') + } + const stylisticOverride = parsed.overrides.find((value: unknown) => + isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules) + if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) { + throw new Error('.oxlintrc.json must contain the @stylistic validator override') + } + const validatorRules = { ...stylisticOverride.rules } + const maxLen = validatorRules['@stylistic/max-len'] + delete validatorRules['@stylistic/max-len'] + + const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href + const formatterModule = await import(formatterUrl) as unknown + if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) { + throw new Error('eslint.format.config.mjs must default-export a config array') + } + const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules)) + if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) { + throw new Error('eslint.format.config.mjs must contain a rules object') + } + + expect(validatorRules).toStrictEqual(formatterOverride.rules) + expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }]) + }) + + it('reports an unused suppression', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) + + try { + await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') + const result = runOxlint([ + '--config', + relative(repositoryRoot, configPath), + '--format', + 'unix', + relative(repositoryRoot, path), + ]) + const output = normalizedOutput(result) + + expect(result.error).toBeUndefined() + expect(result.status, output).toBe(0) + expect(output).toContain('Unused oxlint-disable directive') + } finally { + await Promise.all([ + rm(path, { force: true }), + rm(configPath, { force: true }), + ]) + } + }) + + it('accepts an ignored-only staged selection', () => { + const result = runOxlint([ + '--fix', + '--no-error-on-unmatched-pattern', + 'scripts/install-lefthook.mjs', + ]) + + expect(result.error).toBeUndefined() + expect(result.status, normalizedOutput(result)).toBe(0) + }) + + it('applies staged stylistic fixes before Oxlint validation', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`) + const path = join(directory, 'fix.ts') + + try { + await mkdir(directory, { recursive: true }) + await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n') + + const relativePath = relative(repositoryRoot, path) + const formatResult = runStagedFormatter([relativePath]) + const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath]) + + expect(formatResult.error).toBeUndefined() + expect(formatResult.status, normalizedOutput(formatResult)).toBe(0) + expect(lintResult.error).toBeUndefined() + expect(lintResult.status, normalizedOutput(lintResult)).toBe(0) + await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n') + } finally { + await Promise.all([ + rm(directory, { recursive: true, force: true }), + rm(configPath, { force: true }), + ]) + } + }, 20_000) +}) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 38b7d96a0c..ab80a7329b 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -42,6 +42,18 @@ function withPnpmEntrypoint(action: () => T): T { } } +function withEnv(name: string, value: string | undefined, action: () => T): T { + const previous = process.env[name] + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + try { + return action() + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = previous + } +} + describe('gate graph validation', () => { it.each([ 'ci-primary', @@ -96,6 +108,32 @@ describe('gate graph validation', () => { }) }) +describe('Oxlint gate', () => { + it('uses the package script when no worker bound is configured', () => { + const subject = withEnv('DSH_OXLINT_THREADS', undefined, () => + withPnpmEntrypoint(() => gatesForMode('ci-lint')[0])) + + expect(subject).toMatchObject({ + id: 'lint', + displayCommand: 'pnpm run lint', + command: process.execPath, + args: ['/private/pnpm.cjs', 'run', 'lint'], + }) + }) + + it('surfaces the configured worker bound on the shared package script', () => { + const subject = withEnv('DSH_OXLINT_THREADS', '4', () => + withPnpmEntrypoint(() => gatesForMode('ci-lint')[0])) + + expect(subject).toMatchObject({ + id: 'lint', + displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint', + command: process.execPath, + args: ['/private/pnpm.cjs', 'run', 'lint'], + }) + }) +}) + describe('Node 24 consumer graph', () => { it('owns the seven-command pool and orders restored-artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d10a9ccba7..900b67247e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -181,10 +181,6 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -function nodeOptions(...options: string[]): string { - return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') -} - /** * Construct the complete gate list for a named aggregate. * @param selected - aggregate mode to construct. @@ -380,43 +376,11 @@ function ciWindowsObservationalGates(): Gate[] { ] } -function lintGate(eslintTargets: readonly string[] = ['.']): Gate { - const concurrencyArgs = eslintConcurrencyArgs() - if (process.env.DSH_ESLINT_CACHE === '1') { - return pnpmExec('lint', [ - 'eslint', - ...eslintTargets, - ...concurrencyArgs, - '--cache', - '--cache-location', - '.cache/eslint/', - '--cache-strategy', - 'content', - ], { - label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }) - } - if (concurrencyArgs.length > 0) { - return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { - label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }) - } - return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }) -} - -function eslintConcurrencyArgs(): string[] { - const raw = process.env.DSH_ESLINT_CONCURRENCY - if (raw === undefined || raw === '') return [] - if (raw === 'auto') return ['--concurrency=auto'] - const parsed = Number.parseInt(raw, 10) - if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { - throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`) - } - return [`--concurrency=${raw}`] +function lintGate(): Gate { + const raw = process.env.DSH_OXLINT_THREADS + return pnpmScript('lint', 'lint', raw === undefined || raw === '' + ? {} + : { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` }) } function coverageGate(): Gate { @@ -490,7 +454,6 @@ function docSyncLeafGates(options: { return [ pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), - pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), diff --git a/scripts/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts new file mode 100644 index 0000000000..25245add32 --- /dev/null +++ b/scripts/run-oxlint.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { resolveOxlintInvocation } from './run-oxlint.ts' + +describe('Oxlint invocation', () => { + it('preserves the ordinary default invocation', () => { + expect(resolveOxlintInvocation(['.'], { PATH: '/bin' })).toEqual({ + args: ['.'], + env: { PATH: '/bin' }, + }) + }) + + it('bounds both worker pools from one setting', () => { + expect(resolveOxlintInvocation(['.', '--fix'], { DSH_OXLINT_THREADS: '4', GOMAXPROCS: '12' })).toEqual({ + args: ['.', '--fix', '--threads=4'], + env: { DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' }, + }) + }) + + it.each(['0', '-1', '1.5', 'auto'])('rejects invalid worker bound %s', (value) => { + expect(() => resolveOxlintInvocation(['.'], { DSH_OXLINT_THREADS: value })) + .toThrow('DSH_OXLINT_THREADS must be a positive integer') + }) + + it('rejects a competing direct worker bound', () => { + expect(() => resolveOxlintInvocation(['.', '--threads=2'], { DSH_OXLINT_THREADS: '4' })) + .toThrow('use DSH_OXLINT_THREADS instead') + }) +}) diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts new file mode 100644 index 0000000000..e833f75c69 --- /dev/null +++ b/scripts/run-oxlint.ts @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) + +/** Complete Oxlint child-process arguments and environment. */ +export interface OxlintInvocation { + readonly args: readonly string[] + readonly env: NodeJS.ProcessEnv +} + +/** + * Apply the repository worker bound to both Oxlint backends. + * @param args - Oxlint CLI arguments requested by the caller. + * @param env - Environment inherited by the Oxlint process. + * @returns the complete CLI arguments and child environment. + */ +export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { + const raw = env.DSH_OXLINT_THREADS + if (raw === undefined || raw === '') return { args: [...args], env: { ...env } } + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) + } + if (args.some(arg => arg === '--threads' || arg.startsWith('--threads='))) { + throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.') + } + return { + args: [...args, `--threads=${raw}`], + env: { ...env, GOMAXPROCS: raw }, + } +} + +function main(): void { + const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env) + const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], { + env: invocation.env, + stdio: 'inherit', + }) + if (result.error !== undefined) throw result.error + process.exitCode = result.status ?? 1 +} + +const entrypoint = process.argv[1] +if (entrypoint !== undefined && resolve(entrypoint) === fileURLToPath(import.meta.url)) main() diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d26d2e8d22..acc18f64b4 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a3c16f1241..ce9ede2dff 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -44,7 +44,7 @@ interface InvariantHost { type PluginFiber = ReturnType const hosts = new WeakMap() -// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly. +// oxlint-disable-next-line typescript/unbound-method -- every call below supplies its RegistryService receiver explicitly. const originalPlugin = RegistryService.prototype.plugin RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6ac7cd8863..680a462d36 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -964,6 +964,11 @@ "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillInvocationPolicy", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSummary", diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 8ff42fa876..988c3a2e38 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -126,7 +126,7 @@ function heritageExemption( returnType = d.type.type } else continue baseParams ??= new Set() - // Leading underscores are the deliberately-unused marker (eslint + // Leading underscores are the deliberately-unused marker (lint // argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the // same parameter, so compare underscore-stripped on both sides. for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, '')) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fd74c6d13e..28ceeca2ad 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, + 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' }, 'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' }, @@ -112,6 +114,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index fc2f2c1ee4..a80a3293d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,7 +38,11 @@ "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], + "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], + "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], + "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], + "@deepseek-ai/dsh-typert-generator": ["./packages/typert/generator/src/index.ts"], "@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"], "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], "@deepseek-ai/dsh-session-projection/types": ["./packages/session-projection/session-projection/src/types.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 51f6ad0fa7..3a4a1f1382 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", @@ -38,6 +39,7 @@ "packages/host/directory-picker-browse/**", "packages/host/directory-picker-native/**", "scripts/client-bundle-css.spec.ts", + "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" ], "references": [ @@ -59,6 +61,8 @@ { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, + { "path": "./packages/typert/registry" }, + { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, @@ -147,6 +151,7 @@ { "path": "./packages/ui/tui" }, { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, + { "path": "./packages/typert/generator" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, { "path": "./packages/support/llm-mock-server" }, diff --git a/vitest.config.ts b/vitest.config.ts index 8f9b9ed50e..750bb6ddf2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -98,6 +98,8 @@ export default defineConfig({ 'packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', + // A killed executable lint-contract test can leave a non-product source probe behind. + 'packages/*/*/src/oxlint-contract-*.ts', // GUI step-1 skeleton (PR #500): client/web UI files whose remaining // branches need a browser-grade harness the jsdom lane doesn't cover // yet. TODO(gui): cover and remove as the client test lane matures. @@ -152,6 +154,9 @@ export default defineConfig({ 'packages/client/ui-sidebar/src/client/index.ts', 'packages/client/ui-skill/src/client/index.ts', 'packages/client/ui-workspace/src/client/index.ts', + 'packages/typert/generator/src/analyzer.ts', + 'packages/typert/generator/src/renderer.ts', + 'packages/typert/generator/src/cordis-catalog.ts', 'packages/host/apiproxy/src/index.ts', 'packages/host/apiproxy/src/invariant.ts', 'packages/host/apiproxy/src/api-proxy.ts',