Merge commit 'refs/codex/pr885/master-20260730' into worktree/retarget-pr885-20260729

# Conflicts:
#	eslint.config.mjs
This commit is contained in:
Tianyi Cui
2026-07-30 01:18:28 +08:00
122 changed files with 16714 additions and 2564 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc
README.md: fd5e1e8ec1a0ca426ed717cfa9613c51728c60e1
README.zh.md: ad4f315171377677a934d8bb02d15c2db96e0e91

View File

@@ -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 |

View File

@@ -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 + 本地进程树实现 | 产品:稳定表面 |

View File

@@ -286,3 +286,78 @@ describe('WorkspacesService', () => {
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})
describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { api, sessions, workspaces }
}
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
const b = bench()
const stop = b.workspaces.startInitialSelection()
// Nothing happens before both baselines land.
expect(b.api.callsOf('session.create')).toHaveLength(0)
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
await b.workspaces.refresh()
await b.sessions.refresh()
// Store notifications and the connect round trip are microtask-batched.
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
stop()
})
it('stays idle when a session is already current or no recent Workspace exists', async () => {
const withCurrent = bench()
withCurrent.api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
}))
await withCurrent.sessions.refresh()
withCurrent.sessions.open(sid('s1'))
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
const stopCurrent = withCurrent.workspaces.startInitialSelection()
await withCurrent.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
stopCurrent()
const noRecent = bench()
const stopEmpty = noRecent.workspaces.startInitialSelection()
await noRecent.workspaces.refresh()
await noRecent.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
stopEmpty()
})
it('a failed connect returns to waiting and retries on the next list change', async () => {
const b = bench()
b.api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
}))
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
const stop = b.workspaces.startInitialSelection()
await b.workspaces.refresh()
await b.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(1)
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
// Recovery: the next workspace-list change re-runs the reconcile.
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
await b.workspaces.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(2)
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
stop()
})
})

View File

@@ -237,6 +237,20 @@ export class TestSessions implements ISessions {
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Update a session's list row (the wire-echo stand-in: title settles,
* running flips — components subscribed via useSessions re-render).
* @param id - session id.
* @param patch - summary fields to merge over the row.
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = { ...record.summary, ...patch }
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.

View File

