Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/web/tests/session-title.snapshot.ts
This commit is contained in:
Yichen Jiang
2026-07-30 10:27:00 +08:00
218 changed files with 18147 additions and 3252 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

@@ -601,7 +601,7 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }

View File

@@ -32,7 +32,7 @@ const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
// oxlint-disable-next-line typescript/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {

View File

@@ -9,7 +9,7 @@
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
@@ -310,6 +310,6 @@ export class SlotsService extends Service {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */

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

@@ -10,7 +10,7 @@
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */

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

@@ -8,7 +8,7 @@
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -19,7 +19,7 @@ export async function writeClipboard(text: string): Promise<void> {
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -36,7 +36,7 @@ export async function writeClipboard(text: string): Promise<void> {
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
/* oxlint-enable typescript/no-deprecated */
el.remove()
}

View File

@@ -105,7 +105,7 @@ export function InputBar({
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
@@ -165,8 +165,8 @@ export function InputBar({
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
// oxlint-disable-next-line typescript/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length)
}
@@ -178,13 +178,13 @@ export function InputBar({
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
/* oxlint-disable typescript/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget

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

@@ -12,7 +12,7 @@
export async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
@@ -25,7 +25,7 @@ export async function writeClipboard(text: string): Promise<boolean> {
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
@@ -44,5 +44,5 @@ export async function writeClipboard(text: string): Promise<boolean> {
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
/* oxlint-enable typescript/no-deprecated */
}

View File

@@ -16,7 +16,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
let s: string
try {
// lib typing hides stringify's undefined arm (undefined/function/symbol payloads).
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
s = JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
s = String(payload)

View File

@@ -38,7 +38,7 @@ export function parseQuestionTitle(title: string): string {
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
// oxlint-disable-next-line typescript/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
@@ -64,9 +64,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0

View File

@@ -8,7 +8,7 @@
* consumer `declare module` augmentation merges with declarations lexically in
* the augmented module, not with re-exports.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in THIS compilation unit (so the intersection reads as `never`), but every
* consumer merges keys in and the intersection is what keeps them string-typed.
@@ -350,7 +350,7 @@ interface ErasedOptions {
priority?: number | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
* against the per-declaration InjectParams tuples. */

View File

@@ -21,7 +21,7 @@ export type MaybeSnapshotSelectorHook<T> =
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
@@ -95,14 +95,14 @@ export interface StoreHandle<T, A extends ActionsDecl<T>> {
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
/* oxlint-disable-next-line typescript/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */

View File

@@ -41,7 +41,7 @@ describe('tsdown client artifact', () => {
// Same execution form the loader uses (inline script eval, window scope) —
// the implied-eval ban targets accidental string execution, not this
// deliberate bundle-execution fixture.
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
new Function(code!)()
expect(handoff).toBeDefined()
const modules = new Map<string, unknown>([

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

@@ -134,7 +134,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
// The slot's VALUE is stored for restore and reassigned — never invoked
// detached, so the unbound-method concern does not apply.
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const original = stream.write
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
logs.push(typeof chunk === 'string' ? chunk : String(chunk))

View File

@@ -195,7 +195,7 @@ export class BasicCompactService extends CompactService {
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
@@ -205,14 +205,14 @@ export class BasicCompactService extends CompactService {
return { kind: 'retry' }
}
ctx.logger.warn(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while compaction is awaited.
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
if (result !== null) logResult(result, 'context overflow recovery')

View File

@@ -49,7 +49,7 @@ export function selectCompactableRange(
let accumulated = 0
let keepFromIdx = pricedNodes.length
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
accumulated += pricedNodes[index]!.tokens
keepFromIdx = index
if (accumulated >= retainTokens) break
@@ -57,15 +57,15 @@ export function selectCompactableRange(
if (keepFromIdx === 0) return null
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const first = surfaceNodes[0]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const cutoff = surfaceNodes[keepFromIdx - 1]!
return { start: first, end: cutoff }
}
@@ -98,11 +98,11 @@ export async function compactSurfaceRegion(
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
@@ -196,7 +196,7 @@ function buildSummarizationInput(
const events = session.events
const regionMessages = shadowedSeqs
// shadowedSeqs are current surface seqs, so each is a valid log index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
.map(seq => session.deriveEventMessage(events[seq]!))
.filter((message): message is Message => message !== null)
return {
@@ -213,7 +213,7 @@ function inspectTurnTail(
let compactionInProgress = false
let compactionStateKnown = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (!compactionStateKnown) {
if (event.type === 'compact/start') {

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

@@ -221,7 +221,7 @@ export class ReactLoopAgent implements Agent {
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
// The some() guard above proves the queue is non-empty; the non-null
// assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const { message } = this.queued.shift()!
const inheritedOutboxLength = this.outbox.length
@@ -368,7 +368,7 @@ export class ReactLoopAgent implements Agent {
outcome.failure, requestFailureHistory, outcome.retryPolicy, signal,
() => Promise.resolve<RequestErrorAction>(undefined),
)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- signal can abort while recovery is awaited.
if (action?.kind === 'retry' && !signal.aborted) {
retryFailures = Object.freeze([...requestFailureHistory, outcome.failure])
}
@@ -584,7 +584,7 @@ export class ReactLoopAgent implements Agent {
const maxTokens = this.options.maxTokens
const seedConfig = deepFreeze(structuredClone(
this.requestHeaderLogged
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds
// oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
? persistedConfig!
: {
...route,

View File

@@ -83,7 +83,7 @@ export async function executeToolCalls(
let concluded = false
while (next < planned.length) {
// Commit before classifying again so registry changes affect unstarted calls.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
const first = planned[next]!
const mode = ctx.tools.executionMode(first.exec).kind
const group = mode === 'parallel' ? planned.slice(next) : [first]
@@ -151,7 +151,7 @@ async function runGroup(
const result = slot.needsPost
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
for (const context of result.additionalContexts ?? []) acceptContext(context)
concluded ||= result.concludesTurn === true
@@ -162,7 +162,7 @@ async function runGroup(
const inFlight = new Map<number, Promise<number>>()
const startCall = async (index: number): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index
const call = group[index]!
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
@@ -198,7 +198,7 @@ async function runGroup(
const fillPool = async (): Promise<void> => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
// Re-read later modes after ordered commits so registry changes can create a barrier.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
const nextCall = group[nextToStart]!
if (nextToStart > 0 && mode === 'parallel'
&& ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break

View File

@@ -249,7 +249,7 @@ describe('config-driven session id', () => {
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', () => { throw unrenderable })
// Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never)
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable)

View File

@@ -108,12 +108,12 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
}
},
async serial(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
},
waterfall(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
return waterfall(carrier, name, agent, ...rest)
},

View File

@@ -328,7 +328,7 @@ export class AgentRegistry extends Service {
// caller's composite effect can yield it for in-order teardown; the
// loop's constructor effect returns it directly, identity-nesting the
// registration under that effect.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
@@ -355,7 +355,7 @@ export class AgentRegistry extends Service {
// capability and need no Cordis tracker magic.
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.createAgent, receiver, [ownerCtx, options])
}
@@ -370,7 +370,7 @@ export class AgentRegistry extends Service {
const ownerCtx = this.ctx
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.resume, receiver, [ownerCtx, options])
}
@@ -397,7 +397,7 @@ export class AgentRegistry extends Service {
yield this.enter(agent, this.ctx.agent)
this.announce(agent)
}.bind(this), 'agents.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}

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

@@ -241,7 +241,7 @@ export class ScopedLayers<L extends ScopeLayer> {
}
if (notify) this.onChange()
}.bind(this), options.label)
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
// oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
return dispose
}
}

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[] = []
@@ -596,7 +597,7 @@ export class Session {
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
@@ -911,7 +912,7 @@ export class SessionStore extends Service {
} catch (error: unknown) {
// Preserve the listener's exact rejection value; flush is a caller-owned
// failure boundary, and Cordis listeners may throw arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
return Promise.reject(error)
}
}))

View File

@@ -340,7 +340,7 @@ export class SurfaceManager implements SessionSurface {
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
}

View File

@@ -86,7 +86,7 @@ describe('packChunkRuns', () => {
['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]],
['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)],
])('breaks a run on %s (both halves too short to pack)', (_label, events) => {
expect(packChunkRuns(events as SessionEvent[])).toStrictEqual(events)
expect(packChunkRuns(events)).toStrictEqual(events)
})
it('breaks a tool-call run on call-id or name change', () => {

View File

@@ -546,19 +546,19 @@ export function defineTool<const S extends ParameterSchemaSpec, const O extends
options: DefineToolOptions<S, O>,
): ToolDefinition {
// Object-literal methods do not use `this`; retaining references is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userFinalizeContent = options.finalizeContent
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userRender = options.output.render
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userPresentationMeta = options.output.presentationMeta
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userPresentResult = options.presentResult
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const userIsConcurrencySafe = options.isConcurrencySafe
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)

View File

@@ -27,7 +27,7 @@ export type ContentToolFixtureOptions<S extends ParameterSchemaSpec> = Omit<
export function defineContentToolFixture<const S extends ParameterSchemaSpec>(
options: ContentToolFixtureOptions<S>,
): ToolDefinition {
// eslint-disable-next-line @typescript-eslint/unbound-method
// oxlint-disable-next-line typescript/unbound-method
const execute = options.execute
return defineTool({
...options,

View File

@@ -148,7 +148,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
}
// Cardinality was checked above, so the fallback index zero exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const task = prompt ?? parsed.positionals[0]!
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
@@ -301,7 +301,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!firstTurnEnded) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
if (!firstTurnEnded) { // oxlint-disable-line typescript/no-unnecessary-condition
agent.followup(createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } }))
}
await turnEnded
@@ -361,7 +361,7 @@ async function bootInterruptibly(
return await Promise.race([booting, interruptedBoot])
} catch (error: unknown) {
// The awaited race permits the signal to change after the preflight check.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (signal.aborted) {
void booting.then(
async (lateContext) => {

View File

@@ -32,6 +32,9 @@ class ObservedStateGate {
* the write/edit prior-observation policy.
*/
private owner(actor: object | undefined): object | undefined {
// tsgolint treats object as assignable to weak FsPolicyExec, while tsc still requires the structural cast for property access.
// See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md.
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type.
return (actor as FsPolicyExec | undefined)?.agent?.session
}

View File

@@ -103,7 +103,7 @@ export function applyGoalProjection(state: GoalProjection | null, event: Session
// Session-log data is a durable boundary: the static type promises the kind,
// but a foreign or corrupted change record must degrade to same-reference,
// never feed the zod parse in the registry drive.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- durable-boundary guard
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
if (change === undefined || change.kind !== 'goal/change') return state
if (change.operation === 'clear') return null
return {

View File

@@ -23,7 +23,7 @@ export class GoalError extends HarnessError {
* @param code - stable machine-routable classification.
*/
// Keep the constructor to narrow HarnessError's string code at this boundary.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing
// oxlint-disable-next-line typescript/no-useless-constructor -- type-only narrowing
constructor(message: string, code: GoalErrorCode) {
super(message, code)
}

View File

@@ -125,7 +125,7 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
*/
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {

View File

@@ -78,14 +78,14 @@ export function boundedInsert(window: ListingCandidate[], candidate: ListingCand
// oversized level costs O(1) per candidate past the head instead of a
// window scan (100k children against a 1,001 window must not approach
// 10^8 comparisons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail
// oxlint-disable-next-line typescript/no-non-null-assertion -- a full window (length === keep >= 1) has a tail
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true
// Binary insertion keeps a retained candidate at O(log keep) comparisons.
let lo = 0
let hi = window.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid
else lo = mid + 1
}

View File

@@ -24,7 +24,7 @@ export function providerForClosedStep(
if (stepEndIndex < 0) return undefined
for (let index = stepEndIndex; index >= 0; index -= 1) {
// The loop bounds prove this indexed read exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (event.type === 'request/header') return event.data.header.config.provider
}

View File

@@ -531,7 +531,7 @@ export class LlmService extends Service {
yield value
}
} finally {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the iteration catch sets its latch before entering finally.
if (!completed && !iterationFailed) {
const close = iterator.return?.bind(iterator)
if (close) await close()

View File

@@ -72,10 +72,10 @@ describe('BlockAssembler', () => {
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
const assembler = new BlockAssembler()
// Force the invariant violation: manually corrupt the data structures.
/* eslint-disable */
/* oxlint-disable */
const hack = assembler as any
hack.order.push(99)
/* eslint-enable */
/* oxlint-enable */
expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
})

View File

@@ -756,7 +756,7 @@ describe('LlmService', () => {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
// Third-party adapters can reject with arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
return { next: () => Promise.reject('plain provider failure') }
},
}

View File

@@ -171,7 +171,7 @@ export class TokenMeterService extends Service {
}
while (state.consumedEvents < session.events.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- contiguous session seqs index the durable log
// oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log
const event = session.events[state.consumedEvents]!
this._foldEvent(session, state, event)
state.consumedEvents += 1
@@ -226,7 +226,7 @@ export class TokenMeterService extends Service {
}
// assistant/message is surface-mandatory at every append/seed boundary.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const eventTokens = surface!.tokens
if (event.data.usage !== undefined && nextHeader !== undefined) {
const providerAssistantTokens = this._estimateProviderAssistant(
@@ -334,7 +334,7 @@ export class TokenMeterService extends Service {
// Session construction validates contiguous seqs, and the explicit
// earlier-than-assistant check above therefore guarantees existence.
const source = session.events[seq]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const sourceEvent = source!
if (sourceEvent.type !== 'assistant/chunk') {
throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`)

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

@@ -39,7 +39,7 @@ export class SessionQueryError extends HarnessError {
declare readonly code: SessionQueryErrorCode
// The base stores the value; this signature narrows its open string code.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
// oxlint-disable-next-line typescript/no-useless-constructor
constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) {
super(message, code, options)
}

View File

@@ -91,7 +91,7 @@ export function traceEvent(
}
// The target check above proves the parallel record exists at this index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const targetRecord = analysis.records[seq]!
const replacedBy = analysis.replacedBy.get(seq)
return {
@@ -225,7 +225,7 @@ function buildDescendants(
const stack = [{ sessionId, descendants }]
while (stack.length > 0) {
// The length guard proves a frame exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const frame = stack.pop()!
const nodes: SessionLineageNode[] = []
for (const child of childrenByParent.get(frame.sessionId) ?? []) {
@@ -235,7 +235,7 @@ function buildDescendants(
}
for (let index = nodes.length - 1; index >= 0; index -= 1) {
// The loop bounds prove this indexed node exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const node = nodes[index]!
stack.push({ sessionId: node.session.header.id, descendants: node.descendants })
}

View File

@@ -134,7 +134,7 @@ function expectCode(code: SessionQueryErrorCode): Error {
function rejectUnknown<T>(reason: unknown): Promise<T> {
return new Promise<T>((_resolve, reject) => {
// Exercise containment for an implementation that violates the Error rejection convention.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
reject(reason)
})
}

View File

@@ -1444,7 +1444,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
},
])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => {
const mounted = await mount()
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile unknown rejection is the scenario
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- hostile unknown rejection is the scenario
FakeQuery.sessionSearch = () => Promise.reject(failure())
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)

View File

@@ -36,7 +36,7 @@ class CooperativeAdapter extends LlmAdapter {
if (signal === undefined) throw new Error('expected title request signal')
await new Promise<never>((_resolve, reject) => {
const rejectAbort = (): void => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
reject(signal.reason)
}
if (signal.aborted) {

View File

@@ -381,7 +381,7 @@ class SkillWatchManager {
const current = await resolveRootWatchMode(state.root.path)
// A child unlink can publish an empty catalog before root unlinkDir arrives.
// Discovery therefore revalidates the retained handle independently.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
// oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return
}
await this.replaceWatcher(state)
@@ -398,7 +398,7 @@ class SkillWatchManager {
/* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */
if (watcher === undefined) return
/* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
if (this.closing || state.owners.size === 0) {
await this.closeWatcher(watcher)
return
@@ -407,7 +407,7 @@ class SkillWatchManager {
state.watcher = watcher
state.unhealthy = false
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
if (!this.closing) {
state.unhealthy = true
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)

View File

@@ -267,7 +267,7 @@ export class SkillService extends Service {
invalidateCache()
}
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
@@ -307,7 +307,7 @@ export class SkillService extends Service {
invalidateCache()
}
}, 'skills.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}

View File

@@ -761,7 +761,7 @@ describe('SkillService registry', () => {
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') })
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment
// oxlint-disable-next-line typescript/no-misused-promises -- deliberate rejection proves notification containment
const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected')))
let observed = 0
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
@@ -907,7 +907,7 @@ describe('SkillService registry', () => {
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
return Promise.reject(hostileFailure)
},
async get() {

View File

@@ -268,7 +268,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
let published = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// The loop bounds prove the read-only event view contains this index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'

View File

@@ -56,7 +56,7 @@ class JsonKvUnit implements KvUnit {
private readonly onClose: () => void,
) {}
// eslint-disable-next-line @typescript-eslint/require-await -- async keeps the closed guard a rejection, not a synchronous throw
// oxlint-disable-next-line typescript/require-await -- async keeps the closed guard a rejection, not a synchronous throw
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
this.assertOpen()
const tables: Record<string, Record<string, unknown>> = {}

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

@@ -145,7 +145,7 @@ export async function startInProcessRun(
// Close the narrow handoff race before installing the live-run listener.
// Static analysis does not model the abort that may land between the
// factory's listener detachment and this continuation.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (request.signal.aborted) {
flags.cancelled = true
await handle.dispose()

View File

@@ -194,7 +194,7 @@ export class SubagentService extends Service {
*/
registerProvider(provider: SubagentProvider): () => void {
const name = provider.name
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(name)) {
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')

View File

@@ -226,7 +226,7 @@ describe('SubagentService', () => {
const heard: string[] = []
ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') })
// Runtime listeners may return thenables even though the declaration's observable result is void.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
// oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment
ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') })
ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } })
ctx.on('subagent/provider-removed', name => void heard.push(name))

View File

@@ -192,7 +192,7 @@ export class InvariantService extends Service {
}
// Cordis attaches setup thenability and async teardown to this callable;
// the service seam intentionally exposes only the conventional disposer.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- the extra runtime shape stays private.
// oxlint-disable-next-line typescript/no-misused-promises -- the extra runtime shape stays private.
return registration
}
}

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

Some files were not shown because too many files have changed in this diff Show More