Merge branch 'master' into webtest-migrate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; 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 |
|
||||
|
||||
@@ -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 + 本地进程树实现 | 产品:稳定表面 |
|
||||
|
||||
@@ -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<SessionReferenceCandidate[]> {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
|
||||
@@ -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<boolean>',
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason: string = \'model request\'): Promise<boolean>',
|
||||
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<SessionReferenceCandidate[]>',
|
||||
signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
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 `<package>#<name>`.\n * @param key - global schema key.\n * @returns the live schema record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'resolve(key: string): TypertSchemaRecord',
|
||||
jsDoc: '/**\n * Resolve one required schema.\n * @param key - global schema key.\n * @returns the live schema record.\n * @throws when the key is malformed, the package face is absent, or the schema is not contributed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]',
|
||||
jsDoc: '/**\n * Enumerate live schemas in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching schema records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'getPackage(packageName: string, face: TypertFace = \'host\'): TypertPackageRecord | undefined',
|
||||
jsDoc: '/**\n * Look up generated reflection for one package face.\n * @param packageName - exact npm package name.\n * @param face - face to query; defaults to the host runtime.\n * @returns the live package record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]',
|
||||
jsDoc: '/**\n * Enumerate generated package reflection in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching package records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema',
|
||||
jsDoc: '/**\n * Project a live Zod schema to JSON Schema without caching the result.\n * @param key - global schema key.\n * @param params - Zod projection parameters.\n * @returns a fresh JSON Schema document.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
|
||||
@@ -1281,34 +1315,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */',
|
||||
summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-begin-command',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-begin-command\'(request: BeginCommandRequest): true | undefined',
|
||||
jsDoc: '/**\n * Applies one command claim to the scoped Input. Dispatched with the\n * session\'s scope carrier; the owning session\'s input listener returns\n * `true` only after the phase and span CAS checks pass and the machine\n * actually mutated — producers treat anything else as "not applied".\n * @param request - Claim and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Applies one command claim to the scoped Input.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-consume-token',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-consume-token\'(request: ConsumeTokenRequest): true | undefined',
|
||||
jsDoc: '/**\n * Consumes one command token after business success (popup settle /\n * menu-pick execute). Same carrier routing and applied-truth contract.\n * @param request - Exact span or bare-token guard.\n * @mode bail\n */',
|
||||
summary: 'Consumes one command token after business success (popup settle / menu-pick execute).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-reference',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-reference\'(request: InsertReferenceRequest): true | undefined',
|
||||
jsDoc: '/**\n * Inserts one reference into the scoped Input (same carrier routing and\n * applied-truth contract as begin-command).\n * @param request - Reference and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-text',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-text\'(request: InsertTextRequest): true | undefined',
|
||||
jsDoc: '/**\n * Replaces the trigger token span with literal text — the plain-text\n * reference path (decision 21). Same carrier routing and applied-truth\n * contract; the draft gains ordinary characters, no occurrence entry.\n * @param request - Replacement text and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Replaces the trigger token span with literal text — the plain-text reference path (decision 21).',
|
||||
},
|
||||
{
|
||||
name: 'subagent/end',
|
||||
mode: 'emit',
|
||||
@@ -2698,6 +2704,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TurnTriggerMap',
|
||||
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocTag',
|
||||
declaration: 'export interface TypertDocTag {\n readonly name: string;\n readonly argument?: string;\n readonly comment?: string;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocumentation',
|
||||
declaration: 'export interface TypertDocumentation {\n readonly description?: string;\n readonly summary?: string;\n readonly tags: readonly TypertDocTag[];\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertEventModel',
|
||||
declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertMemberModel',
|
||||
declaration: 'export interface TypertMemberModel {\n readonly kind: \'property\' | \'method\' | \'getter\' | \'setter\' | \'call\' | \'construct\' | \'index\';\n readonly name: string;\n readonly signature: string;\n readonly summary?: string;\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertObjectModel',
|
||||
declaration: 'export interface TypertObjectModel extends TypertDocumentation {\n readonly name: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageFilter',
|
||||
declaration: 'export interface TypertPackageFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageModel',
|
||||
declaration: 'export interface TypertPackageModel {\n readonly services: readonly TypertServiceModel[];\n readonly events: readonly TypertEventModel[];\n readonly objects: readonly TypertObjectModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageRecord',
|
||||
declaration: 'export interface TypertPackageRecord {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchema',
|
||||
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaRecord',
|
||||
declaration: 'export interface TypertSchemaRecord extends TypertSchema {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertServiceModel',
|
||||
declaration: 'export interface TypertServiceModel extends TypertDocumentation {\n readonly key: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertTypeModel',
|
||||
declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'UserInteractionProvider',
|
||||
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
|
||||
|
||||
@@ -115,7 +115,10 @@ export type AgentCancelCause =
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
/**
|
||||
* Public live-agent handle with aliases over the unified delivery primitive.
|
||||
* @typert object
|
||||
*/
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
|
||||
@@ -353,6 +353,7 @@ const attachments = new WeakMap<Session, SessionEntry>()
|
||||
*
|
||||
* 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[] = []
|
||||
|
||||
@@ -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<boolean> {
|
||||
async kill(owner: Agent, id: PtySessionId, reason: string = 'model request'): Promise<boolean> {
|
||||
const record = this.expectOwned(owner, id)
|
||||
if (record.closing !== undefined) {
|
||||
await record.closing
|
||||
|
||||
@@ -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<keyof StorageForms, unknown>()
|
||||
|
||||
|
||||
6
packages/typert/README.i18n.yaml
Normal file
6
packages/typert/README.i18n.yaml
Normal file
@@ -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
|
||||
11
packages/typert/README.md
Normal file
11
packages/typert/README.md
Normal file
@@ -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 |
|
||||
11
packages/typert/README.zh.md
Normal file
11
packages/typert/README.zh.md
Normal file
@@ -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) | 与编译器无关的类型分析和产物生成 | 构建时库 |
|
||||
6
packages/typert/generator/README.i18n.yaml
Normal file
6
packages/typert/generator/README.i18n.yaml
Normal file
@@ -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
|
||||
41
packages/typert/generator/README.md
Normal file
41
packages/typert/generator/README.md
Normal file
@@ -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<SourceType>` 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.
|
||||
41
packages/typert/generator/README.zh.md
Normal file
41
packages/typert/generator/README.zh.md
Normal file
@@ -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<SourceType>`。遇到不支持的 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 导入。
|
||||
- 发现过程会遍历从具体公开导出可达的源文件;既未导出、也未由该图导入的声明会按设计排除在包模型之外。
|
||||
48
packages/typert/generator/package.json
Normal file
48
packages/typert/generator/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1894
packages/typert/generator/src/analyzer.ts
Normal file
1894
packages/typert/generator/src/analyzer.ts
Normal file
File diff suppressed because it is too large
Load Diff
814
packages/typert/generator/src/cordis-catalog.ts
Normal file
814
packages/typert/generator/src/cordis-catalog.ts
Normal file
@@ -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.<key>` 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<Record<string, string>>
|
||||
/** TypeScript or framework types that need no repository documentation link. */
|
||||
readonly foundationTypeNames: ReadonlySet<string>
|
||||
/** Repository types deliberately documented outside the linked data catalog. */
|
||||
readonly typeLinkExemptions: Readonly<Record<string, string>>
|
||||
/** 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<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
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<string, string>
|
||||
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<string, string>()
|
||||
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<string, string>,
|
||||
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<void>') 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<string>()
|
||||
const visited = new Set<TypeNodeId>()
|
||||
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<string, string>,
|
||||
): { name: string; declaration: string }[] {
|
||||
const included = new Map<string, string>()
|
||||
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.<key>` service: its one-line summary and public methods. */',
|
||||
'export interface ServiceApiEntry {',
|
||||
' /** The `ctx.<key>` 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.<key>` 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<Record<string, string>>): string {
|
||||
const seen = new Set<string>()
|
||||
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<Record<string, string>>): 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<Record<string, string>>): 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 = [
|
||||
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
|
||||
'',
|
||||
]
|
||||
|
||||
/** 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.<key>` 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.<key>` 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')
|
||||
}
|
||||
451
packages/typert/generator/src/emitter.ts
Normal file
451
packages/typert/generator/src/emitter.ts
Normal file
@@ -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<string, string[]>()
|
||||
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<SymbolId, string>()
|
||||
private readonly declarations: TypeDeclarationModel[]
|
||||
|
||||
constructor(
|
||||
private readonly renderer: TypeGraphRenderer,
|
||||
private readonly schemas: readonly SchemaModel[],
|
||||
) {
|
||||
const declarations = new Map<SymbolId, TypeDeclarationModel>()
|
||||
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<string>()
|
||||
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<TypeNodeModel, { kind: 'reference' }>): 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')
|
||||
}
|
||||
16
packages/typert/generator/src/index.ts
Normal file
16
packages/typert/generator/src/index.ts
Normal file
@@ -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'
|
||||
31
packages/typert/generator/src/invariant.ts
Normal file
31
packages/typert/generator/src/invariant.ts
Normal file
@@ -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 */
|
||||
375
packages/typert/generator/src/model.ts
Normal file
375
packages/typert/generator/src/model.ts
Normal file
@@ -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)}`)
|
||||
}
|
||||
356
packages/typert/generator/src/renderer.ts
Normal file
356
packages/typert/generator/src/renderer.ts
Normal file
@@ -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<TypeNodeId, TypeNodeModel>
|
||||
private readonly declarations: ReadonlyMap<SymbolId, TypeDeclarationModel>
|
||||
private readonly members: ReadonlyMap<string, MemberModel>
|
||||
private readonly parameterNames = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* 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<SymbolId>()
|
||||
const visiting = new Set<SymbolId>()
|
||||
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)}`)
|
||||
}
|
||||
78
packages/typert/generator/src/tsdown-plugin.ts
Normal file
78
packages/typert/generator/src/tsdown-plugin.ts
Normal file
@@ -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.<face>.js` and `.d.ts` for contributing packages.
|
||||
*/
|
||||
export function typertPlugin(): TypertPlugin {
|
||||
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
|
||||
return {
|
||||
name: 'dsh-typert-generator',
|
||||
writeBundle(options) {
|
||||
// options.dir is the package's absolute outDir (<package>/lib); its
|
||||
// nearest package.json owns the bundle even when a custom config writes
|
||||
// a nested output such as <package>/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
|
||||
}
|
||||
90
packages/typert/generator/src/workspace.ts
Normal file
90
packages/typert/generator/src/workspace.ts
Normal file
@@ -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<string, unknown>)[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<string, unknown>
|
||||
return value.types === expected.types && value.default === expected.default
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
7
packages/typert/generator/tests/fixtures/type-model/cordis.d.ts
vendored
Normal file
7
packages/typert/generator/tests/fixtures/type-model/cordis.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module 'cordis' {
|
||||
export class Service { protected readonly __service?: never }
|
||||
|
||||
export interface Context {}
|
||||
|
||||
export interface Events {}
|
||||
}
|
||||
5
packages/typert/generator/tests/fixtures/type-model/package.json
vendored
Normal file
5
packages/typert/generator/tests/fixtures/type-model/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@fixture/workspace",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
19
packages/typert/generator/tests/fixtures/type-model/packages/client/package.json
vendored
Normal file
19
packages/typert/generator/tests/fixtures/type-model/packages/client/package.json
vendored
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
39
packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts
vendored
Normal file
39
packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts
vendored
Normal file
@@ -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<Payload>
|
||||
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
|
||||
11
packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../host" }
|
||||
]
|
||||
}
|
||||
23
packages/typert/generator/tests/fixtures/type-model/packages/host/package.json
vendored
Normal file
23
packages/typert/generator/tests/fixtures/type-model/packages/host/package.json
vendored
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
143
packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts
vendored
Normal file
143
packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts
vendored
Normal file
@@ -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<State extends object = { ready: boolean }> 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<Value>(input: Box<Value>): Promise<Present<Value>> {
|
||||
return Promise.resolve(input.value as Present<Value>)
|
||||
}
|
||||
}
|
||||
|
||||
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<Payload>): Present<Payload> {
|
||||
return { name: agent.id, count: Object.keys(flags).length }
|
||||
}
|
||||
|
||||
/** Keep an npm-owned type as External. */
|
||||
acceptsExternal(schema: ZodType<string>): 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> {
|
||||
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<string>
|
||||
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<Payload>): 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<Payload>): void
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultOnlyService
|
||||
160
packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts
vendored
Normal file
160
packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
/** Generic source form retained before conditional evaluation. */
|
||||
export interface Box<T> {
|
||||
/** The boxed value. */
|
||||
readonly value: T
|
||||
}
|
||||
|
||||
/** Conditional source form retained instead of its resolved instantiations. */
|
||||
export type Present<T> = T extends null | undefined ? never : T
|
||||
|
||||
/** Mapped source form retained instead of materialized properties. */
|
||||
export type Flags<T> = {
|
||||
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<in Input, out Output, in out State> {
|
||||
consume: (input: Input) => void
|
||||
readonly produce: () => Output
|
||||
state: State
|
||||
}
|
||||
|
||||
/** Infer form nested inside a conditional type. */
|
||||
export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never
|
||||
|
||||
/** Constrained infer form retained before conditional evaluation. */
|
||||
export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never
|
||||
|
||||
/** Template-literal source form. */
|
||||
export type Topic<Name extends string> = `demo/${Name}`
|
||||
|
||||
/** Multiple template spans retain each authored suffix. */
|
||||
export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`
|
||||
|
||||
/** Preserve mapped modifiers when none were authored. */
|
||||
export type PlainMap<Value> = {
|
||||
[Key in keyof Value]: Value[Key]
|
||||
}
|
||||
|
||||
/** Retain key remapping and explicit modifier removal. */
|
||||
export type Remapped<Value> = {
|
||||
-readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key]
|
||||
}
|
||||
|
||||
/** Retain explicit mapped modifier addition. */
|
||||
export type Added<Value> = {
|
||||
+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>(): 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<string> {
|
||||
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: <Value extends Entity = Entity>(
|
||||
this: Entity,
|
||||
value: Value,
|
||||
optional?: string,
|
||||
...rest: number[]
|
||||
) => Promise<Value>
|
||||
constCallback: <const Value extends readonly string[]>(value: Value) => Value
|
||||
factory: new <Value extends Entity>(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<string>
|
||||
imported: import('zod').ZodType<string>
|
||||
importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>
|
||||
importedModule: typeof import('zod')
|
||||
process: NodeJS.Process
|
||||
callable: Callable
|
||||
guards: Guards
|
||||
variance: Variance<Entity, Payload, Box<string>>
|
||||
plainMap: PlainMap<Payload>
|
||||
remapped: Remapped<Payload>
|
||||
added: Added<Payload>
|
||||
abstractEntity: AbstractEntity
|
||||
recursive: Recursive
|
||||
tagOnly: TagOnly
|
||||
unpunctuated: Unpunctuated
|
||||
}
|
||||
8
packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/typert/generator/tests/fixtures/type-model/packages/write/package.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/type-model/packages/write/package.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@fixture/write",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts
vendored
Normal file
18
packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts
vendored
Normal file
@@ -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
|
||||
8
packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json
vendored
Normal file
22
packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.client.json
vendored
Normal file
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.client.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/client" }
|
||||
]
|
||||
}
|
||||
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.host.json
vendored
Normal file
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.host.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/host" }
|
||||
]
|
||||
}
|
||||
4
packages/typert/generator/tests/fixtures/type-model/tsconfig.json
vendored
Normal file
4
packages/typert/generator/tests/fixtures/type-model/tsconfig.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": ["cordis.d.ts"]
|
||||
}
|
||||
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.write.json
vendored
Normal file
7
packages/typert/generator/tests/fixtures/type-model/tsconfig.write.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/write" }
|
||||
]
|
||||
}
|
||||
198
packages/typert/generator/tests/renderer.spec.ts
Normal file
198
packages/typert/generator/tests/renderer.spec.ts
Normal file
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
730
packages/typert/generator/tests/schema-emitter.spec.ts
Normal file
730
packages/typert/generator/tests/schema-emitter.spec.ts
Normal file
@@ -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<TypeNodeModel['kind'], 'supported' | 'unsupported'>
|
||||
|
||||
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['kind'], 'property'>): 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<Pick<
|
||||
TypeDeclarationModel,
|
||||
'abstract' | 'typeParameters' | 'extends' | 'implements' | 'members' | 'type' | 'enumMembers'
|
||||
>> = {},
|
||||
): 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()
|
||||
}
|
||||
68
packages/typert/generator/tests/tools-catalog.spec.ts
Normal file
68
packages/typert/generator/tests/tools-catalog.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
106
packages/typert/generator/tests/tsdown-plugin.spec.ts
Normal file
106
packages/typert/generator/tests/tsdown-plugin.spec.ts
Normal file
@@ -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<string> {
|
||||
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<string, unknown>,
|
||||
output = 'lib',
|
||||
): Promise<string> {
|
||||
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
|
||||
}
|
||||
1246
packages/typert/generator/tests/type-model.spec.ts
Normal file
1246
packages/typert/generator/tests/type-model.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
packages/typert/generator/tsconfig.json
Normal file
21
packages/typert/generator/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/typert/loader/README.i18n.yaml
Normal file
6
packages/typert/loader/README.i18n.yaml
Normal file
@@ -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
|
||||
24
packages/typert/loader/README.md
Normal file
24
packages/typert/loader/README.md
Normal file
@@ -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.
|
||||
24
packages/typert/loader/README.zh.md
Normal file
24
packages/typert/loader/README.zh.md
Normal file
@@ -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()`。
|
||||
45
packages/typert/loader/package.json
Normal file
45
packages/typert/loader/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
351
packages/typert/loader/src/index.ts
Normal file
351
packages/typert/loader/src/index.ts
Normal file
@@ -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<Config> = z.object({
|
||||
packages: z.array(z.string().min(1)).default([]),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
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<string, unknown>)[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<string, unknown>).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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`typert-loader: ${pkgName} ${subject} must be an object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>, 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<string, unknown>, 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<void> {
|
||||
// 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<string, () => void>()
|
||||
// In-flight import/register tasks by entry name.
|
||||
const pending = new Map<string, Promise<void>>()
|
||||
// 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<string, string | null>()
|
||||
// Imported+validated manifests by package name (one import per package per process).
|
||||
const manifests = new Map<string, Promise<TypertContribution>>()
|
||||
const dirty = new Set<string>()
|
||||
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<string, unknown>
|
||||
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<TypertContribution> => {
|
||||
let loading = manifests.get(pkgName)
|
||||
if (loading === undefined) {
|
||||
loading = import(pathToFileURL(path).href).then(
|
||||
(mod: Record<string, unknown>) => 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<void> | 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<void>[] => {
|
||||
const tasks: Promise<void>[] = []
|
||||
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))
|
||||
}
|
||||
30
packages/typert/loader/src/invariant.ts
Normal file
30
packages/typert/loader/src/invariant.ts
Normal file
@@ -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 */
|
||||
462
packages/typert/loader/tests/loader.spec.ts
Normal file
462
packages/typert/loader/tests/loader.spec.ts
Normal file
@@ -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<void> {
|
||||
const dir = join(base, 'node_modules', ...pkgName.split('/'))
|
||||
await mkdir(dir, { recursive: true })
|
||||
const exportsField: Record<string, unknown> = { '.': './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> {
|
||||
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<void> {
|
||||
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<Context['plugin']> {
|
||||
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<void>((resolve) => { markStarted = resolve })
|
||||
let releaseImport: (() => void) | undefined
|
||||
const wait = new Promise<void>((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<typeof ctx.loader.internal>
|
||||
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],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
30
packages/typert/loader/tsconfig.json
Normal file
30
packages/typert/loader/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/typert/registry/README.i18n.yaml
Normal file
6
packages/typert/registry/README.i18n.yaml
Normal file
@@ -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
|
||||
31
packages/typert/registry/README.md
Normal file
31
packages/typert/registry/README.md
Normal file
@@ -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 `<package>#<face>`. Schemas are keyed by `<package>#<name>` 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.
|
||||
31
packages/typert/registry/README.zh.md
Normal file
31
packages/typert/registry/README.zh.md
Normal file
@@ -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) 负责。
|
||||
|
||||
包反射信息以 `<package>#<face>` 为键。schema 以 `<package>#<name>` 为键,并保留生成方的 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,系统会将其作为重复项拒绝。
|
||||
45
packages/typert/registry/package.json
Normal file
45
packages/typert/registry/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
219
packages/typert/registry/src/index.ts
Normal file
219
packages/typert/registry/src/index.ts
Normal file
@@ -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 `<package>#<name>`.
|
||||
*/
|
||||
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 `<package>#<face>`.
|
||||
*/
|
||||
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<string, TypertSchemaRecord>()
|
||||
private readonly packages = new Map<string, TypertPackageRecord>()
|
||||
|
||||
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 `<package>#<name>`.
|
||||
* @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 "<package>#<name>"`)
|
||||
}
|
||||
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<string>()
|
||||
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
|
||||
31
packages/typert/registry/src/invariant.ts
Normal file
31
packages/typert/registry/src/invariant.ts
Normal file
@@ -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 */
|
||||
112
packages/typert/registry/src/types.ts
Normal file
112
packages/typert/registry/src/types.ts
Normal file
@@ -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
|
||||
}
|
||||
148
packages/typert/registry/tests/typert.spec.ts
Normal file
148
packages/typert/registry/tests/typert.spec.ts
Normal file
@@ -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<Context> {
|
||||
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 "<package>#<name>"')
|
||||
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)
|
||||
})
|
||||
})
|
||||
21
packages/typert/registry/tsconfig.json
Normal file
21
packages/typert/registry/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
packages/typert/registry/tsdown.config.ts
Normal file
25
packages/typert/registry/tsdown.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user