@@ -0,0 +1,245 @@
// @vitest-environment jsdom
/**
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
* machinery, real renderer; data fed as fixtures) for surfaces that were
* previously pinned only by the assembled-app jsdom snapshots
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
*
* - the todo_write turn reaches BOTH surfaces through the product
* registrations (keyed toolview row in the flow, plan strip in the input
* dock via the 'todos' projection) and the strip follows projection
* retirement;
* - the bash keyed row carries its resident terminal card, and the fallback
* row reaches the same card through its expand control;
* - the resident composer textarea survives the blank→active conversion as
* the SAME DOM node (focus/IME continuity rides React reconciliation:
* component identity + tree position, which this assembled tree pins).
*
* Component-level behavior (collapse interaction, card model arms, summary
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
* suite only proves the assembled wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const TODOS: TodoItem[] = [
{ content: '梳理需求', status: 'completed' },
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
...over,
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
const LAYOUT_CHILDREN = {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
} as const
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
snapshot: {
nodes,
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
},
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
return runtime
}
describe('todo_write assembly (product registrations, no outlet twins)', () => {
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
const runtime = await bench([todoResult(3)])
// The dock strip reads the host-computed 'todos' projection.
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
const view = runtime.renderRoot()
// Keyed toolview registration took the row (summary derived from args).
const row = view.container.querySelector('[data-sample="todo-row"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
// The plan strip sits in the input dock, fed by the projection
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
// Next turn retires the standing plan (host pushes null): the strip
// clears while the historical row stays in the flow.
await runtime.flush()
runtime.sessions.behavior(SID).projections.set('todos', null)
await waitFor(() => {
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
})
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
await runtime.dispose()
})
})
describe('terminal card assembly', () => {
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
const runtime = await bench([
bashResult(3, 'c-keyed'),
// An unregistered tool with terminal views: GenericToolCard fallback.
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
])
const view = runtime.renderRoot()
// Keyed BashRow renders the card residently (no expand gesture).
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
// Fallback row: card appears only after its expand control.
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
expect(fallback).not.toBeNull()
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
await waitFor(() => {
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
})
await runtime.dispose()
})
})
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
// No session entity: the inert twin renders (disabled textarea), and the
// workspace picker chip is the only live control.
const textarea = view.container.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea!.disabled).toBe(true)
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
await runtime.dispose()
})
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
const runtime = await bench([], { blank: true })
// The hero renders the LIVE composer only when the blank session's
// workspace resolves a chip title; an ownerless blank session shows the
// disabled twin instead (deleted-workspace semantics).
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
const view = runtime.renderRoot()
const hero = view.container.querySelector('textarea')
expect(hero).not.toBeNull()
expect(hero!.disabled).toBe(false)
// First acceptance: the session leaves blank and the composer docks.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.blank = false
draft.composerPhase = 'active'
})
const docked = view.container.querySelector('textarea')
expect(docked).toBe(hero)
await runtime.dispose()
})
})
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.provide('locale', new LocaleService(runtime.ctx))
const prompt = vi.fn<ISession['prompt']>(async () => ({
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}))
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
const composer = view.container.querySelector('textarea')!
fireEvent.change(composer, { target: { value: 'do not lose this' } })
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
// The rejection lands in snapshot.promptError (the Session's own path);
// the fixture mirrors that hop — the assembled InputBar renders it.
await runtime.sessions.updateSnapshot(SID, (draft) => {
draft.promptError = {
op: 'send',
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
}
})
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
// Failure restore: the machine returned the draft to the same textarea.
await waitFor(() => {
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
})
await runtime.dispose()
})
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
expect(crumb.queryByText('S')).toBeNull()
await runtime.dispose()
})
})

View File

@@ -0,0 +1,118 @@
// @vitest-environment jsdom
/**
* The session-rename assembly chain on SlotTestRuntime (real apply, real
* WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog →
* the injected renameSession hop (sessions.binding → ISession.rename) → on
* the accepted unary response the dialog closes and the row re-labels from
* the list state — no push-frame wait. Previously pinned only by the
* assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the
* verb's wire behavior stays with the runtime package
* (session.spec.ts#rename), the dialog's own arms with rows.spec /
* workspace-browser.spec.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
/** Test-owned sidebar shell role: declares and renders the browsing region. */
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
function SidebarFrame({ renderSlot }: FrameProps) {
return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
}
describe('session rename through the assembled browser', () => {
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
const runtime = await SlotTestRuntime.create()
const rename = vi.fn<ISession['rename']>(async title => ({
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
}))
await runtime.sessions.add({
id: SID,
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never
})
await runtime.root.declare(
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
SidebarFrame as never,
)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
// The current session's group auto-expands; open the row's action menu.
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
// The dialog seeds from the current title; submit a padded value.
const input = await view.findByLabelText('Session name') as HTMLInputElement
expect(input.value).toBe('旧标题')
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
// The injected hop reached the session face with the edge-trimmed draft
// (the dialog trims edges; interior normalization is host-side).
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
// Acceptance closes the dialog without any push-frame wait.
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
// The manager lands the unary echo in the list store (its own package
// tests own that hop); the row re-labels from list state alone.
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
await view.findByText('分叉 实验记录')
expect(view.queryByText('旧标题')).toBeNull()
await runtime.dispose()
})
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
const runtime = await SlotTestRuntime.create()
const rename = vi.fn<ISession['rename']>(async () => ({
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
}))
await runtime.sessions.add({
id: SID,
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never
})
await runtime.root.declare(
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
SidebarFrame as never,
)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
await runtime.flush()
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
const input = await view.findByLabelText('Session name')
fireEvent.change(input, { target: { value: '新名' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
// Failure: the injected hop rethrows the business error; the dialog
// stays open with the alert and the row keeps its title.
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('title write failed')
expect(view.getByLabelText('Session name')).toBeTruthy()
expect(view.getByText('旧标题')).toBeTruthy()
await runtime.dispose()
})
})

View File

@@ -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) {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97
README.zh.md: 237b72244be2336a82c8c48cb341d7f9796d08f4
README.md: eda135d93e2912bbb4e111af40d176409b383b5b
README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc

View File

@@ -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

View File

@@ -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未知或未运行的服务目标会高声失败
## 渲染

View File

@@ -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}',

View File

@@ -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

View File

@@ -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[] = []

View File

@@ -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

View File

@@ -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>()

View 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
View 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 |

View 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) | 与编译器无关的类型分析和产物生成 | 构建时库 |

View 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

View 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.

View 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 导入。
- 发现过程会遍历从具体公开导出可达的源文件;既未导出、也未由该图导入的声明会按设计排除在包模型之外。

View 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"
}
}

File diff suppressed because it is too large Load Diff

View 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')
}

View 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')
}

View 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'

View 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 */

View 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)}`)
}

View 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)}`)
}

View 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
}

View 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

View File

@@ -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', () => {

View 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'),
)
})
})

View File

@@ -0,0 +1,7 @@
declare module 'cordis' {
export class Service { protected readonly __service?: never }
export interface Context {}
export interface Events {}
}

View File

@@ -0,0 +1,5 @@
{
"name": "@fixture/workspace",
"private": true,
"type": "module"
}

View 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"
]
}

View 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

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../host" }
]
}

View 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"
]
}

View 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

View 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
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,11 @@
{
"name": "@fixture/write",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
}
}
}

View 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

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View 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
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/client" }
]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/host" }
]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.base.json",
"files": ["cordis.d.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/write" }
]
}

View 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 ?? [],
}
}

View 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()
}

View 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()
})
})

View 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
}

File diff suppressed because it is too large Load Diff

View 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"
}
]
}

View 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

View 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.

View 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()`

View 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"
}
}

View 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))
}

View 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 */

View 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],
}],
},
}
}

View 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"
}
]
}

View 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

View 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.

View 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系统会将其作为重复项拒绝。

View 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"
}
}

View 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()')
// oxlint-disable-next-line typescript/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

View 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 */

View 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
}

View 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)
})
})

View 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"
}
]
}

View 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,
},
])