Merge origin/master into feature/shared-cli-config-foundation
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
|
||||
README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc
|
||||
README.md: fd5e1e8ec1a0ca426ed717cfa9613c51728c60e1
|
||||
README.zh.md: ad4f315171377677a934d8bb02d15c2db96e0e91
|
||||
|
||||
@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
|
||||
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()')
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>([
|
||||
|
||||
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -110,8 +110,8 @@ export class SessionReferenceService extends Service {
|
||||
*/
|
||||
async listCandidates(
|
||||
agent: Agent,
|
||||
query = '',
|
||||
limit = this.config.candidateLimit,
|
||||
query: string = '',
|
||||
limit: number = this.config.candidateLimit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionReferenceCandidate[]> {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
|
||||
README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97
|
||||
README.zh.md: 237b72244be2336a82c8c48cb341d7f9796d08f4
|
||||
README.md: eda135d93e2912bbb4e111af40d176409b383b5b
|
||||
README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc
|
||||
|
||||
@@ -28,7 +28,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
|
||||
|
||||
## The generated API catalog
|
||||
|
||||
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
`src/api-catalog.ts` is generated from the same Typert `FaceModel` projection as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `scripts/gen-cordis-api.ts` is a compatibility entry point for that unified projection, not a second collector. `cordis_inspect` intersects the committed catalog with the live service store at call time; it has no runtime Typert dependency. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 生成的 API 目录
|
||||
|
||||
`src/api-catalog.ts` 由 `scripts/gen-cordis-api.ts` 生成,使用与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 相同的 AST 遍历,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`cordis_inspect` 在调用时把该目录与存活服务 store 取交集。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会明确报错。
|
||||
`src/api-catalog.ts` 与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 由同一个 Typert `FaceModel` 投影生成,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`scripts/gen-cordis-api.ts` 是该统一投影的兼容入口,而非第二套收集器。`cordis_inspect` 在调用时把已提交的目录与存活服务 store 取交集;它在运行时不依赖 Typert。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise<boolean>',
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason: string = \'model request\'): Promise<boolean>',
|
||||
jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */',
|
||||
},
|
||||
{
|
||||
@@ -679,7 +679,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'Exact-read consumer that prepares immutable cross-session message context.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
},
|
||||
{
|
||||
@@ -1002,6 +1002,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'typert',
|
||||
summary: 'Registry of generated schemas and package reflection.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(contribution: TypertContribution): () => void',
|
||||
jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(key: string): TypertSchemaRecord | undefined',
|
||||
jsDoc: '/**\n * Look up one schema by `<package>#<name>`.\n * @param key - global schema key.\n * @returns the live schema record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'resolve(key: string): TypertSchemaRecord',
|
||||
jsDoc: '/**\n * Resolve one required schema.\n * @param key - global schema key.\n * @returns the live schema record.\n * @throws when the key is malformed, the package face is absent, or the schema is not contributed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]',
|
||||
jsDoc: '/**\n * Enumerate live schemas in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching schema records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'getPackage(packageName: string, face: TypertFace = \'host\'): TypertPackageRecord | undefined',
|
||||
jsDoc: '/**\n * Look up generated reflection for one package face.\n * @param packageName - exact npm package name.\n * @param face - face to query; defaults to the host runtime.\n * @returns the live package record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]',
|
||||
jsDoc: '/**\n * Enumerate generated package reflection in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching package records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema',
|
||||
jsDoc: '/**\n * Project a live Zod schema to JSON Schema without caching the result.\n * @param key - global schema key.\n * @param params - Zod projection parameters.\n * @returns a fresh JSON Schema document.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
|
||||
@@ -1281,34 +1315,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */',
|
||||
summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-begin-command',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-begin-command\'(request: BeginCommandRequest): true | undefined',
|
||||
jsDoc: '/**\n * Applies one command claim to the scoped Input. Dispatched with the\n * session\'s scope carrier; the owning session\'s input listener returns\n * `true` only after the phase and span CAS checks pass and the machine\n * actually mutated — producers treat anything else as "not applied".\n * @param request - Claim and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Applies one command claim to the scoped Input.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-consume-token',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-consume-token\'(request: ConsumeTokenRequest): true | undefined',
|
||||
jsDoc: '/**\n * Consumes one command token after business success (popup settle /\n * menu-pick execute). Same carrier routing and applied-truth contract.\n * @param request - Exact span or bare-token guard.\n * @mode bail\n */',
|
||||
summary: 'Consumes one command token after business success (popup settle / menu-pick execute).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-reference',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-reference\'(request: InsertReferenceRequest): true | undefined',
|
||||
jsDoc: '/**\n * Inserts one reference into the scoped Input (same carrier routing and\n * applied-truth contract as begin-command).\n * @param request - Reference and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-text',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-text\'(request: InsertTextRequest): true | undefined',
|
||||
jsDoc: '/**\n * Replaces the trigger token span with literal text — the plain-text\n * reference path (decision 21). Same carrier routing and applied-truth\n * contract; the draft gains ordinary characters, no occurrence entry.\n * @param request - Replacement text and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Replaces the trigger token span with literal text — the plain-text reference path (decision 21).',
|
||||
},
|
||||
{
|
||||
name: 'subagent/end',
|
||||
mode: 'emit',
|
||||
@@ -2698,6 +2704,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TurnTriggerMap',
|
||||
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocTag',
|
||||
declaration: 'export interface TypertDocTag {\n readonly name: string;\n readonly argument?: string;\n readonly comment?: string;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocumentation',
|
||||
declaration: 'export interface TypertDocumentation {\n readonly description?: string;\n readonly summary?: string;\n readonly tags: readonly TypertDocTag[];\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertEventModel',
|
||||
declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertMemberModel',
|
||||
declaration: 'export interface TypertMemberModel {\n readonly kind: \'property\' | \'method\' | \'getter\' | \'setter\' | \'call\' | \'construct\' | \'index\';\n readonly name: string;\n readonly signature: string;\n readonly summary?: string;\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertObjectModel',
|
||||
declaration: 'export interface TypertObjectModel extends TypertDocumentation {\n readonly name: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageFilter',
|
||||
declaration: 'export interface TypertPackageFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageModel',
|
||||
declaration: 'export interface TypertPackageModel {\n readonly services: readonly TypertServiceModel[];\n readonly events: readonly TypertEventModel[];\n readonly objects: readonly TypertObjectModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageRecord',
|
||||
declaration: 'export interface TypertPackageRecord {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchema',
|
||||
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaRecord',
|
||||
declaration: 'export interface TypertSchemaRecord extends TypertSchema {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertServiceModel',
|
||||
declaration: 'export interface TypertServiceModel extends TypertDocumentation {\n readonly key: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertTypeModel',
|
||||
declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'UserInteractionProvider',
|
||||
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -269,7 +269,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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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') }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>> = {}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/typert/README.i18n.yaml
Normal file
6
packages/typert/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/README.md
|
||||
README.md: d11fd8f57245379d67d2a1cdcca334f0032db469
|
||||
README.zh.md: 97e57f9585efa2e86edc1edf576fef4738b63203
|
||||
11
packages/typert/README.md
Normal file
11
packages/typert/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Typert
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Typert separates source analysis, runtime storage, and Loader discovery into independent packages.
|
||||
|
||||
| Package | Role | Cordis key |
|
||||
|---|---|---|
|
||||
| [`registry/`](registry/README.md) | Runtime package reflection and live Zod schema registry | `ctx.typert` |
|
||||
| [`loader/`](loader/README.md) | Loader-entry discovery and generated host-artifact registration | consumes `ctx.loader`, `ctx.typert` |
|
||||
| [`generator/`](generator/README.md) | Compiler-independent type analysis and artifact generation | build-time library |
|
||||
11
packages/typert/README.zh.md
Normal file
11
packages/typert/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Typert
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Typert 将源代码分析、运行时存储和 Loader 发现机制拆分为彼此独立的包(package)。
|
||||
|
||||
| 包 | 职责 | Cordis 键 |
|
||||
|---|---|---|
|
||||
| [`registry/`](registry/README.md) | 运行时包反射和实时 Zod schema 注册表 | `ctx.typert` |
|
||||
| [`loader/`](loader/README.md) | 发现 Loader 条目并注册所生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` |
|
||||
| [`generator/`](generator/README.md) | 与编译器无关的类型分析和产物生成 | 构建时库 |
|
||||
6
packages/typert/generator/README.i18n.yaml
Normal file
6
packages/typert/generator/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/generator/README.md
|
||||
README.md: c343fd9475a9407159037f0a10e3a0586a77c3da
|
||||
README.zh.md: e00abe205e5c5c33e7e0028606df169d447e4006
|
||||
41
packages/typert/generator/README.md
Normal file
41
packages/typert/generator/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @deepseek-ai/dsh-typert-generator
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects.
|
||||
|
||||
Host and client use independent `ts.Program` instances seeded from `tsconfig.host.json` and `tsconfig.client.json`. Direct project references establish face membership, `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded.
|
||||
|
||||
## Analysis Model
|
||||
|
||||
Each face contains package exports, Cordis services and events, explicitly tagged objects and schemas, and a type graph for their reachable declarations. The graph preserves declaration identity, generic parameters and applications, explicit inheritance, conditional and mapped types, import attributes, abstract modifiers, and source JSDoc. Service and `@typert object` surfaces expose public instance members only; constructors, static members, and non-public members are excluded.
|
||||
|
||||
`WorkspaceAnalyzer` defaults to `check` mode and fails on TypeScript syntax or semantic diagnostics, missing reachable public annotations, private cross-package references, and reachable declaration merges that the model cannot retain losslessly. `write` mode inserts checker-derived annotations, rebuilds the program, and returns a clean check-mode model.
|
||||
|
||||
## Emission and Opt-in Publication
|
||||
|
||||
`FaceModelEmitter` consumes only the model. It emits executable JavaScript containing supported Zod schemas and a `TYPERT` contribution, plus a declaration file whose schemas are typed as `z.ZodType<SourceType>` through the package's public export. Unsupported Zod projections fail instead of flattening or weakening the source type.
|
||||
|
||||
`WorkspaceTypertGenerator` discovers contributors by walking package public exports reachable from Cordis `Context` or `Events` augmentations and explicit `@typert` declarations. When invoked for artifact publication, it requires host artifacts at `lib/typert.host.{js,d.ts}` exposed as `package/typert`, and client artifacts at `lib/typert.client.{js,d.ts}` exposed as `package/client/typert`. Generated declarations expose `TYPERT` as `unknown`, so contributing business packages do not depend on the runtime registry.
|
||||
|
||||
Publication is package opt-in. The root build and typecheck do not generate Typert artifacts or require every business package to add Typert exports. Static consumers can call `WorkspaceAnalyzer` directly, select host/client and package subsets, and use bounded package batches without publishing or loading runtime artifacts.
|
||||
|
||||
## Repository-specific Cordis projection
|
||||
|
||||
The root package export includes the model-driven extraction, completeness checks, and deterministic text renderers used by this repository's Cordis catalogs. They accept a `CordisCatalogPolicy`; repository-owned type links, foundation/exemption classifications, and inherited Cordis entries remain in `scripts/gen-cordis-catalog.ts` and are passed in explicitly. The generator package therefore contains projection mechanics, not a hidden copy of this repository's documentation taxonomy.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package runs at build or test time and never contributes to a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Package export patterns are skipped; contributing packages need concrete export targets.
|
||||
- Cross-face named and star re-exports produce links; namespace re-exports fail until `TypeTargetModel` can represent a module namespace without flattening it.
|
||||
- The Zod emitter supports a deliberate subset of the modeled TypeScript graph. Generic schema declarations and computed constructs such as conditional or mapped schema roots fail until a concrete schema-factory policy exists.
|
||||
- Cross-face links are represented for analysis, but no generated schema currently requires a runtime cross-face Zod import.
|
||||
- Discovery follows source files reachable from concrete public exports; declarations that are neither exported nor imported by that graph are intentionally outside the package model.
|
||||
41
packages/typert/generator/README.zh.md
Normal file
41
packages/typert/generator/README.zh.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @deepseek-ai/dsh-typert-generator
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。
|
||||
|
||||
宿主侧与客户端侧分别使用独立的 `ts.Program` 实例,二者以 `tsconfig.host.json` 和 `tsconfig.client.json` 初始化。直接项目引用确定各包(package)所属的 face,`package.json#exports` 确定所有跨包公开边界,跨 face 的边则只能来自源码中的导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。
|
||||
|
||||
## 分析模型
|
||||
|
||||
每个 face 包含包导出、Cordis 服务与事件、显式标记的对象与 schema,以及涵盖其可达声明的类型图。类型图保留声明标识、泛型参数及应用、显式继承、条件类型与映射类型、导入属性、abstract 修饰符和源码 JSDoc。服务和 `@typert object` 对外接口仅暴露公共实例成员;构造函数、静态成员与非公共成员均被排除。
|
||||
|
||||
`WorkspaceAnalyzer` 默认采用 `check` 模式,遇到 TypeScript 语法或语义诊断、可达公开声明缺少类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时,分析会失败。`write` 模式会插入类型检查器推导出的类型标注,重建该程序,并返回无诊断的检查模式模型。
|
||||
|
||||
## 产物生成与选择性发布
|
||||
|
||||
`FaceModelEmitter` 只消费模型。它会生成可执行 JavaScript,其中包含受支持的 Zod schema 和一个 `TYPERT` contribution;同时生成声明文件,通过包的公开导出将其中的 schema 标注为 `z.ZodType<SourceType>`。遇到不支持的 Zod 投影时,生成会失败,不会展平或弱化源类型。
|
||||
|
||||
`WorkspaceTypertGenerator` 会遍历从 Cordis `Context` 或 `Events` 扩充声明及显式 `@typert` 声明可达的包公开导出,以发现贡献方。发布产物时,它要求宿主侧产物位于 `lib/typert.host.{js,d.ts}` 并以 `package/typert` 暴露,客户端侧产物位于 `lib/typert.client.{js,d.ts}` 并以 `package/client/typert` 暴露。生成的声明将 `TYPERT` 暴露为 `unknown`,因此参与贡献的业务包无需依赖运行时注册表。
|
||||
|
||||
各包可自行选择是否发布。根目录的构建和类型检查不会生成 Typert 产物,也不要求每个业务包添加 Typert 导出。静态消费方可以直接调用 `WorkspaceAnalyzer`,选择宿主侧/客户端侧及包子集,并在不发布或加载运行时产物的情况下分批处理包,同时限制每批数量。
|
||||
|
||||
## 本仓库的 Cordis 投影
|
||||
|
||||
包根导出中包含本仓库 Cordis 目录使用的模型驱动提取逻辑、完整性检查和确定性文本渲染器。它们接受 `CordisCatalogPolicy`;由仓库持有的类型链接、基础类型/豁免类型分类和继承的 Cordis 条目仍位于 `scripts/gen-cordis-catalog.ts`,并由调用方显式传入。因此,生成器包只包含投影机制,不会隐式复制本仓库的文档分类体系。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包仅在构建或测试时运行,不会向模型请求添加任何内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- 系统会跳过包导出中的模式匹配;参与贡献的包需要具体的导出目标。
|
||||
- 跨 face 的具名重新导出和星号重新导出会生成链接;在 `TypeTargetModel` 能够不经展平便表示模块命名空间之前,命名空间重新导出会失败。
|
||||
- Zod 产物生成组件仅支持 TypeScript 类型图中有意限定的部分。泛型 schema 声明,以及以条件类型或映射类型为 schema 根的计算构造,都会失败,直到存在明确的 schema 工厂策略。
|
||||
- 跨 face 链接会在模型中表示以供分析,但当前生成的 schema 均不需要跨 face 的运行时 Zod 导入。
|
||||
- 发现过程会遍历从具体公开导出可达的源文件;既未导出、也未由该图导入的声明会按设计排除在包模型之外。
|
||||
48
packages/typert/generator/package.json
Normal file
48
packages/typert/generator/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-typert-generator",
|
||||
"description": "TypeScript project analyzer and model-driven Typert artifact generator",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./tsdown": {
|
||||
"types": "./lib/types/tsdown-plugin.d.ts",
|
||||
"default": "./lib/types/tsdown-plugin.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
1894
packages/typert/generator/src/analyzer.ts
Normal file
1894
packages/typert/generator/src/analyzer.ts
Normal file
File diff suppressed because it is too large
Load Diff
814
packages/typert/generator/src/cordis-catalog.ts
Normal file
814
packages/typert/generator/src/cordis-catalog.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* Cordis catalog-specific projection over the compiler-independent Typert
|
||||
* model. This module owns Cordis validation and text projection mechanics;
|
||||
* callers supply repository-specific type classifications and inherited data.
|
||||
* @module @deepseek-ai/dsh-typert-generator
|
||||
*/
|
||||
|
||||
import { WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import { childTypeNodeIds } from './model.ts'
|
||||
import { TypeGraphRenderer } from './renderer.ts'
|
||||
import type {
|
||||
FaceModel,
|
||||
MemberModel,
|
||||
ParameterModel,
|
||||
SignatureModel,
|
||||
SourceDeclarationModel,
|
||||
SourceLocation,
|
||||
TypeNodeId,
|
||||
} from './model.ts'
|
||||
|
||||
type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
|
||||
/** The fenced-block info string for generated signature blocks (skipped by
|
||||
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
|
||||
const FENCE = 'ts cordis-catalog'
|
||||
|
||||
/** Append fail-closed signature type-link violations from the retained type tree. */
|
||||
function checkTypeLinks(
|
||||
where: string,
|
||||
names: readonly string[],
|
||||
policy: CordisCatalogPolicy,
|
||||
violations: string[],
|
||||
): void {
|
||||
for (const name of names) {
|
||||
if (Object.hasOwn(policy.linkedTypePages, name)
|
||||
|| policy.foundationTypeNames.has(name)
|
||||
|| Object.hasOwn(policy.typeLinkExemptions, name)) continue
|
||||
violations.push(
|
||||
`${where} references unclassified type '${name}'. Add it to linkedTypePages with its documentation page, `
|
||||
+ 'to foundationTypeNames if TypeScript or the framework owns it, or to typeLinkExemptions with '
|
||||
+ 'the non-catalog documentation owner.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw one aggregated diagnostic for every unclassified signature type. */
|
||||
function reportTypeLinkViolations(gate: string, violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`${gate}: ${violations.length} signature type-link coverage violation(s):\n`
|
||||
+ violations.map(violation => ` ${violation}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/** One harness event, extracted from an `interface Events` block. */
|
||||
export interface EventEntry {
|
||||
/** Scoped name, e.g. `agent/request`. */
|
||||
name: string
|
||||
/** The scope prefix, e.g. `agent` (everything before the first `/`). */
|
||||
scope: string
|
||||
/** Full signature text (the method-signature member, JSDoc stripped). */
|
||||
signature: string
|
||||
/** Original declaration JSDoc, dedented from its containing interface. */
|
||||
jsDoc: string
|
||||
/** Dispatch mode from the `@mode` tag. */
|
||||
mode: Mode
|
||||
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
|
||||
doc: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One public service method and the source contract attached to it. */
|
||||
export interface ServiceMethodEntry {
|
||||
/** Public method signature (body stripped). */
|
||||
signature: string
|
||||
/** Original method JSDoc, dedented from its containing class. */
|
||||
jsDoc: string
|
||||
}
|
||||
|
||||
/** One harness service, extracted from an `interface Context` block. */
|
||||
export interface ServiceEntry {
|
||||
/** The `ctx.<key>` name, e.g. `llm`. */
|
||||
key: string
|
||||
/** The service class/interface name, e.g. `LlmService`. */
|
||||
type: string
|
||||
/** Whether the service class is abstract (a seam interface). */
|
||||
abstract: boolean
|
||||
/** Class-level JSDoc prose, one line per paragraph. */
|
||||
doc: string
|
||||
/** Public methods (bodies stripped), in source order. */
|
||||
methods: ServiceMethodEntry[]
|
||||
/** Source pointer of the class declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** A terse inherited-tier entry supplied by the catalog policy. */
|
||||
export interface InheritedEntry {
|
||||
/** Display name of the inherited event or context member group. */
|
||||
name: string
|
||||
/** One-line description rendered into the catalog. */
|
||||
summary: string
|
||||
/** Source pointer such as `vendor/…:line`. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Repository policy consumed by the Cordis catalog parsing and rendering logic. */
|
||||
export interface CordisCatalogPolicy {
|
||||
/** Type names linked from signatures to their documentation pages. */
|
||||
readonly linkedTypePages: Readonly<Record<string, string>>
|
||||
/** TypeScript or framework types that need no repository documentation link. */
|
||||
readonly foundationTypeNames: ReadonlySet<string>
|
||||
/** Repository types deliberately documented outside the linked data catalog. */
|
||||
readonly typeLinkExemptions: Readonly<Record<string, string>>
|
||||
/** Manually curated framework events inherited by every plugin. */
|
||||
readonly inheritedEvents: readonly InheritedEntry[]
|
||||
/** Manually curated framework context members inherited by every plugin. */
|
||||
readonly inheritedServices: readonly InheritedEntry[]
|
||||
}
|
||||
|
||||
/** Complete model-level Cordis projection used by every text renderer. */
|
||||
export interface CordisCatalogModel {
|
||||
readonly events: readonly EventEntry[]
|
||||
readonly services: readonly ServiceEntry[]
|
||||
}
|
||||
|
||||
/** Repository-specific Cordis validation and projection over one Typert face. */
|
||||
export class CordisCatalogProjector {
|
||||
private readonly renderer: TypeGraphRenderer
|
||||
|
||||
/**
|
||||
* @param face - analyzed host face containing package business semantics.
|
||||
* @param sourceDeclarations - exported declarations available to the runtime type closure.
|
||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||
*/
|
||||
constructor(
|
||||
private readonly face: FaceModel,
|
||||
private readonly sourceDeclarations: readonly SourceDeclarationModel[],
|
||||
private readonly policy: CordisCatalogPolicy,
|
||||
) {
|
||||
if (face.face !== 'host') throw new Error(`cordis catalog requires the host face, received ${face.face}`)
|
||||
this.renderer = new TypeGraphRenderer(face.graph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and project the host model's Cordis surface.
|
||||
* @returns every validated service and event projected from the host model.
|
||||
*/
|
||||
project(): CordisCatalogModel {
|
||||
return {
|
||||
events: this.collectEvents(),
|
||||
services: this.collectServices(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model-facing static API consumed by `tool-cordis`.
|
||||
* @param model - validated Cordis catalog projection from this projector.
|
||||
* @returns the model-facing TypeScript catalog source.
|
||||
*/
|
||||
renderRuntimeApi(model: CordisCatalogModel): string {
|
||||
return renderRuntimeApi(
|
||||
model.services,
|
||||
model.events,
|
||||
this.runtimeTypes(model.services),
|
||||
this.policy.inheritedServices,
|
||||
)
|
||||
}
|
||||
|
||||
private collectEvents(): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const packageModel of this.face.packages) {
|
||||
for (const event of packageModel.events) {
|
||||
const source = pointer(event.location)
|
||||
const where = `event '${event.name}' (${source})`
|
||||
const node = this.renderer.node(event.signature)
|
||||
if (node.kind !== 'function') {
|
||||
violations.push(`${where} is not represented by a callable type.`)
|
||||
continue
|
||||
}
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
|
||||
const parsed = parseJsDoc(event.jsDoc ?? '')
|
||||
const mode = event.mode
|
||||
if (!isMode(mode)) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
const last = node.signature.parameters.at(-1)
|
||||
const hasNext = last?.name === 'next'
|
||||
if (isMode(mode) && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (isMode(mode) && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (parsed.doc === '') {
|
||||
violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
}
|
||||
checkParams(
|
||||
where,
|
||||
'event',
|
||||
node.signature.parameters,
|
||||
parsed.params,
|
||||
parameter => parameter.receiver || (hasNext && parameter === last),
|
||||
violations,
|
||||
)
|
||||
if (isMode(mode)) {
|
||||
entries.push({
|
||||
name: event.name,
|
||||
scope: event.name.split('/')[0] ?? event.name,
|
||||
signature: event.text,
|
||||
jsDoc: event.jsDoc ?? '',
|
||||
mode,
|
||||
doc: parsed.doc,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||
return entries
|
||||
}
|
||||
|
||||
private collectServices(): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const packageModel of this.face.packages) {
|
||||
for (const service of packageModel.services) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
if (declaration.kind !== 'class'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file)
|
||||
|| declaration.location.file !== service.location.file) continue
|
||||
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
|
||||
const source = pointer(declaration.location)
|
||||
if (doc === '') {
|
||||
violations.push(`service ctx.${service.key} (${source}): class ${declaration.name} has no JSDoc.`)
|
||||
}
|
||||
const methods: ServiceMethodEntry[] = []
|
||||
for (const memberId of service.members) {
|
||||
const member = this.renderer.member(memberId)
|
||||
if (member.kind !== 'method' || member.name.startsWith('[')) continue
|
||||
const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})`
|
||||
checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations)
|
||||
methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' })
|
||||
if (member.jsDoc === undefined) {
|
||||
violations.push(`${where} has no JSDoc.`)
|
||||
continue
|
||||
}
|
||||
const parsed = parseJsDoc(member.jsDoc)
|
||||
if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`)
|
||||
checkParams(where, 'service', member.signature.parameters, parsed.params,
|
||||
parameter => parameter.receiver, violations)
|
||||
checkReturns(where, member.signature, parsed.returns, this.renderer, violations)
|
||||
}
|
||||
entries.push({
|
||||
key: service.key,
|
||||
type: declaration.name,
|
||||
abstract: declaration.abstract,
|
||||
doc,
|
||||
methods,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||
return entries.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
|
||||
private runtimeTypes(services: readonly ServiceEntry[]): { name: string; declaration: string }[] {
|
||||
const declarations = new Map<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
for (const declaration of this.sourceDeclarations) {
|
||||
if (declaration.face !== 'host' || declaration.kind === 'enum'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue
|
||||
if (declarations.has(declaration.name)) {
|
||||
ambiguous.add(declaration.name)
|
||||
continue
|
||||
}
|
||||
declarations.set(
|
||||
declaration.name,
|
||||
declaration.text.length > MAX_DECL_CHARS
|
||||
? `${declaration.text.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
|
||||
: declaration.text,
|
||||
)
|
||||
}
|
||||
for (const name of ambiguous) declarations.delete(name)
|
||||
return referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), declarations)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the host project once and return both the model and its projection.
|
||||
* @param scanRoot - workspace root containing `tsconfig.host.json`.
|
||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||
* @returns the configured projector and its validated catalog model.
|
||||
*/
|
||||
export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy): {
|
||||
readonly projector: CordisCatalogProjector
|
||||
readonly model: CordisCatalogModel
|
||||
} {
|
||||
const discovery = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
checkDiagnostics: false,
|
||||
}).discoverPackages()
|
||||
const packages = discovery.filter(candidate => candidate.faces.includes('host'))
|
||||
.map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
packages,
|
||||
checkDiagnostics: false,
|
||||
}).analyzeInBatches()
|
||||
const face = workspace.faces.find(candidate => candidate.face === 'host')
|
||||
if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
|
||||
const sourceDeclarations = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
checkDiagnostics: false,
|
||||
}).indexSourceDeclarations()
|
||||
const projector = new CordisCatalogProjector(face, sourceDeclarations, policy)
|
||||
return { projector, model: projector.project() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all modeled events for relationship-document consumers.
|
||||
* @param scanRoot - workspace root containing `tsconfig.host.json`.
|
||||
* @param policy - caller-owned Cordis catalog policy.
|
||||
* @returns all validated event entries.
|
||||
*/
|
||||
export function collectEvents(scanRoot: string, policy: CordisCatalogPolicy): EventEntry[] {
|
||||
return [...projectCordisCatalog(scanRoot, policy).model.events]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all modeled services for relationship-document consumers.
|
||||
* @param scanRoot - workspace root containing `tsconfig.host.json`.
|
||||
* @param policy - caller-owned Cordis catalog policy.
|
||||
* @returns all validated service entries.
|
||||
*/
|
||||
export function collectServices(scanRoot: string, policy: CordisCatalogPolicy): ServiceEntry[] {
|
||||
return [...projectCordisCatalog(scanRoot, policy).model.services]
|
||||
}
|
||||
|
||||
interface ParsedJsDoc {
|
||||
readonly doc: string
|
||||
readonly params: ReadonlyMap<string, string>
|
||||
readonly returns: string | null
|
||||
}
|
||||
|
||||
function parseJsDoc(raw: string): ParsedJsDoc {
|
||||
const lines = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
const blocks: string[] = []
|
||||
let paragraph: string[] = []
|
||||
let list: string[] = []
|
||||
let item: string[] = []
|
||||
let inTags = false
|
||||
const join = (parts: readonly string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const flushItem = (): void => {
|
||||
if (item.length > 0) list.push(join(item))
|
||||
item = []
|
||||
}
|
||||
const flushList = (): void => {
|
||||
flushItem()
|
||||
if (list.length > 0) blocks.push(list.join('\n'))
|
||||
list = []
|
||||
}
|
||||
const flushParagraph = (): void => {
|
||||
flushList()
|
||||
if (paragraph.length > 0) blocks.push(join(paragraph))
|
||||
paragraph = []
|
||||
}
|
||||
for (const line of lines) {
|
||||
const tagLine = line.trimStart()
|
||||
if (tagLine.startsWith('@')) {
|
||||
flushParagraph()
|
||||
inTags = true
|
||||
continue
|
||||
}
|
||||
if (inTags) continue
|
||||
if (line.trim() === '') {
|
||||
flushParagraph()
|
||||
continue
|
||||
}
|
||||
if (/^-\s+/.test(line)) {
|
||||
flushItem()
|
||||
if (paragraph.length > 0) {
|
||||
blocks.push(join(paragraph))
|
||||
paragraph = []
|
||||
}
|
||||
item.push(line)
|
||||
continue
|
||||
}
|
||||
if (item.length > 0) item.push(line)
|
||||
else paragraph.push(line)
|
||||
}
|
||||
flushParagraph()
|
||||
|
||||
const params = new Map<string, string>()
|
||||
let returns: string | null = null
|
||||
let sink: ((text: string) => void) | undefined
|
||||
for (const line of lines) {
|
||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||
if (param !== null) {
|
||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
||||
let value = param[2] ?? ''
|
||||
params.set(name, value)
|
||||
sink = (text) => {
|
||||
value = value === '' ? text : `${value} ${text}`
|
||||
params.set(name, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const returnsTag = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
|
||||
if (returnsTag !== null) {
|
||||
let value = returnsTag[1] ?? ''
|
||||
returns = value
|
||||
sink = (text) => {
|
||||
value = value === '' ? text : `${value} ${text}`
|
||||
returns = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('@') || line.trim() === '') sink = undefined
|
||||
else sink?.(line.trim())
|
||||
}
|
||||
return {
|
||||
doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(),
|
||||
params,
|
||||
returns,
|
||||
}
|
||||
}
|
||||
|
||||
function checkParams(
|
||||
where: string,
|
||||
surface: string,
|
||||
parameters: readonly ParameterModel[],
|
||||
tags: ReadonlyMap<string, string>,
|
||||
isExempt: (parameter: ParameterModel) => boolean,
|
||||
violations: string[],
|
||||
): void {
|
||||
for (const parameter of parameters) {
|
||||
if (parameter.binding !== 'identifier') {
|
||||
violations.push(`${where}: parameter '${parameter.name}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
if (isExempt(parameter)) continue
|
||||
const description = tags.get(parameter.name)
|
||||
if (description === undefined) violations.push(`${where} is missing @param ${parameter.name}.`)
|
||||
else if (description.trim() === '') violations.push(`${where}: @param ${parameter.name} has an empty description.`)
|
||||
}
|
||||
for (const tag of tags.keys()) {
|
||||
if (!parameters.some(parameter => parameter.binding === 'identifier' && parameter.name === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkReturns(
|
||||
where: string,
|
||||
signature: SignatureModel,
|
||||
returns: string | null,
|
||||
renderer: TypeGraphRenderer,
|
||||
violations: string[],
|
||||
): void {
|
||||
const type = renderer.renderType(signature.returns)
|
||||
if (type === 'void' || type === 'Promise<void>') return
|
||||
if (returns === null) violations.push(`${where} is missing @returns (return type: ${type}).`)
|
||||
else if (returns.trim() === '') violations.push(`${where}: @returns has an empty description.`)
|
||||
}
|
||||
|
||||
function reportViolations(gate: string, violations: readonly string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`${gate}: ${String(violations.length)} JSDoc completeness violation(s) (see AGENTS.md):\n`
|
||||
+ violations.map(violation => ` ${violation}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
function pointer(location: SourceLocation): string {
|
||||
return `${location.file}:${String(location.line)}`
|
||||
}
|
||||
|
||||
function isMode(mode: string | undefined): mode is Mode {
|
||||
return mode === 'emit' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial'
|
||||
}
|
||||
|
||||
function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] {
|
||||
const names = new Set<string>()
|
||||
const visited = new Set<TypeNodeId>()
|
||||
const visitSignature = (current: SignatureModel): void => {
|
||||
for (const parameter of current.typeParameters) {
|
||||
if (parameter.constraint !== undefined) visit(parameter.constraint)
|
||||
if (parameter.default !== undefined) visit(parameter.default)
|
||||
}
|
||||
for (const parameter of current.parameters) visit(parameter.type)
|
||||
visit(current.returns)
|
||||
}
|
||||
const visitMember = (member: MemberModel): void => {
|
||||
if (member.kind === 'property') visit(member.type)
|
||||
else visitSignature(member.signature)
|
||||
}
|
||||
const visit = (id: TypeNodeId): void => {
|
||||
if (visited.has(id)) return
|
||||
visited.add(id)
|
||||
const node = renderer.node(id)
|
||||
if (node.kind === 'reference' && node.target.kind !== 'type-parameter') names.add(node.name)
|
||||
if (node.kind === 'type-query') names.add(node.expression)
|
||||
for (const child of childTypeNodeIds(node)) visit(child)
|
||||
if (node.kind === 'object') for (const member of node.members) visitMember(member)
|
||||
if (node.kind === 'function' || node.kind === 'constructor') visitSignature(node.signature)
|
||||
}
|
||||
visitSignature(signature)
|
||||
return [...names].sort()
|
||||
}
|
||||
|
||||
/** Declarations longer than this render as a truncated stub. */
|
||||
const MAX_DECL_CHARS = 1500
|
||||
|
||||
/** Render one value as a single-quoted TypeScript literal. */
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`
|
||||
}
|
||||
|
||||
/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */
|
||||
function referencedTypes(
|
||||
seeds: readonly string[],
|
||||
declarations: ReadonlyMap<string, string>,
|
||||
): { name: string; declaration: string }[] {
|
||||
const included = new Map<string, string>()
|
||||
let frontier = [...seeds]
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const [name, declaration] of declarations) {
|
||||
if (included.has(name)) continue
|
||||
const pattern = new RegExp(`\\b${name}\\b`)
|
||||
if (frontier.some(text => pattern.test(text))) {
|
||||
included.set(name, declaration)
|
||||
next.push(declaration)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included]
|
||||
.map(([name, declaration]) => ({ name, declaration }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
function firstSentence(doc: string): string {
|
||||
const line = doc.split('\n', 1)[0] ?? ''
|
||||
const match = /^(.*?[.!?])(?:\s|$)/.exec(line)
|
||||
return (match?.[1] ?? line).trim()
|
||||
}
|
||||
|
||||
/** Render the byte-compatible model-facing API catalog. */
|
||||
function renderRuntimeApi(
|
||||
services: readonly ServiceEntry[],
|
||||
events: readonly EventEntry[],
|
||||
types: readonly { name: string; declaration: string }[],
|
||||
inheritedServices: readonly InheritedEntry[],
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'/**',
|
||||
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
|
||||
' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by',
|
||||
' * `pnpm run verify-cordis-api` in doc-sync).',
|
||||
' *',
|
||||
' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
|
||||
' * model: harness services (summary + public method signatures/JSDoc),',
|
||||
' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by',
|
||||
' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
|
||||
' * docs cannot diverge.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
|
||||
' */',
|
||||
'',
|
||||
'/** One public service method and its source-owned contract. */',
|
||||
'export interface ServiceApiMethod {',
|
||||
' /** Public method signature with its body stripped. */',
|
||||
' signature: string',
|
||||
' /** Original method JSDoc, with only container indentation removed. */',
|
||||
' jsDoc: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One harness `ctx.<key>` service: its one-line summary and public methods. */',
|
||||
'export interface ServiceApiEntry {',
|
||||
' /** The `ctx.<key>` name, e.g. `tools`. */',
|
||||
' key: string',
|
||||
' /** First sentence of the service class JSDoc. */',
|
||||
' summary: string',
|
||||
' /** Public methods, bodies stripped, in source order. */',
|
||||
' methods: readonly ServiceApiMethod[]',
|
||||
'}',
|
||||
'',
|
||||
'/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
|
||||
'export interface EventApiEntry {',
|
||||
' /** The scoped event name, e.g. `agent/status`. */',
|
||||
' name: string',
|
||||
' /** The dispatch mode from the declaration\'s `@mode` tag. */',
|
||||
' mode: string',
|
||||
' /** The exact listener signature, whitespace-normalized. */',
|
||||
' signature: string',
|
||||
' /** Original event JSDoc, with only container indentation removed. */',
|
||||
' jsDoc: string',
|
||||
' /** First sentence of the event JSDoc. */',
|
||||
' summary: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */',
|
||||
'export interface InheritedApiEntry {',
|
||||
' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */',
|
||||
' name: string',
|
||||
' /** One-line summary of what the member does. */',
|
||||
' summary: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One named type shape the service signatures reference. */',
|
||||
'export interface TypeApiEntry {',
|
||||
' /** The exported type/interface name, e.g. `BashRunResult`. */',
|
||||
' name: string',
|
||||
' /** The full declaration text, comments stripped. */',
|
||||
' declaration: string',
|
||||
'}',
|
||||
'',
|
||||
'/** Every harness `ctx.<key>` service, sorted by key. */',
|
||||
'export const SERVICE_API: readonly ServiceApiEntry[] = [',
|
||||
]
|
||||
for (const service of services) {
|
||||
lines.push(' {')
|
||||
lines.push(` key: ${quote(service.key)},`)
|
||||
lines.push(` summary: ${quote(firstSentence(service.doc))},`)
|
||||
if (service.methods.length === 0) {
|
||||
lines.push(' methods: [],')
|
||||
} else {
|
||||
lines.push(' methods: [')
|
||||
for (const method of service.methods) {
|
||||
lines.push(' {')
|
||||
lines.push(` signature: ${quote(method.signature)},`)
|
||||
lines.push(` jsDoc: ${quote(method.jsDoc)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(' ],')
|
||||
}
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** Every harness event, sorted by name. */',
|
||||
'export const EVENT_API: readonly EventApiEntry[] = [',
|
||||
)
|
||||
for (const event of [...events].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(event.name)},`)
|
||||
lines.push(` mode: ${quote(event.mode)},`)
|
||||
lines.push(` signature: ${quote(event.signature)},`)
|
||||
lines.push(` jsDoc: ${quote(event.jsDoc)},`)
|
||||
lines.push(` summary: ${quote(firstSentence(event.doc))},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */',
|
||||
'export const TYPE_API: readonly TypeApiEntry[] = [',
|
||||
)
|
||||
for (const type of types) {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(type.name)},`)
|
||||
lines.push(` declaration: ${quote(type.declaration)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */',
|
||||
'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [',
|
||||
)
|
||||
for (const inherited of inheritedServices) {
|
||||
lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`)
|
||||
}
|
||||
lines.push(']', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
/** Render the cross-link "Types:" line for a signature, or '' if none apply. */
|
||||
function typeLinks(signature: string, linkedTypePages: Readonly<Record<string, string>>): string {
|
||||
const seen = new Set<string>()
|
||||
for (const name of Object.keys(linkedTypePages)) {
|
||||
if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name)
|
||||
}
|
||||
if (seen.size === 0) return ''
|
||||
const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${linkedTypePages[n]})`)
|
||||
return `Types: ${links.join(' · ')}`
|
||||
}
|
||||
|
||||
/** Render one harness event entry. */
|
||||
function renderEvent(e: EventEntry, linkedTypePages: Readonly<Record<string, string>>): string[] {
|
||||
const out = [`### \`${e.name}\` — ${e.mode}`, '']
|
||||
if (e.doc) out.push(e.doc, '')
|
||||
out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
|
||||
const links = typeLinks(e.signature, linkedTypePages)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render one harness service entry. */
|
||||
function renderService(s: ServiceEntry, linkedTypePages: Readonly<Record<string, string>>): string[] {
|
||||
const kind = s.abstract ? ' (abstract seam)' : ''
|
||||
const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, '']
|
||||
if (s.doc) out.push(s.doc, '')
|
||||
if (s.methods.length) {
|
||||
const declarations = s.methods.flatMap((method, index) => [
|
||||
...(index > 0 ? [''] : []),
|
||||
method.jsDoc,
|
||||
method.signature,
|
||||
])
|
||||
out.push('```' + FENCE, ...declarations, '```', '')
|
||||
const links = typeLinks(s.methods.map(method => method.signature).join('\n'), linkedTypePages)
|
||||
if (links) out.push(links, '')
|
||||
}
|
||||
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
/** The shared generated-file banner comment. */
|
||||
const BANNER = [
|
||||
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
|
||||
'',
|
||||
]
|
||||
|
||||
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
|
||||
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
|
||||
|
||||
/**
|
||||
* Render the events catalog deterministically.
|
||||
* @param events - validated event entries to render.
|
||||
* @param policy - type links and inherited events supplied by the caller.
|
||||
* @returns the complete generated Markdown document.
|
||||
*/
|
||||
export function renderEvents(events: EventEntry[], policy: CordisCatalogPolicy): string {
|
||||
const lines: string[] = [
|
||||
...BANNER,
|
||||
'# Cordis Events Catalog',
|
||||
'',
|
||||
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
|
||||
'',
|
||||
GATE_NOTICE,
|
||||
'',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
|
||||
'',
|
||||
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
|
||||
'',
|
||||
]
|
||||
const scopes = [...new Set(events.map(e => e.scope))].sort()
|
||||
for (const scope of scopes) {
|
||||
lines.push(`## \`${scope}/*\``, '')
|
||||
for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
lines.push(...renderEvent(e, policy.linkedTypePages))
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
'## Inherited events (cordis core + loader/hmr/timer)',
|
||||
'',
|
||||
'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.',
|
||||
'',
|
||||
)
|
||||
for (const e of policy.inheritedEvents) {
|
||||
lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the services catalog deterministically.
|
||||
* @param services - validated service entries to render.
|
||||
* @param policy - type links and inherited services supplied by the caller.
|
||||
* @returns the complete generated Markdown document.
|
||||
*/
|
||||
export function renderServices(services: ServiceEntry[], policy: CordisCatalogPolicy): string {
|
||||
const lines: string[] = [
|
||||
...BANNER,
|
||||
'# Cordis Services Catalog',
|
||||
'',
|
||||
'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
|
||||
'',
|
||||
GATE_NOTICE,
|
||||
'',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
|
||||
'',
|
||||
]
|
||||
for (const s of services) lines.push(...renderService(s, policy.linkedTypePages))
|
||||
lines.push(
|
||||
'## Inherited `ctx` members (cordis core + loader/hmr/timer)',
|
||||
'',
|
||||
'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.',
|
||||
'',
|
||||
)
|
||||
for (const s of policy.inheritedServices) {
|
||||
lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
451
packages/typert/generator/src/emitter.ts
Normal file
451
packages/typert/generator/src/emitter.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Model-driven Typert artifact emitter. It consumes only FaceModel and
|
||||
* TypeGraph data; TypeScript compiler nodes are not part of this boundary.
|
||||
* @module @deepseek-ai/dsh-typert-generator/emitter
|
||||
*/
|
||||
|
||||
import type {
|
||||
DocumentationModel,
|
||||
FaceModel,
|
||||
MemberModel,
|
||||
PackageModel,
|
||||
SchemaModel,
|
||||
SymbolId,
|
||||
TypeDeclarationModel,
|
||||
TypeNodeId,
|
||||
TypeNodeModel,
|
||||
} from './model.ts'
|
||||
import { TypeGraphRenderer } from './renderer.ts'
|
||||
|
||||
/** Failure to project a modeled construct into an emitted artifact. */
|
||||
export class TypertEmitError extends Error {
|
||||
override name = 'TypertEmitError'
|
||||
}
|
||||
|
||||
/** JavaScript and declaration artifacts for one package on one face. */
|
||||
export interface ModelEmitResult {
|
||||
readonly package: string
|
||||
readonly face: FaceModel['face']
|
||||
readonly exports: readonly string[]
|
||||
readonly js: string
|
||||
readonly dts: string
|
||||
}
|
||||
|
||||
interface RuntimeMemberModel {
|
||||
readonly kind: MemberModel['kind']
|
||||
readonly name: string
|
||||
readonly signature: string
|
||||
readonly summary?: string
|
||||
readonly jsDoc?: string
|
||||
}
|
||||
|
||||
interface RuntimeTypeModel {
|
||||
readonly name: string
|
||||
readonly declaration: string
|
||||
}
|
||||
|
||||
interface RuntimeServiceModel extends DocumentationModel {
|
||||
readonly key: string
|
||||
readonly exportName: string
|
||||
readonly members: readonly RuntimeMemberModel[]
|
||||
readonly types: readonly RuntimeTypeModel[]
|
||||
}
|
||||
|
||||
interface RuntimeEventModel extends DocumentationModel {
|
||||
readonly name: string
|
||||
readonly mode?: string
|
||||
readonly signature: string
|
||||
}
|
||||
|
||||
interface RuntimeObjectModel extends DocumentationModel {
|
||||
readonly name: string
|
||||
readonly exportName: string
|
||||
readonly members: readonly RuntimeMemberModel[]
|
||||
readonly types: readonly RuntimeTypeModel[]
|
||||
}
|
||||
|
||||
interface RuntimePackageModel {
|
||||
readonly services: readonly RuntimeServiceModel[]
|
||||
readonly events: readonly RuntimeEventModel[]
|
||||
readonly objects: readonly RuntimeObjectModel[]
|
||||
}
|
||||
|
||||
/** Emit generated runtime and type artifacts from one independently analyzed face. */
|
||||
export class FaceModelEmitter {
|
||||
private readonly renderer: TypeGraphRenderer
|
||||
|
||||
/**
|
||||
* Create an emitter for one face graph.
|
||||
* @param face - independently analyzed face.
|
||||
*/
|
||||
constructor(private readonly face: FaceModel) {
|
||||
this.renderer = new TypeGraphRenderer(face.graph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one modeled package.
|
||||
* @param packageName - exact package name in the face model.
|
||||
* @returns executable JavaScript and its precise declaration file.
|
||||
*/
|
||||
emit(packageName: string): ModelEmitResult {
|
||||
const packageModel = this.face.packages.find(candidate => candidate.name === packageName)
|
||||
if (packageModel === undefined) {
|
||||
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`)
|
||||
}
|
||||
const schemas = new SchemaEmitter(this.renderer, packageModel.schemas)
|
||||
const schemaArtifact = schemas.emit()
|
||||
const runtimeModel = this.runtimeModel(packageModel)
|
||||
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
|
||||
const dts = this.renderDts(packageModel, schemaArtifact)
|
||||
return {
|
||||
package: packageName,
|
||||
face: this.face.face,
|
||||
exports: packageModel.schemas.map(schema => schema.export.name),
|
||||
js,
|
||||
dts,
|
||||
}
|
||||
}
|
||||
|
||||
private runtimeModel(packageModel: PackageModel): RuntimePackageModel {
|
||||
const services = packageModel.services.map((service): RuntimeServiceModel => {
|
||||
const members = service.members.map(id => this.runtimeMember(this.renderer.member(id)))
|
||||
return {
|
||||
...documentationLiteral(service),
|
||||
key: service.key,
|
||||
exportName: service.export.name,
|
||||
members,
|
||||
types: this.runtimeTypes(this.renderer.declarationClosureForMembers(service.members), service.symbol),
|
||||
}
|
||||
})
|
||||
const events = packageModel.events.map((event): RuntimeEventModel => {
|
||||
const node = this.renderer.node(event.signature)
|
||||
if (node.kind !== 'function') {
|
||||
throw new TypertEmitError(`typert emitter(${this.face.face}): event ${event.name} is not a function type`)
|
||||
}
|
||||
return {
|
||||
...documentationLiteral(event),
|
||||
name: event.name,
|
||||
...(event.mode === undefined ? {} : { mode: event.mode }),
|
||||
signature: `${quote(event.name)}${this.renderer.renderSignature(node.signature)}`,
|
||||
}
|
||||
})
|
||||
const objects = packageModel.objects.map((object): RuntimeObjectModel => {
|
||||
const declaration = this.renderer.declaration(object.symbol)
|
||||
return {
|
||||
...documentationLiteral(object),
|
||||
name: declaration.name,
|
||||
exportName: object.export.name,
|
||||
members: declaration.members.map(member => this.runtimeMember(member)),
|
||||
types: this.runtimeTypes(this.renderer.declarationClosureForMembers(declaration.members.map(member => member.id)), declaration.id),
|
||||
}
|
||||
})
|
||||
return { services, events, objects }
|
||||
}
|
||||
|
||||
private runtimeMember(member: MemberModel): RuntimeMemberModel {
|
||||
return {
|
||||
kind: member.kind,
|
||||
name: member.name,
|
||||
signature: this.renderer.renderMember(member, true),
|
||||
...(member.summary === undefined ? {} : { summary: member.summary }),
|
||||
...(member.jsDoc === undefined ? {} : { jsDoc: member.jsDoc }),
|
||||
}
|
||||
}
|
||||
|
||||
private runtimeTypes(declarations: readonly TypeDeclarationModel[], root: SymbolId): RuntimeTypeModel[] {
|
||||
return declarations
|
||||
.filter(declaration => declaration.id !== root)
|
||||
.map(declaration => ({
|
||||
name: declaration.name,
|
||||
declaration: this.renderer.renderDeclaration(declaration.id),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
private renderJs(
|
||||
packageModel: PackageModel,
|
||||
schemas: SchemaArtifact,
|
||||
runtimeModel: RuntimePackageModel,
|
||||
): string {
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */',
|
||||
]
|
||||
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
|
||||
lines.push(...schemas.definitions)
|
||||
if (schemas.definitions.length > 0) lines.push('')
|
||||
for (const schema of schemas.exports) lines.push(`export const ${schema.exportName} = ${schema.internalName}`)
|
||||
if (schemas.exports.length > 0) lines.push('')
|
||||
const model = JSON.stringify(runtimeModel, null, 2)
|
||||
lines.push('export const TYPERT = {')
|
||||
lines.push(` package: ${quote(packageModel.name)},`)
|
||||
lines.push(` face: ${quote(this.face.face)},`)
|
||||
lines.push(' schemas: [')
|
||||
for (const schema of schemas.exports) {
|
||||
lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push(` model: ${indent(model, 2).trimStart()},`)
|
||||
lines.push('}')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
private renderDts(packageModel: PackageModel, schemas: SchemaArtifact): string {
|
||||
const imports = new Map<string, string[]>()
|
||||
for (const schema of schemas.exports) {
|
||||
const specifier = packageExportSpecifier(packageModel.name, schema.model.export.subpath)
|
||||
const names = imports.get(specifier) ?? []
|
||||
names.push(`${schema.model.export.name} as ${schema.exportName}$source`)
|
||||
imports.set(specifier, names)
|
||||
}
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */',
|
||||
]
|
||||
if (schemas.exports.length > 0) lines.splice(1, 0, 'import type { z } from \'zod\'')
|
||||
for (const [specifier, names] of [...imports].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
lines.push(`import type { ${names.sort().join(', ')} } from ${quote(specifier)}`)
|
||||
}
|
||||
lines.push('')
|
||||
for (const schema of schemas.exports) {
|
||||
lines.push(`export declare const ${schema.exportName}: z.ZodType<${schema.exportName}$source>`)
|
||||
}
|
||||
if (schemas.exports.length > 0) lines.push('')
|
||||
// The Loader validates and narrows this generated module boundary before
|
||||
// registration. Keeping the public declaration unknown prevents every
|
||||
// contributing business package from depending on the runtime registry.
|
||||
lines.push('export declare const TYPERT: unknown')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
}
|
||||
|
||||
interface SchemaExport {
|
||||
readonly model: SchemaModel
|
||||
readonly exportName: string
|
||||
readonly internalName: string
|
||||
}
|
||||
|
||||
interface SchemaArtifact {
|
||||
readonly definitions: readonly string[]
|
||||
readonly exports: readonly SchemaExport[]
|
||||
}
|
||||
|
||||
class SchemaEmitter {
|
||||
private readonly names = new Map<SymbolId, string>()
|
||||
private readonly declarations: TypeDeclarationModel[]
|
||||
|
||||
constructor(
|
||||
private readonly renderer: TypeGraphRenderer,
|
||||
private readonly schemas: readonly SchemaModel[],
|
||||
) {
|
||||
const declarations = new Map<SymbolId, TypeDeclarationModel>()
|
||||
for (const schema of schemas) {
|
||||
for (const declaration of renderer.declarationClosureForTypes([schema.type])) {
|
||||
declarations.set(declaration.id, declaration)
|
||||
}
|
||||
}
|
||||
this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
|
||||
const identifiers = new Set<string>()
|
||||
for (const declaration of this.declarations) {
|
||||
const base = `${safeIdentifier(declaration.name)}$schema`
|
||||
let name = base
|
||||
let suffix = 2
|
||||
while (identifiers.has(name)) name = `${base}${String(suffix++)}`
|
||||
identifiers.add(name)
|
||||
this.names.set(declaration.id, name)
|
||||
}
|
||||
}
|
||||
|
||||
emit(): SchemaArtifact {
|
||||
const definitions = this.declarations.map((declaration) => {
|
||||
if (declaration.typeParameters.length > 0) {
|
||||
this.fail(declaration.name, 'generic declarations require a schema-factory projection')
|
||||
}
|
||||
return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}`
|
||||
})
|
||||
const exports = this.schemas.map((model): SchemaExport => ({
|
||||
model,
|
||||
exportName: safeIdentifier(model.export.name),
|
||||
internalName: this.schemaName(model.symbol),
|
||||
}))
|
||||
return { definitions, exports }
|
||||
}
|
||||
|
||||
private declarationSchema(declaration: TypeDeclarationModel): string {
|
||||
if (declaration.kind === 'enum') {
|
||||
this.fail(declaration.name, 'enum declarations have no Zod projection')
|
||||
}
|
||||
if (declaration.kind === 'alias') {
|
||||
if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type')
|
||||
return this.describe(this.typeSchema(declaration.type), declaration)
|
||||
}
|
||||
const own = this.objectSchema(declaration.members, declaration.name)
|
||||
let result = own
|
||||
for (const heritage of declaration.extends) {
|
||||
result = `z.intersection(${this.typeSchema(heritage)}, ${result})`
|
||||
}
|
||||
return this.describe(result, declaration)
|
||||
}
|
||||
|
||||
private typeSchema(id: TypeNodeId): string {
|
||||
const node = this.renderer.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return this.keywordSchema(node.name)
|
||||
case 'literal': return `z.literal(${node.text})`
|
||||
case 'parenthesized': return this.typeSchema(node.type)
|
||||
case 'reference': return this.referenceSchema(node)
|
||||
case 'union': {
|
||||
if (node.types.length === 0) return 'z.never()'
|
||||
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId)
|
||||
return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])`
|
||||
}
|
||||
case 'intersection': {
|
||||
const [head, ...tail] = node.types
|
||||
if (head === undefined) return 'z.unknown()'
|
||||
return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head))
|
||||
}
|
||||
case 'array': return `z.array(${this.typeSchema(node.element)})`
|
||||
case 'tuple': {
|
||||
const fixed = node.elements.filter(element => !element.rest)
|
||||
const rest = node.elements.find(element => element.rest)
|
||||
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])`
|
||||
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})`
|
||||
return schema
|
||||
}
|
||||
case 'object': return this.objectSchema(node.members, id)
|
||||
case 'operator':
|
||||
case 'indexed-access':
|
||||
case 'conditional':
|
||||
case 'infer':
|
||||
case 'mapped':
|
||||
case 'template-literal':
|
||||
case 'type-query':
|
||||
case 'import-type':
|
||||
case 'predicate':
|
||||
case 'function':
|
||||
case 'constructor':
|
||||
case 'this': return this.unsupported(node)
|
||||
}
|
||||
}
|
||||
|
||||
private referenceSchema(node: Extract<TypeNodeModel, { kind: 'reference' }>): string {
|
||||
if (node.target.kind === 'declaration') {
|
||||
return `z.lazy(() => ${this.schemaName(node.target.symbol)})`
|
||||
}
|
||||
if (node.target.kind === 'standard') {
|
||||
switch (node.target.name) {
|
||||
case 'Array':
|
||||
case 'ReadonlyArray': {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'array reference has no element type')
|
||||
return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray')
|
||||
}
|
||||
case 'Record': {
|
||||
const key = node.arguments[0]
|
||||
const value = node.arguments[1]
|
||||
if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types')
|
||||
return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})`
|
||||
}
|
||||
case 'Date': return 'z.date()'
|
||||
default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`)
|
||||
}
|
||||
}
|
||||
this.fail(node.name, `${node.target.kind} reference has no Zod projection`)
|
||||
}
|
||||
|
||||
private tupleRestSchema(id: TypeNodeId): string {
|
||||
const node = this.renderer.node(id)
|
||||
if (node.kind === 'array') return this.typeSchema(node.element)
|
||||
if (node.kind === 'reference'
|
||||
&& node.target.kind === 'standard'
|
||||
&& (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'tuple rest array has no element type')
|
||||
return this.typeSchema(element)
|
||||
}
|
||||
this.fail(id, 'tuple rest element must retain an array type')
|
||||
}
|
||||
|
||||
private objectSchema(members: readonly MemberModel[], subject: string): string {
|
||||
const properties: string[] = []
|
||||
for (const member of members) {
|
||||
if (member.static || member.visibility !== 'public') continue
|
||||
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
|
||||
const property = this.describe(
|
||||
this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional),
|
||||
member,
|
||||
)
|
||||
properties.push(`${quote(member.name)}: ${property}`)
|
||||
}
|
||||
return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
}
|
||||
|
||||
private keywordSchema(name: string): string {
|
||||
switch (name) {
|
||||
case 'any': return 'z.any()'
|
||||
case 'unknown': return 'z.unknown()'
|
||||
case 'never': return 'z.never()'
|
||||
case 'string': return 'z.string()'
|
||||
case 'number': return 'z.number()'
|
||||
case 'bigint': return 'z.bigint()'
|
||||
case 'boolean': return 'z.boolean()'
|
||||
case 'symbol': return 'z.symbol()'
|
||||
case 'undefined': return 'z.undefined()'
|
||||
case 'void': return 'z.void()'
|
||||
case 'object': return "z.custom((value) => (typeof value === 'object' && value !== null) || typeof value === 'function')"
|
||||
default: this.fail(name, `keyword ${name} has no Zod projection`)
|
||||
}
|
||||
}
|
||||
|
||||
private schemaName(symbol: SymbolId): string {
|
||||
const name = this.names.get(symbol)
|
||||
if (name === undefined) this.fail(symbol, 'referenced declaration is outside the selected schema closure')
|
||||
return name
|
||||
}
|
||||
|
||||
private describe(schema: string, documentation: DocumentationModel): string {
|
||||
return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})`
|
||||
}
|
||||
|
||||
private optional(schema: string, optional: boolean): string {
|
||||
return optional ? `${schema}.optional()` : schema
|
||||
}
|
||||
|
||||
private readonly(schema: string, readonly: boolean): string {
|
||||
return readonly ? `${schema}.readonly()` : schema
|
||||
}
|
||||
|
||||
private unsupported(node: TypeNodeModel): never {
|
||||
this.fail(node.id, `type node ${node.kind} has no Zod projection`)
|
||||
}
|
||||
|
||||
private fail(subject: string, message: string): never {
|
||||
throw new TypertEmitError(`typert Zod emitter: ${subject}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function documentationLiteral(documentation: DocumentationModel): DocumentationModel {
|
||||
return {
|
||||
...(documentation.description === undefined ? {} : { description: documentation.description }),
|
||||
...(documentation.summary === undefined ? {} : { summary: documentation.summary }),
|
||||
tags: documentation.tags,
|
||||
...(documentation.jsDoc === undefined ? {} : { jsDoc: documentation.jsDoc }),
|
||||
}
|
||||
}
|
||||
|
||||
function packageExportSpecifier(packageName: string, subpath: string): string {
|
||||
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
|
||||
}
|
||||
|
||||
function safeIdentifier(name: string): string {
|
||||
const normalized = name.replace(/[^$\w]/gu, '_')
|
||||
if (/^[$A-Z_a-z]/u.test(normalized)) return normalized
|
||||
return `_${normalized}`
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'`
|
||||
}
|
||||
|
||||
function indent(value: string, spaces: number): string {
|
||||
const prefix = ' '.repeat(spaces)
|
||||
return value.split('\n').map(line => `${prefix}${line}`).join('\n')
|
||||
}
|
||||
16
packages/typert/generator/src/index.ts
Normal file
16
packages/typert/generator/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Public surface of the Typert analyzer, compiler-independent model, and
|
||||
* model-driven artifact emitters. Build wiring lives in the `./tsdown`
|
||||
* subpath.
|
||||
* @module @deepseek-ai/dsh-typert-generator
|
||||
*/
|
||||
|
||||
export { WorkspaceAnalyzer, TypertAnalysisError } from './analyzer.ts'
|
||||
export type { AnalysisMode, DiscoveredTypertPackage, WorkspaceAnalyzerOptions } from './analyzer.ts'
|
||||
export { FaceModelEmitter, TypertEmitError } from './emitter.ts'
|
||||
export type { ModelEmitResult } from './emitter.ts'
|
||||
export * from './cordis-catalog.ts'
|
||||
export { TypeGraphRenderer, TypeGraphRenderError } from './renderer.ts'
|
||||
export { WorkspaceTypertGenerator } from './workspace.ts'
|
||||
export type { WorkspaceEmitResult } from './workspace.ts'
|
||||
export type * from './model.ts'
|
||||
31
packages/typert/generator/src/invariant.ts
Normal file
31
packages/typert/generator/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-typert-generator`.
|
||||
* @module @deepseek-ai/dsh-typert-generator/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-typert-generator'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'typert-generator-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this source-project analyzer and build-time emitter
|
||||
* runs outside any cordis runtime; model snapshots, executable artifacts, and
|
||||
* consuming-package typechecks enforce its output contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
375
packages/typert/generator/src/model.ts
Normal file
375
packages/typert/generator/src/model.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Compiler-independent Typert analysis model. TypeScript nodes and checker
|
||||
* objects are extraction inputs only; emitters consume this graph.
|
||||
* @module @deepseek-ai/dsh-typert-generator/model
|
||||
*/
|
||||
|
||||
/** One independently compiled side of the workspace. */
|
||||
export type TypertFace = 'host' | 'client'
|
||||
|
||||
/** Stable graph-local identifier of a type expression. */
|
||||
export type TypeNodeId = string
|
||||
|
||||
/** Stable workspace identifier of a declared symbol. */
|
||||
export type SymbolId = string
|
||||
|
||||
/** Keyword types accepted in ordinary TypeScript source declarations. */
|
||||
export type KeywordTypeName =
|
||||
| 'any'
|
||||
| 'bigint'
|
||||
| 'boolean'
|
||||
| 'never'
|
||||
| 'number'
|
||||
| 'object'
|
||||
| 'string'
|
||||
| 'symbol'
|
||||
| 'undefined'
|
||||
| 'unknown'
|
||||
| 'void'
|
||||
|
||||
/** Prefix operators accepted on TypeScript type nodes. */
|
||||
export type TypeOperatorName = 'keyof' | 'readonly' | 'unique'
|
||||
|
||||
/** Source position retained for diagnostics and source-edit mode. */
|
||||
export interface SourceLocation {
|
||||
readonly file: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
}
|
||||
|
||||
/** One public package export and the declaration it resolves to. */
|
||||
export interface ExportModel {
|
||||
readonly subpath: string
|
||||
readonly name: string
|
||||
readonly symbol: SymbolId
|
||||
readonly aliases: readonly string[]
|
||||
}
|
||||
|
||||
/** One structured JSDoc tag, retaining its original text for unknown tags. */
|
||||
export interface JsDocTagModel {
|
||||
readonly name: string
|
||||
readonly argument?: string
|
||||
readonly comment?: string
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** JSDoc retained as a standard part of every documented model element. */
|
||||
export interface DocumentationModel {
|
||||
readonly description?: string
|
||||
readonly summary?: string
|
||||
readonly tags: readonly JsDocTagModel[]
|
||||
readonly jsDoc?: string
|
||||
}
|
||||
|
||||
/** One Cordis Context contribution. */
|
||||
export interface ServiceModel extends DocumentationModel {
|
||||
readonly key: string
|
||||
readonly symbol: SymbolId
|
||||
readonly export: ExportModel
|
||||
readonly members: readonly string[]
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
/** One Cordis Events contribution. */
|
||||
export interface EventModel extends DocumentationModel {
|
||||
readonly name: string
|
||||
readonly signature: TypeNodeId
|
||||
/** Body-free declaration text retained for byte-stable source projections. */
|
||||
readonly text: string
|
||||
readonly mode?: string
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
/** One explicitly exported reference-passed object. */
|
||||
export interface ObjectModel extends DocumentationModel {
|
||||
readonly export: ExportModel
|
||||
readonly symbol: SymbolId
|
||||
readonly passing: 'reference'
|
||||
}
|
||||
|
||||
/** One explicitly selected value type for schema generation. */
|
||||
export interface SchemaModel extends DocumentationModel {
|
||||
readonly export: ExportModel
|
||||
readonly symbol: SymbolId
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
/** Business semantics discovered in one package on one face. */
|
||||
export interface PackageModel {
|
||||
readonly name: string
|
||||
readonly root: string
|
||||
readonly exports: readonly ExportModel[]
|
||||
readonly services: readonly ServiceModel[]
|
||||
readonly events: readonly EventModel[]
|
||||
readonly objects: readonly ObjectModel[]
|
||||
readonly schemas: readonly SchemaModel[]
|
||||
}
|
||||
|
||||
/** One explicit import/re-export edge between independently compiled faces. */
|
||||
export interface CrossFaceLink {
|
||||
readonly fromFace: TypertFace
|
||||
readonly fromPackage: string
|
||||
readonly toFace: TypertFace
|
||||
readonly toPackage: string
|
||||
readonly subpath: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Complete analysis result for an independently compiled face. */
|
||||
export interface FaceModel {
|
||||
readonly face: TypertFace
|
||||
readonly packages: readonly PackageModel[]
|
||||
readonly graph: TypeGraph
|
||||
}
|
||||
|
||||
/** Complete host/client analysis result. */
|
||||
export interface WorkspaceModel {
|
||||
readonly faces: readonly FaceModel[]
|
||||
readonly crossFaceLinks: readonly CrossFaceLink[]
|
||||
}
|
||||
|
||||
/** One top-level authored type declaration indexed without making it a graph root. */
|
||||
export interface SourceDeclarationModel {
|
||||
readonly face: TypertFace
|
||||
readonly package: string
|
||||
readonly name: string
|
||||
readonly kind: 'interface' | 'class' | 'alias' | 'enum'
|
||||
readonly location: SourceLocation
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** Visibility recorded on class members. */
|
||||
export type MemberVisibility = 'public' | 'protected' | 'private'
|
||||
|
||||
/** One generic type parameter, preserving its pre-evaluation constraint/default. */
|
||||
export interface TypeParameterModel {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly const: boolean
|
||||
readonly constraint?: TypeNodeId
|
||||
readonly default?: TypeNodeId
|
||||
readonly variance?: 'in' | 'out' | 'in-out'
|
||||
}
|
||||
|
||||
/** One function-like parameter. */
|
||||
export interface ParameterModel {
|
||||
readonly name: string
|
||||
readonly binding: 'identifier' | 'object' | 'array'
|
||||
readonly type: TypeNodeId
|
||||
readonly optional: boolean
|
||||
readonly rest: boolean
|
||||
readonly receiver: boolean
|
||||
readonly initializer?: string
|
||||
}
|
||||
|
||||
/** A function/call/construct signature. */
|
||||
export interface SignatureModel {
|
||||
readonly typeParameters: readonly TypeParameterModel[]
|
||||
readonly parameters: readonly ParameterModel[]
|
||||
readonly returns: TypeNodeId
|
||||
}
|
||||
|
||||
/** Shared flags of a class/interface/type-literal member. */
|
||||
export interface MemberBase extends DocumentationModel {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly optional: boolean
|
||||
readonly readonly: boolean
|
||||
readonly async: boolean
|
||||
readonly abstract: boolean
|
||||
readonly static: boolean
|
||||
readonly visibility: MemberVisibility
|
||||
readonly location: SourceLocation
|
||||
/** Body-free declaration text retained for byte-stable source projections. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** A property member. */
|
||||
export interface PropertyMemberModel extends MemberBase {
|
||||
readonly kind: 'property'
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
/** A method member. */
|
||||
export interface MethodMemberModel extends MemberBase {
|
||||
readonly kind: 'method'
|
||||
readonly signature: SignatureModel
|
||||
}
|
||||
|
||||
/** A getter or setter member. */
|
||||
export interface AccessorMemberModel extends MemberBase {
|
||||
readonly kind: 'getter' | 'setter'
|
||||
readonly signature: SignatureModel
|
||||
}
|
||||
|
||||
/** A call/construct/index signature in an interface or type literal. */
|
||||
export interface SignatureMemberModel extends MemberBase {
|
||||
readonly kind: 'call' | 'construct' | 'index'
|
||||
readonly signature: SignatureModel
|
||||
}
|
||||
|
||||
/** One declaration or object-literal member. */
|
||||
export type MemberModel =
|
||||
| PropertyMemberModel
|
||||
| MethodMemberModel
|
||||
| AccessorMemberModel
|
||||
| SignatureMemberModel
|
||||
|
||||
/** One enum member, retaining its developer-authored initializer. */
|
||||
export interface EnumMemberModel extends DocumentationModel {
|
||||
readonly name: string
|
||||
readonly initializer?: string
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
/** One authored part of a merged interface declaration. */
|
||||
export interface TypeDeclarationPartModel extends DocumentationModel {
|
||||
readonly package: string
|
||||
readonly location: SourceLocation
|
||||
readonly typeParameters: readonly TypeParameterModel[]
|
||||
readonly extends: readonly TypeNodeId[]
|
||||
readonly members: readonly string[]
|
||||
}
|
||||
|
||||
/** A declared interface, class, or alias. */
|
||||
export interface TypeDeclarationModel extends DocumentationModel {
|
||||
readonly id: SymbolId
|
||||
readonly package: string
|
||||
readonly name: string
|
||||
readonly kind: 'interface' | 'class' | 'alias' | 'enum'
|
||||
readonly abstract: boolean
|
||||
readonly exported: boolean
|
||||
readonly location: SourceLocation
|
||||
/** Canonical body-free declaration text retained alongside the type tree. */
|
||||
readonly text: string
|
||||
readonly typeParameters: readonly TypeParameterModel[]
|
||||
readonly extends: readonly TypeNodeId[]
|
||||
readonly implements: readonly TypeNodeId[]
|
||||
readonly members: readonly MemberModel[]
|
||||
readonly parts?: readonly TypeDeclarationPartModel[]
|
||||
readonly type?: TypeNodeId
|
||||
readonly enumMembers?: readonly EnumMemberModel[]
|
||||
}
|
||||
|
||||
/** Target of a named type reference. */
|
||||
export type TypeTargetModel =
|
||||
| { readonly kind: 'declaration'; readonly symbol: SymbolId }
|
||||
| { readonly kind: 'type-parameter'; readonly parameter: string }
|
||||
| {
|
||||
readonly kind: 'cross-face'
|
||||
readonly face: TypertFace
|
||||
readonly package: string
|
||||
readonly subpath: string
|
||||
readonly name: string
|
||||
}
|
||||
| {
|
||||
readonly kind: 'external'
|
||||
readonly module: string
|
||||
readonly subpath: string
|
||||
readonly name: string
|
||||
}
|
||||
| { readonly kind: 'standard'; readonly name: string }
|
||||
|
||||
/** One tuple element, retaining labels and optional/rest modifiers. */
|
||||
export interface TupleElementModel {
|
||||
readonly name?: string
|
||||
readonly type: TypeNodeId
|
||||
readonly optional: boolean
|
||||
readonly rest: boolean
|
||||
}
|
||||
|
||||
/** One template-literal interpolation. */
|
||||
export interface TemplateSpanModel {
|
||||
readonly type: TypeNodeId
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** Compiler-independent TypeScript type expression. */
|
||||
export type TypeNodeModel =
|
||||
| { readonly id: TypeNodeId; readonly kind: 'keyword'; readonly name: KeywordTypeName }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'literal'; readonly value: string | number | bigint | boolean | null; readonly text: string }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'parenthesized'; readonly type: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'reference'; readonly name: string; readonly target: TypeTargetModel; readonly arguments: readonly TypeNodeId[] }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'union' | 'intersection'; readonly types: readonly TypeNodeId[] }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'array'; readonly element: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'tuple'; readonly elements: readonly TupleElementModel[] }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'object'; readonly members: readonly MemberModel[] }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'function'; readonly signature: SignatureModel }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'constructor'; readonly abstract: boolean; readonly signature: SignatureModel }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'indexed-access'; readonly object: TypeNodeId; readonly index: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'operator'; readonly operator: TypeOperatorName; readonly type: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'conditional'; readonly check: TypeNodeId; readonly extends: TypeNodeId; readonly whenTrue: TypeNodeId; readonly whenFalse: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'infer'; readonly parameter: TypeParameterModel }
|
||||
| {
|
||||
readonly id: TypeNodeId
|
||||
readonly kind: 'mapped'
|
||||
readonly parameter: TypeParameterModel
|
||||
readonly nameType?: TypeNodeId
|
||||
readonly value?: TypeNodeId
|
||||
readonly readonly: 'add' | 'remove' | 'preserve'
|
||||
readonly optional: 'add' | 'remove' | 'preserve'
|
||||
}
|
||||
| { readonly id: TypeNodeId; readonly kind: 'template-literal'; readonly head: string; readonly spans: readonly TemplateSpanModel[] }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'type-query'; readonly expression: string; readonly arguments: readonly TypeNodeId[] }
|
||||
| {
|
||||
readonly id: TypeNodeId
|
||||
readonly kind: 'import-type'
|
||||
readonly module: string
|
||||
readonly qualifier?: string
|
||||
readonly arguments: readonly TypeNodeId[]
|
||||
readonly typeof: boolean
|
||||
readonly attributes?: string
|
||||
readonly target?: TypeTargetModel
|
||||
}
|
||||
| { readonly id: TypeNodeId; readonly kind: 'predicate'; readonly asserts: boolean; readonly parameter: string; readonly type?: TypeNodeId }
|
||||
| { readonly id: TypeNodeId; readonly kind: 'this' }
|
||||
|
||||
/**
|
||||
* Return the direct type-expression edges owned by one node.
|
||||
* @param node - compiler-independent type node to inspect.
|
||||
* @returns graph-local ids of its direct child type nodes.
|
||||
*/
|
||||
export function childTypeNodeIds(node: TypeNodeModel): TypeNodeId[] {
|
||||
switch (node.kind) {
|
||||
case 'parenthesized':
|
||||
case 'operator': return [node.type]
|
||||
case 'reference': return [...node.arguments]
|
||||
case 'union':
|
||||
case 'intersection': return [...node.types]
|
||||
case 'array': return [node.element]
|
||||
case 'tuple': return node.elements.map(element => element.type)
|
||||
case 'indexed-access': return [node.object, node.index]
|
||||
case 'conditional': return [node.check, node.extends, node.whenTrue, node.whenFalse]
|
||||
case 'mapped': return [
|
||||
...(node.parameter.constraint === undefined ? [] : [node.parameter.constraint]),
|
||||
...(node.parameter.default === undefined ? [] : [node.parameter.default]),
|
||||
...(node.nameType === undefined ? [] : [node.nameType]),
|
||||
...(node.value === undefined ? [] : [node.value]),
|
||||
]
|
||||
case 'template-literal': return node.spans.map(span => span.type)
|
||||
case 'type-query':
|
||||
case 'import-type': return [...node.arguments]
|
||||
case 'predicate': return node.type === undefined ? [] : [node.type]
|
||||
case 'infer': return [
|
||||
...(node.parameter.constraint === undefined ? [] : [node.parameter.constraint]),
|
||||
...(node.parameter.default === undefined ? [] : [node.parameter.default]),
|
||||
]
|
||||
case 'keyword':
|
||||
case 'literal':
|
||||
case 'object':
|
||||
case 'function':
|
||||
case 'constructor':
|
||||
case 'this': return []
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Type declarations and expressions owned by one face. */
|
||||
export interface TypeGraph {
|
||||
readonly declarations: readonly TypeDeclarationModel[]
|
||||
readonly nodes: readonly TypeNodeModel[]
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unsupported model variant ${JSON.stringify(value)}`)
|
||||
}
|
||||
356
packages/typert/generator/src/renderer.ts
Normal file
356
packages/typert/generator/src/renderer.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Rendering and traversal over the compiler-independent TypeGraph. Emitters
|
||||
* use this module instead of reaching back into TypeScript AST nodes.
|
||||
* @module @deepseek-ai/dsh-typert-generator/renderer
|
||||
*/
|
||||
|
||||
import { childTypeNodeIds } from './model.ts'
|
||||
import type {
|
||||
MemberModel,
|
||||
ParameterModel,
|
||||
SignatureModel,
|
||||
SymbolId,
|
||||
TypeDeclarationModel,
|
||||
TypeGraph,
|
||||
TypeNodeId,
|
||||
TypeNodeModel,
|
||||
TypeParameterModel,
|
||||
} from './model.ts'
|
||||
|
||||
/** Failure to render or traverse an internally inconsistent TypeGraph. */
|
||||
export class TypeGraphRenderError extends Error {
|
||||
override name = 'TypeGraphRenderError'
|
||||
}
|
||||
|
||||
/** Read and render one TypeGraph without compiler objects. */
|
||||
export class TypeGraphRenderer {
|
||||
private readonly nodes: ReadonlyMap<TypeNodeId, TypeNodeModel>
|
||||
private readonly declarations: ReadonlyMap<SymbolId, TypeDeclarationModel>
|
||||
private readonly members: ReadonlyMap<string, MemberModel>
|
||||
private readonly parameterNames = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Index one complete graph.
|
||||
* @param graph - compiler-independent graph to render.
|
||||
*/
|
||||
constructor(readonly graph: TypeGraph) {
|
||||
this.nodes = new Map(graph.nodes.map(node => [node.id, node]))
|
||||
this.declarations = new Map(graph.declarations.map(declaration => [declaration.id, declaration]))
|
||||
this.members = new Map(graph.declarations.flatMap(declaration => declaration.members.map(member => [member.id, member] as const)))
|
||||
for (const declaration of graph.declarations) {
|
||||
this.indexParameters(declaration.typeParameters)
|
||||
for (const member of declaration.members) {
|
||||
if ('signature' in member) this.indexParameters(member.signature.typeParameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node id or fail with the broken edge.
|
||||
* @param id - graph-local type node id.
|
||||
* @returns the referenced node.
|
||||
*/
|
||||
node(id: TypeNodeId): TypeNodeModel {
|
||||
const node = this.nodes.get(id)
|
||||
if (node === undefined) throw new TypeGraphRenderError(`type graph references missing node ${id}`)
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a declaration id or fail with the broken edge.
|
||||
* @param id - workspace symbol id.
|
||||
* @returns the referenced declaration.
|
||||
*/
|
||||
declaration(id: SymbolId): TypeDeclarationModel {
|
||||
const declaration = this.declarations.get(id)
|
||||
if (declaration === undefined) throw new TypeGraphRenderError(`type graph references missing declaration ${id}`)
|
||||
return declaration
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a public member id.
|
||||
* @param id - declaration member id.
|
||||
* @returns the referenced member.
|
||||
*/
|
||||
member(id: string): MemberModel {
|
||||
const member = this.members.get(id)
|
||||
if (member === undefined) throw new TypeGraphRenderError(`type graph references missing member ${id}`)
|
||||
return member
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one type expression from the retained source structure.
|
||||
* @param id - type node id.
|
||||
* @returns TypeScript type text.
|
||||
*/
|
||||
renderType(id: TypeNodeId): string {
|
||||
const node = this.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return node.name
|
||||
case 'literal': return node.text
|
||||
case 'parenthesized': return `(${this.renderType(node.type)})`
|
||||
case 'reference': {
|
||||
const name = node.target.kind === 'type-parameter'
|
||||
? this.parameterNames.get(node.target.parameter) ?? node.name
|
||||
: node.name
|
||||
return node.arguments.length === 0
|
||||
? name
|
||||
: `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
}
|
||||
case 'union': return node.types.map(type => this.renderType(type)).join(' | ')
|
||||
case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ')
|
||||
case 'array': {
|
||||
const element = this.renderType(node.element)
|
||||
const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element
|
||||
return `${wrapped}[]`
|
||||
}
|
||||
case 'tuple': {
|
||||
const elements = node.elements.map((element) => {
|
||||
const type = this.renderType(element.type)
|
||||
if (element.name !== undefined) {
|
||||
return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}`
|
||||
}
|
||||
return `${element.rest ? '...' : ''}${type}${element.optional ? '?' : ''}`
|
||||
})
|
||||
return `[${elements.join(', ')}]`
|
||||
}
|
||||
case 'object': return this.renderObject(node.members)
|
||||
case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]`
|
||||
case 'operator': return `${node.operator} ${this.renderType(node.type)}`
|
||||
case 'conditional': {
|
||||
return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}`
|
||||
}
|
||||
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}`
|
||||
case 'mapped': {
|
||||
const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly '
|
||||
const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?'
|
||||
if (node.parameter.constraint === undefined) {
|
||||
throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`)
|
||||
}
|
||||
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}`
|
||||
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}`
|
||||
const value = node.value === undefined ? 'unknown' : this.renderType(node.value)
|
||||
return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }`
|
||||
}
|
||||
case 'template-literal': {
|
||||
const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('')
|
||||
return `\`${escapeTemplate(node.head)}${spans}\``
|
||||
}
|
||||
case 'type-query': {
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
return `typeof ${node.expression}${argumentsText}`
|
||||
}
|
||||
case 'import-type': {
|
||||
const attributes = node.attributes === undefined ? '' : `, ${node.attributes}`
|
||||
const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}`
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}`
|
||||
}
|
||||
case 'predicate': {
|
||||
const assertion = node.asserts ? 'asserts ' : ''
|
||||
return node.type === undefined
|
||||
? `${assertion}${node.parameter}`
|
||||
: `${assertion}${node.parameter} is ${this.renderType(node.type)}`
|
||||
}
|
||||
case 'this': return 'this'
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a callable signature without a member name.
|
||||
* @param signature - modeled signature.
|
||||
* @returns parameter list and return type.
|
||||
*/
|
||||
renderSignature(signature: SignatureModel): string {
|
||||
return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one class/interface member as a body-free declaration.
|
||||
* @param member - modeled member.
|
||||
* @param sourceModifiers - retain source-only modifiers for reflection text.
|
||||
* @returns one-line TypeScript member text.
|
||||
*/
|
||||
renderMember(member: MemberModel, sourceModifiers = false): string {
|
||||
if (sourceModifiers) return member.text
|
||||
const name = renderPropertyName(member.name)
|
||||
const optional = member.optional ? '?' : ''
|
||||
const readonly = member.readonly ? 'readonly ' : ''
|
||||
const abstract = member.abstract ? 'abstract ' : ''
|
||||
switch (member.kind) {
|
||||
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}`
|
||||
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}`
|
||||
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}`
|
||||
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}`
|
||||
case 'call': return this.renderSignature(member.signature)
|
||||
case 'construct': return `new ${this.renderSignature(member.signature)}`
|
||||
case 'index': {
|
||||
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')
|
||||
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}`
|
||||
}
|
||||
default: return assertNever(member)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a named declaration without JSDoc.
|
||||
* @param id - declaration symbol id.
|
||||
* @returns exported TypeScript declaration text.
|
||||
*/
|
||||
renderDeclaration(id: SymbolId): string {
|
||||
const declaration = this.declaration(id)
|
||||
const parameters = this.renderTypeParameters(declaration.typeParameters)
|
||||
if (declaration.kind === 'enum') {
|
||||
const members = declaration.enumMembers?.map(member =>
|
||||
` ${renderPropertyName(member.name)}${member.initializer === undefined ? '' : ` = ${member.initializer}`},`) ?? []
|
||||
return [`export enum ${declaration.name} {`, ...members, '}'].join('\n')
|
||||
}
|
||||
if (declaration.kind === 'alias') {
|
||||
if (declaration.type === undefined) throw new TypeGraphRenderError(`alias ${id} has no type node`)
|
||||
return `export type ${declaration.name}${parameters} = ${this.renderType(declaration.type)};`
|
||||
}
|
||||
const extendsTypes = declaration.extends.map(type => this.renderType(type))
|
||||
const implementsTypes = declaration.implements.map(type => this.renderType(type))
|
||||
const heritage = [
|
||||
extendsTypes.length === 0 ? '' : ` extends ${extendsTypes.join(', ')}`,
|
||||
implementsTypes.length === 0 ? '' : ` implements ${implementsTypes.join(', ')}`,
|
||||
].join('')
|
||||
const prefix = declaration.kind === 'class' && declaration.abstract ? 'abstract ' : ''
|
||||
const members = declaration.members.map(member => ` ${this.renderMember(member)};`)
|
||||
return [`export ${prefix}${declaration.kind} ${declaration.name}${parameters}${heritage} {`, ...members, '}'].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the transitive declaration closure referenced by members.
|
||||
* @param memberIds - business-surface member ids.
|
||||
* @returns declarations in graph order, excluding no roots implicitly.
|
||||
*/
|
||||
declarationClosureForMembers(memberIds: readonly string[]): TypeDeclarationModel[] {
|
||||
return this.declarationClosure(memberIds, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the transitive declaration closure referenced by type roots.
|
||||
* @param typeIds - graph type roots.
|
||||
* @returns declarations in graph order.
|
||||
*/
|
||||
declarationClosureForTypes(typeIds: readonly TypeNodeId[]): TypeDeclarationModel[] {
|
||||
return this.declarationClosure([], typeIds)
|
||||
}
|
||||
|
||||
private declarationClosure(
|
||||
memberIds: readonly string[],
|
||||
typeIds: readonly TypeNodeId[],
|
||||
): TypeDeclarationModel[] {
|
||||
const found = new Set<SymbolId>()
|
||||
const visiting = new Set<SymbolId>()
|
||||
const visitNode = (id: TypeNodeId): void => {
|
||||
const node = this.node(id)
|
||||
if (node.kind === 'reference' && node.target.kind === 'declaration') visitDeclaration(node.target.symbol)
|
||||
if (node.kind === 'import-type' && node.target?.kind === 'declaration') visitDeclaration(node.target.symbol)
|
||||
for (const child of childTypeNodeIds(node)) visitNode(child)
|
||||
for (const signature of nodeSignatures(node)) visitSignature(signature)
|
||||
if (node.kind === 'object') for (const member of node.members) visitMember(member)
|
||||
}
|
||||
const visitSignature = (signature: SignatureModel): void => {
|
||||
for (const parameter of signature.typeParameters) {
|
||||
if (parameter.constraint !== undefined) visitNode(parameter.constraint)
|
||||
if (parameter.default !== undefined) visitNode(parameter.default)
|
||||
}
|
||||
for (const parameter of signature.parameters) visitNode(parameter.type)
|
||||
visitNode(signature.returns)
|
||||
}
|
||||
const visitMember = (member: MemberModel): void => {
|
||||
if (member.kind === 'property') visitNode(member.type)
|
||||
else visitSignature(member.signature)
|
||||
}
|
||||
const visitDeclaration = (id: SymbolId): void => {
|
||||
if (found.has(id) || visiting.has(id)) return
|
||||
visiting.add(id)
|
||||
const declaration = this.declaration(id)
|
||||
for (const parameter of declaration.typeParameters) {
|
||||
if (parameter.constraint !== undefined) visitNode(parameter.constraint)
|
||||
if (parameter.default !== undefined) visitNode(parameter.default)
|
||||
}
|
||||
for (const type of [...declaration.extends, ...declaration.implements]) visitNode(type)
|
||||
if (declaration.type !== undefined) visitNode(declaration.type)
|
||||
for (const member of declaration.members) visitMember(member)
|
||||
visiting.delete(id)
|
||||
found.add(id)
|
||||
}
|
||||
for (const id of memberIds) visitMember(this.member(id))
|
||||
for (const id of typeIds) visitNode(id)
|
||||
return this.graph.declarations.filter(declaration => found.has(declaration.id))
|
||||
}
|
||||
|
||||
private renderSignatureHead(signature: SignatureModel): string {
|
||||
return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})`
|
||||
}
|
||||
|
||||
private renderReturn(signature: SignatureModel): string {
|
||||
return `: ${this.renderType(signature.returns)}`
|
||||
}
|
||||
|
||||
private renderParameter(parameter: ParameterModel): string {
|
||||
const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name
|
||||
const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : ''
|
||||
const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}`
|
||||
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}`
|
||||
}
|
||||
|
||||
private renderTypeParameters(parameters: readonly TypeParameterModel[]): string {
|
||||
return parameters.length === 0
|
||||
? ''
|
||||
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>`
|
||||
}
|
||||
|
||||
private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string {
|
||||
const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} `
|
||||
const constModifier = parameter.const ? 'const ' : ''
|
||||
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}`
|
||||
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}`
|
||||
return `${constModifier}${variance}${parameter.name}${constraint}${fallback}`
|
||||
}
|
||||
|
||||
private renderObject(members: readonly MemberModel[]): string {
|
||||
if (members.length === 0) return '{}'
|
||||
return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }`
|
||||
}
|
||||
|
||||
private indexParameters(parameters: readonly TypeParameterModel[]): void {
|
||||
for (const parameter of parameters) this.parameterNames.set(parameter.id, parameter.name)
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSignatures(node: TypeNodeModel): SignatureModel[] {
|
||||
return node.kind === 'function' || node.kind === 'constructor' ? [node.signature] : []
|
||||
}
|
||||
|
||||
function needsArrayParentheses(node: TypeNodeModel): boolean {
|
||||
return node.kind === 'union' || node.kind === 'intersection' || node.kind === 'function' || node.kind === 'constructor' || node.kind === 'conditional'
|
||||
}
|
||||
|
||||
function renderPropertyName(name: string): string {
|
||||
if (name.startsWith('[') && name.endsWith(']')) return name
|
||||
if (/^(?:[$A-Z_a-z][$\w]*|\d+)$/u.test(name)) return name
|
||||
return quote(name)
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`
|
||||
}
|
||||
|
||||
function escapeTemplate(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${')
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new TypeGraphRenderError(`unsupported model variant ${JSON.stringify(value)}`)
|
||||
}
|
||||
78
packages/typert/generator/src/tsdown-plugin.ts
Normal file
78
packages/typert/generator/src/tsdown-plugin.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Optional tsdown (rolldown) plugin face of the typert generator. When added
|
||||
* to a workspace tsdown config, it runs after each opted-in package bundle is
|
||||
* written and re-emits its model-driven face artifact at the package output
|
||||
* root. Packages without a Typert export are skipped.
|
||||
* @module @deepseek-ai/dsh-typert-generator/tsdown
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { WorkspaceTypertGenerator } from './workspace.ts'
|
||||
import type { WorkspaceEmitResult } from './workspace.ts'
|
||||
|
||||
/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
|
||||
interface TypertPlugin {
|
||||
name: string
|
||||
writeBundle: (options: { dir?: string }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the typert generation plugin for the root tsdown config.
|
||||
* @returns a rolldown-compatible plugin that emits `lib/typert.<face>.js` and `.d.ts` for contributing packages.
|
||||
*/
|
||||
export function typertPlugin(): TypertPlugin {
|
||||
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
|
||||
return {
|
||||
name: 'dsh-typert-generator',
|
||||
writeBundle(options) {
|
||||
// options.dir is the package's absolute outDir (<package>/lib); its
|
||||
// nearest package.json owns the bundle even when a custom config writes
|
||||
// a nested output such as <package>/lib/dev.
|
||||
if (options.dir === undefined) return
|
||||
const root = workspaceRoot(options.dir)
|
||||
const packageDir = packageRoot(options.dir, root)
|
||||
if (packageDir === undefined) return
|
||||
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
name?: string
|
||||
exports?: unknown
|
||||
}
|
||||
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
|
||||
let artifacts = artifactsByRoot.get(root)
|
||||
if (artifacts === undefined) {
|
||||
artifacts = new WorkspaceTypertGenerator(root).generate()
|
||||
artifactsByRoot.set(root, artifacts)
|
||||
}
|
||||
const output = join(packageDir, 'lib')
|
||||
mkdirSync(output, { recursive: true })
|
||||
for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) {
|
||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hasTypertExport(exportsField: unknown): boolean {
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert')
|
||||
}
|
||||
|
||||
function packageRoot(start: string, workspace: string): string | undefined {
|
||||
let current = resolve(start)
|
||||
while (current !== workspace) {
|
||||
if (existsSync(join(current, 'package.json'))) return current
|
||||
current = dirname(current)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function workspaceRoot(start: string): string {
|
||||
let current = resolve(start)
|
||||
while (!existsSync(join(current, 'tsconfig.host.json'))) {
|
||||
const parent = dirname(current)
|
||||
if (parent === current) throw new Error(`typert-generator: cannot find workspace root above ${start}`)
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
90
packages/typert/generator/src/workspace.ts
Normal file
90
packages/typert/generator/src/workspace.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Workspace-level discovery and model-driven Typert generation.
|
||||
* @module @deepseek-ai/dsh-typert-generator/workspace
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import type { DiscoveredTypertPackage } from './analyzer.ts'
|
||||
import { FaceModelEmitter } from './emitter.ts'
|
||||
import type { ModelEmitResult } from './emitter.ts'
|
||||
|
||||
/** One emitted artifact paired with its source package root. */
|
||||
export interface WorkspaceEmitResult extends ModelEmitResult {
|
||||
readonly packageRoot: string
|
||||
}
|
||||
|
||||
/** Discover, analyze, and emit package reflection from independent faces. */
|
||||
export class WorkspaceTypertGenerator {
|
||||
/**
|
||||
* Bind generation to one workspace root.
|
||||
* @param root - directory containing face aggregate tsconfigs.
|
||||
*/
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
/**
|
||||
* Find public package faces that contribute Cordis services/events or
|
||||
* explicitly tagged Typert roots.
|
||||
* @returns discovered packages in stable package-name order.
|
||||
*/
|
||||
discover(): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({ root: this.root }).discoverPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all discovered contributors, or an explicit package subset.
|
||||
* @param packages - optional exact package names for a focused pass.
|
||||
* @returns one artifact per package face.
|
||||
*/
|
||||
generate(packages?: readonly string[]): WorkspaceEmitResult[] {
|
||||
const selected = packages ?? this.discover().map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze()
|
||||
const artifacts: WorkspaceEmitResult[] = []
|
||||
for (const face of workspace.faces) {
|
||||
const emitter = new FaceModelEmitter(face)
|
||||
for (const packageModel of face.packages) {
|
||||
const artifact = {
|
||||
...emitter.emit(packageModel.name),
|
||||
packageRoot: packageModel.root,
|
||||
}
|
||||
this.validateExport(artifact)
|
||||
artifacts.push(artifact)
|
||||
}
|
||||
}
|
||||
return artifacts
|
||||
}
|
||||
|
||||
private validateExport(artifact: WorkspaceEmitResult): void {
|
||||
const manifestPath = resolve(this.root, artifact.packageRoot, 'package.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
||||
exports?: unknown
|
||||
files?: unknown
|
||||
}
|
||||
const subpath = artifact.face === 'host' ? './typert' : './client/typert'
|
||||
const expected = {
|
||||
types: `./lib/typert.${artifact.face}.d.ts`,
|
||||
default: `./lib/typert.${artifact.face}.js`,
|
||||
}
|
||||
const actual = manifest.exports !== null && typeof manifest.exports === 'object'
|
||||
? (manifest.exports as Record<string, unknown>)[subpath]
|
||||
: undefined
|
||||
if (!sameExport(actual, expected)) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(${artifact.face}): ${artifact.package} must export ${subpath} as ${JSON.stringify(expected)}`,
|
||||
)
|
||||
}
|
||||
const files = Array.isArray(manifest.files) ? manifest.files : []
|
||||
for (const file of [`lib/typert.${artifact.face}.js`, `lib/typert.${artifact.face}.d.ts`]) {
|
||||
if (!files.includes(file)) {
|
||||
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameExport(actual: unknown, expected: { types: string; default: string }): boolean {
|
||||
if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false
|
||||
const value = actual as Record<string, unknown>
|
||||
return value.types === expected.types && value.default === expected.default
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Contract and negative-path tests for the cordis catalog generator
|
||||
* Model-extraction and negative-path contracts for the Cordis catalog generator
|
||||
* (`scripts/gen-cordis-catalog.ts`).
|
||||
*/
|
||||
|
||||
@@ -7,16 +7,91 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
import {
|
||||
collectEvents as collectEventsWithPolicy,
|
||||
collectServices as collectServicesWithPolicy,
|
||||
renderEvents as renderEventsWithPolicy,
|
||||
renderServices as renderServicesWithPolicy,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
import type {
|
||||
CordisCatalogPolicy,
|
||||
EventEntry,
|
||||
ServiceEntry,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
|
||||
const TEST_POLICY: CordisCatalogPolicy = {
|
||||
linkedTypePages: { SessionEvent: 'core.md' },
|
||||
foundationTypeNames: new Set(['AbortSignal', 'Promise', 'Readonly']),
|
||||
typeLinkExemptions: { PresetSpec: 'fixture deployment metadata' },
|
||||
inheritedEvents: [],
|
||||
inheritedServices: [],
|
||||
}
|
||||
|
||||
function collectEvents(root: string): EventEntry[] {
|
||||
return collectEventsWithPolicy(root, TEST_POLICY)
|
||||
}
|
||||
|
||||
function collectServices(root: string): ServiceEntry[] {
|
||||
return collectServicesWithPolicy(root, TEST_POLICY)
|
||||
}
|
||||
|
||||
function renderEvents(events: EventEntry[]): string {
|
||||
return renderEventsWithPolicy(events, TEST_POLICY)
|
||||
}
|
||||
|
||||
function renderServices(services: ServiceEntry[]): string {
|
||||
return renderServicesWithPolicy(services, TEST_POLICY)
|
||||
}
|
||||
|
||||
const TYPE_FIXTURES = [
|
||||
'export interface FixtureEntry {}',
|
||||
'interface SessionEvent {}',
|
||||
'interface PresetSpec {}',
|
||||
'interface MissingOne {}',
|
||||
'type missingTwo = string',
|
||||
'interface MissingServiceType {}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
/** Materialize one independently compilable package and its host aggregate. */
|
||||
function writeProject(root: string, source: string): void {
|
||||
const packageRoot = join(root, 'packages', 'group', 'fix')
|
||||
const sourceRoot = join(packageRoot, 'src')
|
||||
mkdirSync(sourceRoot, { recursive: true })
|
||||
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: './packages/group/fix' }],
|
||||
}))
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@fixture/fix',
|
||||
private: true,
|
||||
type: 'module',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './lib/types/index.d.ts',
|
||||
default: './lib/index.js',
|
||||
},
|
||||
},
|
||||
}))
|
||||
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
rootDir: 'src',
|
||||
target: 'ES2022',
|
||||
},
|
||||
include: ['src'],
|
||||
}))
|
||||
writeFileSync(join(sourceRoot, 'index.ts'), `${TYPE_FIXTURES}${source}`)
|
||||
}
|
||||
|
||||
/** Write a fixture package exposing one `interface Events` block and return the
|
||||
* scan root to hand `collectEvents`. */
|
||||
function fixtureRoot(eventsBlock: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
writeProject(
|
||||
root,
|
||||
`declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`,
|
||||
)
|
||||
return root
|
||||
@@ -27,10 +102,8 @@ function fixtureRoot(eventsBlock: string): string {
|
||||
* `collectServices`. */
|
||||
function serviceFixtureRoot(classSource: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
writeProject(
|
||||
root,
|
||||
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
|
||||
)
|
||||
return root
|
||||
@@ -95,9 +168,9 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
'fix/two',
|
||||
'packages/group/fix/src/index.ts',
|
||||
'missingTwo',
|
||||
'Add it to LINK_MAP',
|
||||
'FOUNDATION_TYPE_NAMES',
|
||||
'TYPE_LINK_EXEMPTIONS',
|
||||
'Add it to linkedTypePages',
|
||||
'foundationTypeNames',
|
||||
'typeLinkExemptions',
|
||||
].join('[\\s\\S]*'))
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * First.\n * @param value - first value.\n * @mode emit\n */\n \'fix/one\'(value: MissingOne): void\n /**\n * Second.\n * @param value - second value.\n * @mode emit\n */\n \'fix/two\'(value: missingTwo): void',
|
||||
@@ -222,7 +295,7 @@ export class FixService {
|
||||
it('hard-errors on an unannotated (inferred) return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
|
||||
))).toThrow(/no return type annotation/)
|
||||
))).toThrow(/missing an explicit type annotation/)
|
||||
})
|
||||
|
||||
it('hard-errors on a service class with no JSDoc', () => {
|
||||
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
projectCordisCatalog,
|
||||
renderEvents,
|
||||
renderServices,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
import { CORDIS_CATALOG_POLICY } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
|
||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||
|
||||
describe('Typert-backed Cordis catalog', () => {
|
||||
it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => {
|
||||
const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)
|
||||
const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8')
|
||||
|
||||
expect(renderEvents([...model.events], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/events.md'))
|
||||
expect(renderServices([...model.services], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/services.md'))
|
||||
expect(projector.renderRuntimeApi(model)).toBe(
|
||||
expected('packages/cordis/tool-cordis/src/api-catalog.ts'),
|
||||
)
|
||||
})
|
||||
})
|
||||
7
packages/typert/generator/tests/fixtures/type-model/cordis.d.ts
vendored
Normal file
7
packages/typert/generator/tests/fixtures/type-model/cordis.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module 'cordis' {
|
||||
export class Service { protected readonly __service?: never }
|
||||
|
||||
export interface Context {}
|
||||
|
||||
export interface Events {}
|
||||
}
|
||||
5
packages/typert/generator/tests/fixtures/type-model/package.json
vendored
Normal file
5
packages/typert/generator/tests/fixtures/type-model/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@fixture/workspace",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
19
packages/typert/generator/tests/fixtures/type-model/packages/client/package.json
vendored
Normal file
19
packages/typert/generator/tests/fixtures/type-model/packages/client/package.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@fixture/client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client/typert": {
|
||||
"types": "./lib/typert.client.d.ts",
|
||||
"default": "./lib/typert.client.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/typert.client.js",
|
||||
"lib/typert.client.d.ts"
|
||||
]
|
||||
}
|
||||
39
packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts
vendored
Normal file
39
packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Service } from 'cordis'
|
||||
import type HostDefault from '@fixture/host'
|
||||
import type * as Host from '@fixture/host'
|
||||
import type { AgentPhase } from '@fixture/host'
|
||||
import type { HostAgent, Payload } from '@fixture/host'
|
||||
|
||||
export type { Box as ReexportedBox } from '@fixture/host'
|
||||
export type { ZodType as ReexportedZodType } from 'zod'
|
||||
|
||||
/** Client-owned inheritance preserves an explicit generic cross-face edge. */
|
||||
export interface ClientAgent extends HostAgent<{ ready: true }> {}
|
||||
|
||||
/** Client-owned view with explicit references to host exports. */
|
||||
export interface ClientView {
|
||||
readonly agent: HostAgent<{ ready: true }>
|
||||
readonly inherited: ClientAgent
|
||||
readonly importedAgent: import('@fixture/host').Agent<{ ready: true }>
|
||||
readonly importedAgentWithNamedArgument: import('@fixture/host').Agent<Payload>
|
||||
readonly namespaceAgent: Host.Agent<{ ready: true }>
|
||||
readonly defaultService: HostDefault
|
||||
readonly payload: Payload
|
||||
readonly phase: AgentPhase
|
||||
}
|
||||
|
||||
/** Client-face service. */
|
||||
export class ClientBridge extends Service {
|
||||
/** Return the host-owned object unchanged. */
|
||||
reflect(view: ClientView): HostAgent<{ ready: true }> {
|
||||
return view.agent
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
clientBridge: ClientBridge
|
||||
}
|
||||
}
|
||||
|
||||
export default ClientBridge
|
||||
11
packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/type-model/packages/client/tsconfig.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../host" }
|
||||
]
|
||||
}
|
||||
23
packages/typert/generator/tests/fixtures/type-model/packages/host/package.json
vendored
Normal file
23
packages/typert/generator/tests/fixtures/type-model/packages/host/package.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@fixture/host",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./lib/types/models.d.ts",
|
||||
"default": "./lib/models.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts"
|
||||
]
|
||||
}
|
||||
143
packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts
vendored
Normal file
143
packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Service } from 'cordis'
|
||||
import type { ZodType } from 'zod'
|
||||
import type { AgentPhase, Box, Entity, Flags, Payload, Present, SyntaxZoo } from './models.ts'
|
||||
|
||||
export { AgentPhase } from './models.ts'
|
||||
export type { Box, Entity, Flags, Payload, Present } from './models.ts'
|
||||
|
||||
/**
|
||||
* Reference-passed capability object.
|
||||
* @typert object
|
||||
*/
|
||||
export class Agent<State extends object = { ready: boolean }> implements Entity {
|
||||
static {}
|
||||
static readonly kind: string = 'agent'
|
||||
readonly id: string
|
||||
state: State
|
||||
protected readonly generation: number = 1
|
||||
private readonly secret: string = 'fixture'
|
||||
|
||||
constructor(id: string, state: State) {
|
||||
this.id = id
|
||||
this.state = state
|
||||
}
|
||||
|
||||
/** Read the public display label. */
|
||||
get label(): string {
|
||||
return this.id
|
||||
}
|
||||
|
||||
/** Accept a public display label. */
|
||||
set label(value: string) {
|
||||
void value
|
||||
}
|
||||
|
||||
/** Run one typed input. */
|
||||
run<Value>(input: Box<Value>): Promise<Present<Value>> {
|
||||
return Promise.resolve(input.value as Present<Value>)
|
||||
}
|
||||
}
|
||||
|
||||
export { Agent as HostAgent }
|
||||
|
||||
/** Service exported only through a non-default alias. */
|
||||
class AliasedService extends Service {
|
||||
/** Report readiness. */
|
||||
ready(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export { AliasedService as PublicAliasedService }
|
||||
|
||||
/** Service exported only through the package default. */
|
||||
class DefaultOnlyService extends Service {
|
||||
/** Report readiness. */
|
||||
ready(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixture service with generic, mapped, and truly external boundary types. */
|
||||
export class DemoService extends Service {
|
||||
static readonly kind: string = 'demo'
|
||||
protected readonly generation: number = 1
|
||||
private readonly secret: string = 'fixture'
|
||||
|
||||
/** Inspect one agent without flattening its generic state. */
|
||||
inspect(agent: Agent<{ ready: true }>, flags: Flags<Payload>): Present<Payload> {
|
||||
return { name: agent.id, count: Object.keys(flags).length }
|
||||
}
|
||||
|
||||
/** Keep an npm-owned type as External. */
|
||||
acceptsExternal(schema: ZodType<string>): void {
|
||||
void schema
|
||||
}
|
||||
|
||||
/** Accept a developer-authored enum without flattening it. */
|
||||
setPhase(phase: AgentPhase): void {
|
||||
void phase
|
||||
}
|
||||
|
||||
/** Exercise every retained type-graph shape from a public boundary. */
|
||||
inspectSyntax(zoo: SyntaxZoo): void {
|
||||
void zoo
|
||||
}
|
||||
|
||||
/** Preserve async source metadata without changing its type signature. */
|
||||
async inspectAsync(zoo: SyntaxZoo): Promise<void> {
|
||||
void zoo
|
||||
}
|
||||
|
||||
/** Retain an authored binding-pattern parameter. */
|
||||
destructure({ name }: Payload, [suffix]: [string]): string {
|
||||
return name + suffix
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
demo: DemoService
|
||||
aliased: AliasedService
|
||||
defaultOnly: DefaultOnlyService
|
||||
ignoredInline: {}
|
||||
ignoredPrimitive: string
|
||||
ignoredExternal: ZodType<string>
|
||||
ignoredMethod(): void
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A generic fixture event.
|
||||
* @param agent - emitting agent.
|
||||
* @param payload - event payload.
|
||||
* @mode emit
|
||||
*/
|
||||
'demo/ready'(agent: Agent<{ ready: true }>, payload: Box<Payload>): void
|
||||
|
||||
'demo/unmodeled'(): void
|
||||
|
||||
'demo/property': (payload: Payload) => void
|
||||
|
||||
/** @mode serial */
|
||||
'demo/serial-property': (payload: Payload) => void
|
||||
|
||||
(payload: Payload): void
|
||||
}
|
||||
|
||||
interface IgnoredInterface {}
|
||||
|
||||
type IgnoredDeclaration = string
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
demo: DemoService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
'demo/ready'(agent: Agent<{ ready: true }>, payload: Box<Payload>): void
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultOnlyService
|
||||
160
packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts
vendored
Normal file
160
packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
/** Generic source form retained before conditional evaluation. */
|
||||
export interface Box<T> {
|
||||
/** The boxed value. */
|
||||
readonly value: T
|
||||
}
|
||||
|
||||
/** Conditional source form retained instead of its resolved instantiations. */
|
||||
export type Present<T> = T extends null | undefined ? never : T
|
||||
|
||||
/** Mapped source form retained instead of materialized properties. */
|
||||
export type Flags<T> = {
|
||||
readonly [K in keyof T]?: boolean
|
||||
}
|
||||
|
||||
/** Explicit base edge for reference-passed objects. */
|
||||
export interface Entity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/** Developer-authored enum retained as a declaration. */
|
||||
export enum AgentPhase {
|
||||
Unknown,
|
||||
Idle = 'idle',
|
||||
Running = 'running',
|
||||
}
|
||||
|
||||
/** Runtime-validating data root. @typert schema */
|
||||
export interface Payload {
|
||||
name: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Signature members represented without flattening their callable forms. */
|
||||
export interface Callable {
|
||||
(value: string): number
|
||||
new (value: string): Entity
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
/** Input, output, and invariant parameters retain authored variance. */
|
||||
export interface Variance<in Input, out Output, in out State> {
|
||||
consume: (input: Input) => void
|
||||
readonly produce: () => Output
|
||||
state: State
|
||||
}
|
||||
|
||||
/** Infer form nested inside a conditional type. */
|
||||
export type Result<Value> = Value extends (...arguments_: never[]) => infer Output ? Output : never
|
||||
|
||||
/** Constrained infer form retained before conditional evaluation. */
|
||||
export type StringResult<Value> = Value extends readonly [infer Output extends string] ? Output : never
|
||||
|
||||
/** Template-literal source form. */
|
||||
export type Topic<Name extends string> = `demo/${Name}`
|
||||
|
||||
/** Multiple template spans retain each authored suffix. */
|
||||
export type Route<From extends string, To extends string> = `/${From}/to/${To}/end`
|
||||
|
||||
/** Preserve mapped modifiers when none were authored. */
|
||||
export type PlainMap<Value> = {
|
||||
[Key in keyof Value]: Value[Key]
|
||||
}
|
||||
|
||||
/** Retain key remapping and explicit modifier removal. */
|
||||
export type Remapped<Value> = {
|
||||
-readonly [Key in keyof Value as `get${Capitalize<string & Key>}`]-?: Value[Key]
|
||||
}
|
||||
|
||||
/** Retain explicit mapped modifier addition. */
|
||||
export type Added<Value> = {
|
||||
+readonly [Key in keyof Value]+?: Value[Key]
|
||||
}
|
||||
|
||||
/** Value used by a type query and indexed access. */
|
||||
export const phaseOrder = ['idle', 'running'] as const
|
||||
|
||||
/** Generic value used by an instantiated type query. */
|
||||
export declare function genericFactory<Value>(): Value
|
||||
|
||||
/** Predicates and the polymorphic this type remain signatures. */
|
||||
export interface Guards {
|
||||
isEntity(value: unknown): value is Entity
|
||||
isFluent(): this is Guards
|
||||
assertEntity(value: unknown): asserts value is Entity
|
||||
assertPresent(value: unknown): asserts value
|
||||
fluent(): this
|
||||
}
|
||||
|
||||
/** Abstract declarations remain distinct from concrete classes. */
|
||||
export abstract class AbstractEntity implements Entity {
|
||||
abstract readonly id: string
|
||||
}
|
||||
|
||||
/** Recursive declaration edges retain their declaration target. */
|
||||
export interface Recursive extends Box<string> {
|
||||
readonly next?: Recursive
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface TagOnly {
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** Description without terminal punctuation */
|
||||
export interface Unpunctuated {
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** Every supported TypeNode shape is reachable from this declaration. */
|
||||
export interface SyntaxZoo {
|
||||
anyValue: any
|
||||
bigintValue: bigint
|
||||
parenthesized: (Entity | null)
|
||||
literals: 1 | 1n | -2 | -2n | false | `fixed`
|
||||
readonly uniqueToken: unique symbol
|
||||
intersection: Entity & { active: boolean }
|
||||
array: string[]
|
||||
tuple: [head: string, count?: number, ...tail: boolean[]]
|
||||
unnamedTuple: [string?, ...number[]]
|
||||
readonlyTuple: readonly [string, number]
|
||||
object: {
|
||||
readonly value?: string
|
||||
'quoted-name': number
|
||||
1: boolean
|
||||
['computed']: symbol
|
||||
invoke?(input: number): void
|
||||
}
|
||||
callback: <Value extends Entity = Entity>(
|
||||
this: Entity,
|
||||
value: Value,
|
||||
optional?: string,
|
||||
...rest: number[]
|
||||
) => Promise<Value>
|
||||
constCallback: <const Value extends readonly string[]>(value: Value) => Value
|
||||
factory: new <Value extends Entity>(value: Value) => Value
|
||||
abstractFactory: abstract new (id: string) => AbstractEntity
|
||||
indexed: Payload['name']
|
||||
inferred: Result<() => string>
|
||||
constrainedInfer: StringResult<['value']>
|
||||
topic: Topic<'ready'>
|
||||
route: Route<'source', 'target'>
|
||||
query: typeof phaseOrder
|
||||
instantiatedQuery: typeof genericFactory<string>
|
||||
imported: import('zod').ZodType<string>
|
||||
importedWith: import('zod', { with: { 'resolution-mode': 'import' } }).ZodType<string>
|
||||
importedModule: typeof import('zod')
|
||||
process: NodeJS.Process
|
||||
callable: Callable
|
||||
guards: Guards
|
||||
variance: Variance<Entity, Payload, Box<string>>
|
||||
plainMap: PlainMap<Payload>
|
||||
remapped: Remapped<Payload>
|
||||
added: Added<Payload>
|
||||
abstractEntity: AbstractEntity
|
||||
recursive: Recursive
|
||||
tagOnly: TagOnly
|
||||
unpunctuated: Unpunctuated
|
||||
}
|
||||
8
packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/type-model/packages/host/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/typert/generator/tests/fixtures/type-model/packages/write/package.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/type-model/packages/write/package.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@fixture/write",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts
vendored
Normal file
18
packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Service } from 'cordis'
|
||||
|
||||
/** Service whose public annotations are intentionally absent. */
|
||||
export class WritableService extends Service {
|
||||
value = 1
|
||||
|
||||
echo(input = 'value') {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
writable: WritableService
|
||||
}
|
||||
}
|
||||
|
||||
export default WritableService
|
||||
8
packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/type-model/packages/write/tsconfig.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json
vendored
Normal file
22
packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"allowImportingTsExtensions": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"cordis": ["./cordis.d.ts"],
|
||||
"@fixture/host": ["./packages/host/src/index.ts"],
|
||||
"@fixture/host/*": ["./packages/host/src/*"],
|
||||
"@fixture/client": ["./packages/client/src/index.ts"],
|
||||
"@fixture/write": ["./packages/write/src/index.ts"]
|
||||
},
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user