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/.gitignore b/.gitignore index 2ac3f1d7a9..4444b7b675 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ apps/web/dist/ .worktrees/ worktrees/ .agents/worktrees/ +.typert-*/ 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/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index a19038f1e7..4e25927711 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: a1ddad111e0478f5b165e10b714cee4c97f0ae00 -architecture.zh.md: add3627951c16d4c6d58df25779773abe1f02f08 +architecture.md: 9a33a85e806e04dec3bfb73de8e4781c90f37677 +architecture.zh.md: 6fb91d6511c1213fa44da8a3ca17b100e1e29b95 diff --git a/docs/architecture.md b/docs/architecture.md index a1ddad111e..9a33a85e80 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 add3627951..6fb91d6511 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 3783bfd284..191d96b255 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f311aa419e..e6a52ce5a4 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:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:329`](../../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:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:253`](../../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:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../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:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -183,7 +183,7 @@ A still-pending inbox item changed content or position. The item id and placemen Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -206,7 +206,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:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -230,7 +230,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:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -260,7 +260,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:398`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -282,7 +282,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:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -307,7 +307,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:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -327,7 +327,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:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -351,7 +351,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:366`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -377,7 +377,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:413`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:416`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -680,75 +680,6 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan Source: [`packages/skill/skill/src/index.ts:188`](../../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) - ## `subagent/*` ### `subagent/end` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 826c5f49d7..c655de3a70 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` @@ -2199,6 +2199,67 @@ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) +## `ctx.typert` — `TypertRegistry` + +Registry of generated schemas and package reflection. + +```ts cordis-catalog +/** + * 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 + +/** + * 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 5d2725f049..93cb9ebf9a 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: 10c138877d25bd0c74af02a64245a48876a81255 -core.zh.md: b32849bf0737f98102266fa21b7335857229e546 +core.md: a39560eecca4689186ce2d3fc183250f8698cb90 +core.zh.md: a5e3b80e11c97d64de1afd082ef02097eb61787c diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 10c138877d..a39560eecc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -504,7 +504,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 b32849bf07..a5e3b80e11 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -512,7 +512,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/event-producer-consumer.md b/docs/event-producer-consumer.md index 80ab95f34b..f4767e035d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ 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:326`](../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:250`](../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:259`](../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:440`](../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:304`](../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:316`](../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:278`](../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/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:353`](../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:379`](../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:398`](../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:339`](../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:427`](../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:268`](../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:366`](../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:413`](../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:329`](../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:253`](../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:262`](../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:443`](../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:307`](../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:319`](../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:281`](../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/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:356`](../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:382`](../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:401`](../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:342`](../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:430`](../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:271`](../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:369`](../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:416`](../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) | @@ -37,10 +37,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:188`](../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` | | `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) | @@ -68,9 +64,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..95d2d3bb0f 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"] @@ -274,6 +279,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 @@ -321,6 +328,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 @@ -1003,6 +1012,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) | @@ -1019,6 +1030,7 @@ 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) | 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 index 696b082828..7098d8b9c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -185,4 +185,17 @@ export default tseslint.config( '@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }], }, }, + + // TypeGraph coverage must retain source-authored syntax that production lint rules forbid. + { + files: ['packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'], + rules: { + '@stylistic/quotes': 'off', + '@typescript-eslint/no-deprecated': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-mixed-enums': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unnecessary-type-parameters': 'off', + }, + }, ) 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/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/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 3f103be67c..0083e7af2c 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 */', }, { @@ -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.', @@ -1288,34 +1322,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', @@ -2717,6 +2723,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/src/types.ts b/packages/core/agent/src/types.ts index 242bfd00e4..d32eb303bc 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -135,7 +135,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/session/src/index.ts b/packages/core/session/src/index.ts index e42414503b..70e79a3008 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[] = [] 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/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/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..90e59ca316 --- /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()') + // eslint-disable-next-line @typescript-eslint/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/pnpm-lock.yaml b/pnpm-lock.yaml index 362e622aab..5c38a0982c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4881,6 +4881,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: 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 b942a3d3ee..4c30f8f2c5 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', @@ -52,8 +45,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', @@ -207,12 +200,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', @@ -220,7 +214,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', @@ -245,6 +239,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', @@ -271,401 +273,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')) { @@ -675,9 +327,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) @@ -698,7 +348,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/run-gates.ts b/scripts/run-gates.ts index d10a9ccba7..e975e42693 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -490,7 +490,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/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..527dc44758 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"], @@ -57,6 +61,7 @@ "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], + "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index f7b102c9aa..7cd27c954c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -40,6 +40,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": [ @@ -61,6 +62,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" }, @@ -149,6 +152,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..b74dd938a0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -152,6 +152,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',