mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
214
scripts/gen-client-catalog.spec.ts
Normal file
214
scripts/gen-client-catalog.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The client slot catalog's judgement, proven on hand-built inputs: the
|
||||
* contract checks that must reject an unteachable slot, and the projection
|
||||
* facts a registrant depends on (who occupies a seat, what replacing it costs,
|
||||
* which owner has to be mounted). Run against the real workspace, the
|
||||
* generator's own `--check` covers freshness; these cases pin the rules that
|
||||
* make a stale or undocumented contract fail loudly instead of shipping.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectSlotEntries, oversizedSlotReports, resolveSlotEntries, validateSlotContracts } from './gen-client-catalog.ts'
|
||||
import type { SlotDeclaration, SlotRegistration, TypeDeclaration } from './slot-walk.ts'
|
||||
|
||||
/** A declaration with every field the catalog needs, overridable per case. */
|
||||
function declaration(over: Partial<SlotDeclaration> = {}): SlotDeclaration {
|
||||
return {
|
||||
key: 'demo.seat',
|
||||
kind: 'single',
|
||||
scope: 'root',
|
||||
jsDoc: '/** A seat. Registering here replaces the shipped entry. */',
|
||||
package: '@deepseek-ai/dsh-client-demo',
|
||||
source: 'packages/client/demo/src/client/contract/slots.ts:1',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** A registration into `demo.seat`, overridable per case. */
|
||||
function registration(over: Partial<SlotRegistration> = {}): SlotRegistration {
|
||||
return {
|
||||
key: 'demo.seat',
|
||||
package: '@deepseek-ai/dsh-client-demo',
|
||||
component: 'DemoSeat',
|
||||
children: [],
|
||||
source: 'packages/client/demo/src/client/index.ts:10',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** An exported owner-props declaration the catalog can resolve. */
|
||||
const OWNER_TYPES = new Map<string, TypeDeclaration>([
|
||||
['DemoOwnerProps', {
|
||||
name: 'DemoOwnerProps',
|
||||
text: '/** Owner share. */\nexport interface DemoOwnerProps {\n /** Column width. */\n width: number\n}',
|
||||
source: 'packages/client/demo/src/client/contract/slots.ts:20',
|
||||
}],
|
||||
])
|
||||
|
||||
describe('client slot contract validation', () => {
|
||||
it('accepts a documented slot whose owner props resolve', () => {
|
||||
expect(validateSlotContracts(
|
||||
[declaration({ ownerType: 'DemoOwnerProps' })],
|
||||
[registration()],
|
||||
OWNER_TYPES,
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a slot with no registrant-facing prose, naming the writing template', () => {
|
||||
const problems = validateSlotContracts([declaration({ jsDoc: '' })], [], new Map())
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain('has no JSDoc prose')
|
||||
expect(problems[0]).toContain('ui-settings')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['kind', { kind: 'whatever' }],
|
||||
['scope', { scope: 'whatever' }],
|
||||
])('rejects a slot whose %s is not one of the contract literals', (field, over) => {
|
||||
const problems = validateSlotContracts([declaration(over)], [], new Map())
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain(`no literal '${field}'`)
|
||||
})
|
||||
|
||||
it('rejects owner props no exported declaration provides', () => {
|
||||
const problems = validateSlotContracts([declaration({ ownerType: 'MissingProps' })], [], new Map())
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain('MissingProps')
|
||||
})
|
||||
|
||||
it('rejects the same key declared twice, because a merge would hide one contract', () => {
|
||||
const problems = validateSlotContracts(
|
||||
[declaration(), declaration({ source: 'packages/client/other/src/client/slots.ts:3' })],
|
||||
[],
|
||||
new Map(),
|
||||
)
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain('is also declared at')
|
||||
})
|
||||
|
||||
it('rejects a registration into an undeclared slot as a scan blind spot', () => {
|
||||
const problems = validateSlotContracts([declaration()], [registration({ key: 'ghost.seat' })], new Map())
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain('blind spot')
|
||||
})
|
||||
|
||||
it('rejects a children declaration for a slot no merge types', () => {
|
||||
const problems = validateSlotContracts([declaration()], [registration({ children: ['ghost.child'] })], new Map())
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain("child slot 'ghost.child'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('client slot projection', () => {
|
||||
const kits = new Map<string, readonly string[]>([['root', ['useSessions: Hook']]])
|
||||
|
||||
it('warns that a single seat with a shipped occupant is replaced, not shared', () => {
|
||||
const [entry] = resolveSlotEntries([declaration()], [registration()], OWNER_TYPES, kits)
|
||||
expect(entry?.replaceRisk).toBe('shadows-shipped-ui')
|
||||
expect(entry?.occupants).toEqual(['client-demo DemoSeat'])
|
||||
})
|
||||
|
||||
it('treats a list seat as additive even when shipped entries exist', () => {
|
||||
const [entry] = resolveSlotEntries(
|
||||
[declaration({ kind: 'list' })],
|
||||
[registration({ id: 'shipped' })],
|
||||
OWNER_TYPES,
|
||||
kits,
|
||||
)
|
||||
expect(entry?.replaceRisk).toBe('none')
|
||||
expect(entry?.occupants).toEqual(["client-demo DemoSeat id 'shipped'"])
|
||||
expect(entry?.registerOptions.map(option => option.name)).toEqual(['id', 'order', 'label'])
|
||||
})
|
||||
|
||||
it('names the entry whose mount makes a child seat exist', () => {
|
||||
const parent = registration({ key: 'demo.parent', children: ['demo.seat'] })
|
||||
const entries = resolveSlotEntries(
|
||||
[declaration(), declaration({ key: 'demo.parent' })],
|
||||
[parent],
|
||||
OWNER_TYPES,
|
||||
kits,
|
||||
)
|
||||
expect(entries.find(entry => entry.key === 'demo.seat')?.declaredBy)
|
||||
.toContain("an entry in 'demo.parent' (client-demo)")
|
||||
expect(entries.find(entry => entry.key === 'demo.parent')?.declaredBy)
|
||||
.toContain('built in')
|
||||
})
|
||||
|
||||
it('reports an open keyed domain and the keys already taken', () => {
|
||||
const [entry] = resolveSlotEntries(
|
||||
[declaration({ kind: 'keyed' })],
|
||||
[registration({ entryKey: 'bash' }), registration({ entryKey: 'read' })],
|
||||
OWNER_TYPES,
|
||||
kits,
|
||||
)
|
||||
expect(entry?.keyDomain).toContain('open: any string')
|
||||
expect(entry?.keyDomain).toContain('already taken: bash, read')
|
||||
})
|
||||
|
||||
it('carries owner-props documentation into the entry, not just the type name', () => {
|
||||
const [entry] = resolveSlotEntries([declaration({ ownerType: 'DemoOwnerProps' })], [], OWNER_TYPES, kits)
|
||||
expect(entry?.ownerProps.join('\n')).toContain('Column width.')
|
||||
})
|
||||
|
||||
it('expands owner props one level and only names the shapes they reference', () => {
|
||||
// Transitive expansion once dragged the whole session model into four
|
||||
// seats; a registrant needs the fields, not the graph behind them.
|
||||
const types = new Map(OWNER_TYPES)
|
||||
types.set('Zone', {
|
||||
name: 'Zone',
|
||||
text: 'export interface Zone {\n session: BigSnapshot\n}',
|
||||
source: 'packages/client/demo/src/client/contract/slots.ts:30',
|
||||
})
|
||||
types.set('BigSnapshot', {
|
||||
name: 'BigSnapshot',
|
||||
text: 'export interface BigSnapshot {\n turns: number\n}',
|
||||
source: 'packages/client/demo/src/client/snapshot.ts:1',
|
||||
})
|
||||
const [entry] = resolveSlotEntries([declaration({ ownerType: 'Zone' })], [], types, kits)
|
||||
expect(entry?.ownerProps.join('\n')).toContain('export interface Zone')
|
||||
expect(entry?.ownerProps.join('\n')).not.toContain('export interface BigSnapshot')
|
||||
expect(entry?.ownerPropsReferences).toEqual(['BigSnapshot'])
|
||||
})
|
||||
|
||||
it('offers a runnable registration whose options match the cardinality', () => {
|
||||
const [entry] = resolveSlotEntries([declaration({ kind: 'list' })], [], OWNER_TYPES, kits)
|
||||
expect(entry?.example).toContain("ctx.slots.inject('demo.seat'")
|
||||
expect(entry?.example).toContain("id: 'my-entry'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('the per-slot report budget', () => {
|
||||
it('rejects a slot whose report a model could not finish reading', () => {
|
||||
// Truncation already bounds one declaration, so the remaining runaway is
|
||||
// prose: a contract that grew into a manual costs exactly what narrowing to
|
||||
// one slot was supposed to save.
|
||||
const manual = ['/**', ...Array.from({ length: 150 }, (_, i) => ` * Paragraph ${String(i)} about this seat.`), ' */']
|
||||
const entries = resolveSlotEntries([declaration({ jsDoc: manual.join('\n') })], [], OWNER_TYPES, new Map())
|
||||
const problems = oversizedSlotReports(entries)
|
||||
expect(problems).toHaveLength(1)
|
||||
expect(problems[0]).toContain("slot 'demo.seat'")
|
||||
expect(problems[0]).toContain('tighten')
|
||||
})
|
||||
|
||||
it('passes a slot whose report stays within the budget', () => {
|
||||
const entries = resolveSlotEntries([declaration({ ownerType: 'DemoOwnerProps' })], [], OWNER_TYPES, new Map())
|
||||
expect(oversizedSlotReports(entries)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the real workspace surface', () => {
|
||||
it('collects every declared slot with a teachable contract', () => {
|
||||
const entries = collectSlotEntries(process.cwd())
|
||||
expect(entries.length).toBeGreaterThan(30)
|
||||
for (const entry of entries) {
|
||||
expect(entry.summary, `${entry.key} has no summary`).not.toBe('')
|
||||
expect(['single', 'list', 'keyed', 'chain']).toContain(entry.kind)
|
||||
expect(['root', 'session', 'session-maybe']).toContain(entry.scope)
|
||||
}
|
||||
// The frame root is the canonical trap: occupied by the shipped app frame,
|
||||
// so a dynamic package registering there replaces the whole UI.
|
||||
const root = entries.find(entry => entry.key === 'root')
|
||||
expect(root?.replaceRisk).toBe('shadows-shipped-ui')
|
||||
expect(root?.occupants.join(' ')).toContain('AppFrame')
|
||||
})
|
||||
})
|
||||
558
scripts/gen-client-catalog.ts
Normal file
558
scripts/gen-client-catalog.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Generate the model-facing client slot catalog consumed by `cordis_inspect
|
||||
* what:"client"`. A dynamic package's browser half can only contribute UI
|
||||
* through `ctx.slots.register`, and every fact it needs to do that safely —
|
||||
* which keys exist, what each register call must pass, what the component
|
||||
* receives, who already occupies the seat, and when the seat exists at all —
|
||||
* is decided at compile time by the shipped web bundle. This generator reads
|
||||
* those facts lexically (no type-checker program) and emits them as a data
|
||||
* module inside `tool-cordis`, so the host-side toolset teaches the browser
|
||||
* surface without importing a single client runtime module.
|
||||
*
|
||||
* `--check` verifies the committed artifact is fresh.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
declaredTypes,
|
||||
indexExportedTypes,
|
||||
referencedTypeNames,
|
||||
scanSlotFiles,
|
||||
slotDeclarations,
|
||||
slotRegistrations,
|
||||
standardKitMembers,
|
||||
} from './slot-walk.ts'
|
||||
import type { ScannedFile, SlotDeclaration, SlotRegistration, TypeDeclaration } from './slot-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'packages/extensions/cordis-client-runner/src/client/slot-catalog.ts'
|
||||
|
||||
/** Source globs: every workspace package's sources, `.tsx` included (a contract may live in one). */
|
||||
const SOURCE_GLOBS = ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx']
|
||||
|
||||
/** Slot cardinalities the contract allows. */
|
||||
const KINDS = ['single', 'list', 'keyed', 'chain'] as const
|
||||
/** Slot data scopes the contract allows. */
|
||||
const SCOPES = ['root', 'session', 'session-maybe'] as const
|
||||
|
||||
/** Declarations longer than this render truncated; the full shape stays in source. */
|
||||
const MAX_DECL_CHARS = 1200
|
||||
|
||||
/**
|
||||
* Line budget for ONE slot's expanded report. The whole point of narrowing to a
|
||||
* single slot is to spend less context, so a report a model cannot finish
|
||||
* reading is a defect rather than a detail. Today's widest slot renders 60
|
||||
* lines, so this leaves room to document a slot properly while catching the two
|
||||
* ways a report runs away: an owner share that hands down a subsystem instead of
|
||||
* a share, and prose that grew into a manual.
|
||||
*/
|
||||
const MAX_ENTRY_LINES = 120
|
||||
|
||||
/** One register-call option as the catalog teaches it. */
|
||||
interface OptionDoc {
|
||||
readonly name: string
|
||||
readonly requirement: 'required' | 'optional'
|
||||
readonly type: string
|
||||
readonly doc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register options per cardinality, curated from `KindOptions` in
|
||||
* `packages/client/ui-slots/src/index.ts` — the authority for what a register
|
||||
* call may pass. Curated rather than projected because the authority is a
|
||||
* conditional type keyed on the slot's kind: it has no per-kind declaration a
|
||||
* lexical scan could read, and its own JSDoc addresses the compiler, not a
|
||||
* registrant. `verify-client-catalog` pins the authority's text so a change
|
||||
* there forces this table to be revisited.
|
||||
*/
|
||||
const REGISTER_OPTIONS: Readonly<Record<(typeof KINDS)[number], readonly OptionDoc[]>> = {
|
||||
single: [],
|
||||
list: [
|
||||
{ name: 'id', requirement: 'required', type: 'string', doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.' },
|
||||
{ name: 'order', requirement: 'optional', type: 'number', doc: 'Position among the entries, ascending (default 0).' },
|
||||
{ name: 'label', requirement: 'optional', type: 'string | (() => string)', doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.' },
|
||||
],
|
||||
keyed: [
|
||||
{ name: 'key', requirement: 'required', type: 'string', doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.' },
|
||||
],
|
||||
chain: [
|
||||
{ name: 'select', requirement: 'required', type: '(owner) => unknown | null', doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.' },
|
||||
],
|
||||
}
|
||||
|
||||
/** The one register option a dynamic package must NOT pass, and why. */
|
||||
const PRIORITY_NOTE = 'Do NOT pass `priority`: the browser-half facade assigns one automatically, and it is LOWER than every shipped entry — in a single or keyed cell that means your entry is the one that renders.'
|
||||
|
||||
/** Cross-cutting rules a registrant needs once, not per slot. */
|
||||
const CLIENT_NOTES: readonly string[] = [
|
||||
'Contribute UI only through `ctx.slots.register(options, Component)`; declare `inject: [\'slots\']` in your returned plugin (object form) or the seat is withheld.',
|
||||
'Wrap every registration in `ctx.slots.inject(key, () => ctx.slots.register(...))`. A slot exists only while the entry that declared it is mounted, and registering into an undeclared slot throws; `inject` runs your registration when the declaration is (or becomes) live and re-runs it if the owner remounts.',
|
||||
PRIORITY_NOTE,
|
||||
'You cannot `import` anything, so the design-system components are out of reach: build markup with `React.createElement` and ship CSS through `styles.insert(css)`. Use the theme CSS variables (`var(--dsw-alias-bg-layer-1)`, `var(--dsw-alias-label-primary)`, …) instead of literal colors, or your contribution breaks in the other color scheme.',
|
||||
'Every component receives the framework hook seats listed under `framework props` for its scope; a selector hook is called with a selector, e.g. `useSessions(state => state.current)`.',
|
||||
'This catalog is the COMPILE-TIME contract of the shipped web bundle, not a snapshot of one page: a key is registrable only where the owner that declares it is mounted. A failed registration surfaces in the browser-half load report — read it back with `cordis_inspect what:"temporary"`.',
|
||||
]
|
||||
|
||||
/** Standard-kit interface that applies to each scope, beyond the global one. */
|
||||
const SCOPE_KIT: Readonly<Record<(typeof SCOPES)[number], string | undefined>> = {
|
||||
'root': undefined,
|
||||
'session': 'SessionStandardProps',
|
||||
'session-maybe': 'SessionMaybeStandardProps',
|
||||
}
|
||||
|
||||
/** One resolved catalog entry, ready to render. */
|
||||
export interface SlotEntry {
|
||||
readonly key: string
|
||||
readonly kind: string
|
||||
readonly scope: string
|
||||
readonly summary: string
|
||||
readonly doc: string
|
||||
readonly registerOptions: readonly OptionDoc[]
|
||||
readonly ownerProps: readonly string[]
|
||||
readonly ownerPropsReferences: readonly string[]
|
||||
readonly standardProps: readonly string[]
|
||||
readonly keyDomain: string
|
||||
readonly hookContext: string
|
||||
readonly slotInject: string
|
||||
readonly declaredBy: string
|
||||
readonly occupants: readonly string[]
|
||||
readonly replaceRisk: string
|
||||
readonly example: string
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace and resolve every catalog entry, failing loud on a
|
||||
* contract the catalog cannot teach.
|
||||
* @param scanRoot - repository root to scan.
|
||||
* @returns the entries, sorted by key.
|
||||
* @throws when any declared slot is unteachable or the scan contradicts itself.
|
||||
*/
|
||||
export function collectSlotEntries(scanRoot: string): SlotEntry[] {
|
||||
const files = scanSlotFiles(scanRoot, SOURCE_GLOBS)
|
||||
const declarations = files.flatMap(file => slotDeclarations(file))
|
||||
const registrations = files.flatMap(file => slotRegistrations(file))
|
||||
const types = indexExportedTypes(scanRoot, SOURCE_GLOBS)
|
||||
const problems = validateSlotContracts(declarations, registrations, types)
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`gen-client-catalog: ${String(problems.length)} contract violation(s):\n${problems.map(problem => ` ${problem}`).join('\n')}`)
|
||||
}
|
||||
const entries = resolveSlotEntries(declarations, registrations, types, standardKits(files))
|
||||
const oversized = oversizedSlotReports(entries)
|
||||
if (oversized.length > 0) {
|
||||
throw new Error(`gen-client-catalog: ${String(oversized.length)} slot(s) exceed the per-slot report budget `
|
||||
+ `of ${String(MAX_ENTRY_LINES)} lines:\n${oversized.map(problem => ` ${problem}`).join('\n')}`)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots whose expanded report exceeds {@link MAX_ENTRY_LINES}. Separated from
|
||||
* the scan so the budget is provable on one hand-built entry.
|
||||
* @param entries - resolved catalog entries.
|
||||
* @returns one message per over-budget slot, empty when every report is readable.
|
||||
*/
|
||||
export function oversizedSlotReports(entries: readonly SlotEntry[]): string[] {
|
||||
return entries
|
||||
.filter(entry => entryLines(entry) > MAX_ENTRY_LINES)
|
||||
.map(entry => `slot '${entry.key}' (${entry.source}) reports ${String(entryLines(entry))} lines. `
|
||||
+ 'Narrow the owner share it passes down (a slot hands a registrant a share, not a subsystem) or tighten '
|
||||
+ 'its prose, so asking about one slot stays cheaper than asking about all of them.')
|
||||
}
|
||||
|
||||
/** Line count of one entry's variable-length content, the proxy for its rendered report. */
|
||||
function entryLines(entry: SlotEntry): number {
|
||||
const blocks = [entry.doc, entry.example, ...entry.ownerProps, ...entry.registerOptions.map(option => option.doc)]
|
||||
return blocks.reduce((total, block) => total + block.split('\n').length, 0)
|
||||
+ entry.standardProps.length + entry.ownerPropsReferences.length + entry.occupants.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed contract checks: an unteachable slot must break the gate rather
|
||||
* than ship an entry a model cannot act on. Pure, so every rejection is
|
||||
* provable without scanning the workspace.
|
||||
* @param declarations - every declared slot.
|
||||
* @param registrations - every registration call site.
|
||||
* @param types - exported type index the owner-props reference resolves against.
|
||||
* @returns one message per violation, empty when the surface is teachable.
|
||||
*/
|
||||
export function validateSlotContracts(
|
||||
declarations: readonly SlotDeclaration[],
|
||||
registrations: readonly SlotRegistration[],
|
||||
types: ReadonlyMap<string, TypeDeclaration>,
|
||||
): string[] {
|
||||
const problems: string[] = []
|
||||
const byKey = new Map<string, SlotDeclaration>()
|
||||
for (const declaration of declarations) {
|
||||
const where = `slot '${declaration.key}' (${declaration.source})`
|
||||
const previous = byKey.get(declaration.key)
|
||||
if (previous !== undefined) {
|
||||
problems.push(`${where} is also declared at ${previous.source}; SlotMap merges duplicates silently, so the catalog cannot tell which documentation wins.`)
|
||||
continue
|
||||
}
|
||||
byKey.set(declaration.key, declaration)
|
||||
if (!(KINDS as readonly string[]).includes(declaration.kind)) {
|
||||
problems.push(`${where} has no literal 'kind'; the catalog derives the register options from it, so it must be one of ${KINDS.join('/')}.`)
|
||||
}
|
||||
if (!(SCOPES as readonly string[]).includes(declaration.scope)) {
|
||||
problems.push(`${where} has no literal 'scope'; the catalog derives the framework props from it, so it must be one of ${SCOPES.join('/')}.`)
|
||||
}
|
||||
if (docProse(declaration.jsDoc) === '') {
|
||||
problems.push(`${where} has no JSDoc prose. Write it from the REGISTRANT's side: what to pass, what the component receives, whom a registration replaces, and what absence looks like (packages/client/ui-settings/src/client/contract/slots.ts is the template).`)
|
||||
}
|
||||
if (declaration.ownerType !== undefined
|
||||
&& /^[A-Za-z_$][\w$]*$/.test(declaration.ownerType)
|
||||
&& !types.has(declaration.ownerType)) {
|
||||
problems.push(`${where} names owner props '${declaration.ownerType}' that no exported declaration provides; export the interface so the catalog can show what the component receives.`)
|
||||
}
|
||||
}
|
||||
for (const registration of registrations) {
|
||||
if (!byKey.has(registration.key)) {
|
||||
problems.push(`registration into '${registration.key}' (${registration.source}) targets a slot no SlotMap merge declares; either the scan has a blind spot or the registration is dead.`)
|
||||
}
|
||||
for (const child of registration.children) {
|
||||
if (!byKey.has(child)) {
|
||||
problems.push(`registration at ${registration.source} declares child slot '${child}' that no SlotMap merge types.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Project validated declarations into catalog entries: cardinality decides the
|
||||
* register options, scope decides the framework props, and the registration
|
||||
* call sites decide who already sits in the seat and which owner's mount makes
|
||||
* it exist. Pure, so the projection facts are provable without a workspace.
|
||||
* @param declarations - validated slot declarations.
|
||||
* @param registrations - every registration call site.
|
||||
* @param types - exported type index for owner-props expansion.
|
||||
* @param kits - framework prop seats per scope.
|
||||
* @returns the entries, sorted by key.
|
||||
*/
|
||||
export function resolveSlotEntries(
|
||||
declarations: readonly SlotDeclaration[],
|
||||
registrations: readonly SlotRegistration[],
|
||||
types: ReadonlyMap<string, TypeDeclaration>,
|
||||
kits: ReadonlyMap<string, readonly string[]>,
|
||||
): SlotEntry[] {
|
||||
const declaredBy = new Map<string, SlotRegistration>()
|
||||
for (const registration of registrations) {
|
||||
for (const child of registration.children) {
|
||||
if (!declaredBy.has(child)) declaredBy.set(child, registration)
|
||||
}
|
||||
}
|
||||
return declarations
|
||||
.map(declaration => entryOf(declaration, registrations, declaredBy.get(declaration.key), types, kits))
|
||||
.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
|
||||
/** The framework prop seats per scope, read from the merged standard-kit interfaces. */
|
||||
function standardKits(files: readonly ScannedFile[]): ReadonlyMap<string, readonly string[]> {
|
||||
const global = standardKitMembers(files, 'GlobalStandardProps')
|
||||
const kits = new Map<string, readonly string[]>()
|
||||
for (const scope of SCOPES) {
|
||||
const extra = SCOPE_KIT[scope]
|
||||
kits.set(scope, [...global, ...extra === undefined ? [] : standardKitMembers(files, extra)])
|
||||
}
|
||||
return kits
|
||||
}
|
||||
|
||||
/** Resolve one declaration into its catalog entry. */
|
||||
function entryOf(
|
||||
declaration: SlotDeclaration,
|
||||
registrations: readonly SlotRegistration[],
|
||||
declaredBy: SlotRegistration | undefined,
|
||||
types: ReadonlyMap<string, TypeDeclaration>,
|
||||
kits: ReadonlyMap<string, readonly string[]>,
|
||||
): SlotEntry {
|
||||
const occupants = registrations.filter(registration => registration.key === declaration.key)
|
||||
const cellOccupied = occupants.some(occupant =>
|
||||
declaration.kind === 'single' || occupant.entryKey !== undefined)
|
||||
const doc = docProse(declaration.jsDoc)
|
||||
const owner = ownerShapes(declaration.ownerType, types)
|
||||
return {
|
||||
key: declaration.key,
|
||||
kind: declaration.kind,
|
||||
scope: declaration.scope,
|
||||
summary: firstSentence(doc),
|
||||
doc,
|
||||
registerOptions: REGISTER_OPTIONS[declaration.kind as (typeof KINDS)[number]],
|
||||
ownerProps: owner.declarations.map(type => truncate(type.text)),
|
||||
ownerPropsReferences: owner.references,
|
||||
standardProps: kits.get(declaration.scope) ?? [],
|
||||
keyDomain: keyDomainOf(declaration, occupants),
|
||||
hookContext: declaration.hookContext ?? '',
|
||||
slotInject: declaration.injectType ?? '',
|
||||
declaredBy: declaredBy === undefined
|
||||
? 'the runtime itself (built in; always present)'
|
||||
: `an entry in '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that entry is mounted`,
|
||||
occupants: occupants.map(occupant => [
|
||||
shortPackage(occupant.package),
|
||||
occupant.component,
|
||||
...occupant.id === undefined ? [] : [`id '${occupant.id}'`],
|
||||
...occupant.entryKey === undefined ? [] : [`key '${occupant.entryKey}'`],
|
||||
].join(' ')),
|
||||
replaceRisk: cellOccupied && (declaration.kind === 'single' || declaration.kind === 'keyed')
|
||||
? 'shadows-shipped-ui'
|
||||
: 'none',
|
||||
example: exampleOf(declaration),
|
||||
source: declaration.source,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner-props contract at ONE level: the owner declaration(s) themselves,
|
||||
* plus the names of the shapes their fields reference. Expanding transitively
|
||||
* pulled the whole session model into four seats (one report exceeded 2400
|
||||
* lines), which defeats the purpose of narrowing to a single slot — a registrant
|
||||
* needs the fields and their documented meaning, not the type graph behind them.
|
||||
*/
|
||||
function ownerShapes(
|
||||
ownerType: string | undefined,
|
||||
types: ReadonlyMap<string, TypeDeclaration>,
|
||||
): { declarations: TypeDeclaration[]; references: string[] } {
|
||||
if (ownerType === undefined) return { declarations: [], references: [] }
|
||||
const declarations = declaredTypes(referencedTypeNames([ownerType], types), types)
|
||||
const own = new Set(declarations.map(declaration => declaration.name))
|
||||
const references = referencedTypeNames(declarations.map(declaration => declaration.text), types)
|
||||
.filter(name => !own.has(name))
|
||||
return { declarations, references }
|
||||
}
|
||||
|
||||
/** How a keyed slot's key domain is constrained, '' for the other kinds. */
|
||||
function keyDomainOf(declaration: SlotDeclaration, occupants: readonly SlotRegistration[]): string {
|
||||
if (declaration.kind !== 'keyed') return ''
|
||||
const taken = [...new Set(occupants.flatMap(occupant => occupant.entryKey === undefined ? [] : [occupant.entryKey]))].sort()
|
||||
const shipped = taken.length === 0 ? 'none are taken yet' : `already taken: ${taken.join(', ')}`
|
||||
return declaration.keyProps === undefined
|
||||
? `open: any string the owner dispatches (no compile-time key set), ${shipped}`
|
||||
: `fixed by the owner's key table ${declaration.keyProps}, ${shipped}`
|
||||
}
|
||||
|
||||
/** A runnable minimal registration for one slot, per cardinality. */
|
||||
function exampleOf(declaration: SlotDeclaration): string {
|
||||
const options = [`name: '${declaration.key}'`, ...KIND_EXAMPLE[declaration.kind] ?? []].join(', ')
|
||||
return [
|
||||
'return {',
|
||||
" inject: ['slots'],",
|
||||
' apply(ctx) {',
|
||||
` ctx.slots.inject('${declaration.key}', () => ctx.slots.register(`,
|
||||
` { ${options} },`,
|
||||
" () => React.createElement('div', null, 'hello'),",
|
||||
' ))',
|
||||
' },',
|
||||
'}',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Extra example options per cardinality. */
|
||||
const KIND_EXAMPLE: Readonly<Record<string, readonly string[]>> = {
|
||||
single: [],
|
||||
list: ["id: 'my-entry'", 'order: 100', "label: 'My entry'"],
|
||||
keyed: ["key: '<one key the owner dispatches>'"],
|
||||
chain: ['select: owner => null'],
|
||||
}
|
||||
|
||||
/** Drop the `@deepseek-ai/dsh-` prefix so rows stay readable. */
|
||||
function shortPackage(name: string): string {
|
||||
return name.replace('@deepseek-ai/dsh-', '')
|
||||
}
|
||||
|
||||
/** Truncate an over-long declaration, naming the truncation. */
|
||||
function truncate(text: string): string {
|
||||
return text.length > MAX_DECL_CHARS
|
||||
? `${text.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
|
||||
: text
|
||||
}
|
||||
|
||||
/** JSDoc prose: comment markers and block tags removed, paragraphs kept. */
|
||||
function docProse(jsDoc: string): string {
|
||||
const lines = jsDoc.replace(/^\/\*\*/, '').replace(/\*\/$/, '').split('\n')
|
||||
.map(line => line.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
const kept: string[] = []
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('@')) break
|
||||
kept.push(line)
|
||||
}
|
||||
return kept.join('\n').replace(/\{@link\s+([^}]+)\}/g, '$1').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
/** First sentence of a prose block, for the compact listing. */
|
||||
function firstSentence(doc: string): string {
|
||||
const flat = doc.replace(/\s+/g, ' ').trim()
|
||||
const match = /^(.*?[.!?])(?:\s|$)/.exec(flat)
|
||||
return (match?.[1] ?? flat).trim()
|
||||
}
|
||||
|
||||
/** Render one value as a single-quoted TypeScript literal. */
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`
|
||||
}
|
||||
|
||||
/** Render a readonly string-array literal. */
|
||||
function list(values: readonly string[], indent: string): string {
|
||||
if (values.length === 0) return '[]'
|
||||
return ['[', ...values.map(value => `${indent} ${quote(value)},`), `${indent}]`].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated data module.
|
||||
* @param entries - resolved catalog entries.
|
||||
* @returns the module source.
|
||||
*/
|
||||
export function renderClientCatalog(entries: readonly SlotEntry[]): string {
|
||||
const lines: string[] = [
|
||||
'/**',
|
||||
' * Generated by scripts/gen-client-catalog.ts — do not edit by hand; run',
|
||||
' * `pnpm run gen-client-catalog` to regenerate (freshness-gated by',
|
||||
' * `pnpm run verify-client-catalog` in doc-sync).',
|
||||
' *',
|
||||
' * The compile-time contract of the shipped web bundle\'s slot surface, as',
|
||||
' * `cordis_inspect what:"client"` serves it to the model: every SlotMap key a',
|
||||
' * browser half can register into, what that register call must pass, what the',
|
||||
' * component receives, who already occupies the seat, and which owner has to be',
|
||||
' * mounted for the seat to exist. Data only — this module is the one legitimate',
|
||||
' * meeting point of the two planes, so it carries strings, never client imports.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-cordis-client-runner/client/slot-catalog',
|
||||
' */',
|
||||
'',
|
||||
'/** One option a register call passes for a given slot cardinality. */',
|
||||
'export interface ClientSlotOption {',
|
||||
' /** Option name as written in the register options object. */',
|
||||
' name: string',
|
||||
' /** Whether the cardinality requires it. */',
|
||||
' requirement: string',
|
||||
' /** Accepted type, in source spelling. */',
|
||||
' type: string',
|
||||
' /** What it does, from the registrant\'s side. */',
|
||||
' doc: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One browser-half slot a dynamic package can contribute UI into. */',
|
||||
'export interface ClientSlotEntry {',
|
||||
' /** SlotMap key passed as the register call\'s `name`. */',
|
||||
' key: string',
|
||||
' /** Cardinality: `single`, `list`, `keyed`, or `chain`. */',
|
||||
' kind: string',
|
||||
' /** Data scope: `root`, `session`, or `session-maybe`. */',
|
||||
' scope: string',
|
||||
' /** First sentence of the contract prose. */',
|
||||
' summary: string',
|
||||
' /** Full contract prose from the SlotMap declaration. */',
|
||||
' doc: string',
|
||||
' /** Options this cardinality accepts (beyond `name`). */',
|
||||
' registerOptions: readonly ClientSlotOption[]',
|
||||
' /** Declarations of the props the owner passes down, with their own documentation. */',
|
||||
' ownerProps: readonly string[]',
|
||||
' /** Names of the shapes those props reference; deliberately not expanded here. */',
|
||||
' ownerPropsReferences: readonly string[]',
|
||||
' /** Framework-supplied component props for this scope. */',
|
||||
' standardProps: readonly string[]',
|
||||
' /** For keyed slots: how the key set is constrained and which keys are taken. */',
|
||||
' keyDomain: string',
|
||||
' /** Opaque per-render-site context passed to slot-level hooks, when the slot declares one. */',
|
||||
' hookContext: string',
|
||||
' /** Slot-level inject face every entry receives, when the slot declares one. */',
|
||||
' slotInject: string',
|
||||
' /** Which mounted entry makes this slot exist. */',
|
||||
' declaredBy: string',
|
||||
' /** Entries the shipped composition already registered here. */',
|
||||
' occupants: readonly string[]',
|
||||
' /** `shadows-shipped-ui` when registering here replaces shipped UI; `none` when additive. */',
|
||||
' replaceRisk: string',
|
||||
' /** A minimal browser half that registers into this slot. */',
|
||||
' example: string',
|
||||
' /** Source pointer of the contract declaration. */',
|
||||
' source: string',
|
||||
'}',
|
||||
'',
|
||||
'/** Rules that apply to every browser-half contribution, in reading order. */',
|
||||
'export const CLIENT_NOTES: readonly string[] = [',
|
||||
...CLIENT_NOTES.map(note => ` ${quote(note)},`),
|
||||
']',
|
||||
'',
|
||||
'/** Every slot the shipped web bundle declares, sorted by key. */',
|
||||
// The entries below repeat by nature: seats of one cardinality share their
|
||||
// register options and framework props verbatim, and that sameness is the
|
||||
// contract a registrant reads, not a refactor waiting to happen. Clone
|
||||
// detection is told so here rather than through a config exception, which is
|
||||
// how this repository marks duplication that belongs to its subject.
|
||||
'// Seats of one cardinality repeat their register options and framework props',
|
||||
'// verbatim; that sameness IS the contract a registrant reads, so clone',
|
||||
'// detection is told to skip the data rather than the file.',
|
||||
'/* jscpd:ignore-start */',
|
||||
'export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [',
|
||||
]
|
||||
for (const entry of entries) {
|
||||
lines.push(' {')
|
||||
lines.push(` key: ${quote(entry.key)},`)
|
||||
lines.push(` kind: ${quote(entry.kind)},`)
|
||||
lines.push(` scope: ${quote(entry.scope)},`)
|
||||
lines.push(` summary: ${quote(entry.summary)},`)
|
||||
lines.push(` doc: ${quote(entry.doc)},`)
|
||||
if (entry.registerOptions.length === 0) {
|
||||
lines.push(' registerOptions: [],')
|
||||
} else {
|
||||
lines.push(' registerOptions: [')
|
||||
for (const option of entry.registerOptions) {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(option.name)},`)
|
||||
lines.push(` requirement: ${quote(option.requirement)},`)
|
||||
lines.push(` type: ${quote(option.type)},`)
|
||||
lines.push(` doc: ${quote(option.doc)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(' ],')
|
||||
}
|
||||
lines.push(` ownerProps: ${list(entry.ownerProps, ' ')},`)
|
||||
lines.push(` ownerPropsReferences: ${list(entry.ownerPropsReferences, ' ')},`)
|
||||
lines.push(` standardProps: ${list(entry.standardProps, ' ')},`)
|
||||
lines.push(` keyDomain: ${quote(entry.keyDomain)},`)
|
||||
lines.push(` hookContext: ${quote(entry.hookContext)},`)
|
||||
lines.push(` slotInject: ${quote(entry.slotInject)},`)
|
||||
lines.push(` declaredBy: ${quote(entry.declaredBy)},`)
|
||||
lines.push(` occupants: ${list(entry.occupants, ' ')},`)
|
||||
lines.push(` replaceRisk: ${quote(entry.replaceRisk)},`)
|
||||
lines.push(` example: ${quote(entry.example)},`)
|
||||
lines.push(` source: ${quote(entry.source)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(']', '/* jscpd:ignore-end */', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI entry: regenerate the catalog, or with `--check` fail when it is stale.
|
||||
* @returns nothing; writes the artifact or reports freshness through the process.
|
||||
*/
|
||||
export function main(): void {
|
||||
const content = renderClientCatalog(collectSlotEntries(root))
|
||||
const destination = resolve(root, OUT)
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(destination, 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (never generated) is expected here, and its remedy is the
|
||||
// same as a stale artifact's: regenerate.
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-client-catalog: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-client-catalog: stale — ${OUT}. Run \`pnpm run gen-client-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
console.log(`gen-client-catalog: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
* projection enforces event modes, JSDoc parameter/return completeness, and
|
||||
* signature type-link coverage; the inherited (vendor) tier renders to
|
||||
* `docs/cordis-api/inherited.md`. `--check` verifies every generated artifact.
|
||||
*
|
||||
* Generated regions embed `file:line` source pointers, so inserting lines ABOVE a
|
||||
* recorded symbol makes the committed output stale even though nothing about the
|
||||
* symbol changed. Regenerate after editing any file this projection records — the
|
||||
* failure otherwise surfaces as the "reproduces every committed catalog artifact
|
||||
* byte for byte" test failing, which reads like a snapshot regression rather than
|
||||
* a missing regeneration.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -47,6 +54,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
agentDefaultModel: 'core.md',
|
||||
agentPresets: 'core.md',
|
||||
agents: 'core.md',
|
||||
apiProxy: 'typert.md',
|
||||
approval: 'approval.md',
|
||||
attachments: 'attachment.md',
|
||||
shell: 'shell.md',
|
||||
@@ -57,12 +65,14 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
compaction: 'compaction.md',
|
||||
credentials: 'credentials.md',
|
||||
directoryPicker: 'workspace.md',
|
||||
dynamicCordisRunner: 'self-modification.md',
|
||||
e2b: 'subprocess.md',
|
||||
fs: 'filesystem.md',
|
||||
goals: 'goal.md',
|
||||
webServer: 'web-server.md',
|
||||
invariants: 'invariants.md',
|
||||
llm: 'llm-streaming.md',
|
||||
lsp: 'lsp.md',
|
||||
messageFeedback: 'feedback.md',
|
||||
permissionPresets: 'permission-presets.md',
|
||||
planMode: 'plan.md',
|
||||
@@ -105,10 +115,15 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their API.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
* face only) name the package README that owns their surface.
|
||||
*
|
||||
* Two categories remain, and neither is a projection gap a scanning rule could
|
||||
* close. An OPTIONAL key (`key?: X`) is a value the launcher or boot code
|
||||
* installs before the tree mounts, which the analyzer skips by rule because no
|
||||
* plugin provides it and `inject` cannot reach it. A client-face key belongs to
|
||||
* the browser Context, which this host-face program never sees; the browser
|
||||
* surface has its own generated catalog (`scripts/gen-client-catalog.ts`, served
|
||||
* to a model as `cordis_runtime_inspect what:"client"`).
|
||||
*/
|
||||
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
||||
@@ -121,7 +136,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the API',
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the API',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the API',
|
||||
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
@@ -153,6 +167,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent-preset': 'core.md',
|
||||
'approval': 'approval.md',
|
||||
'commands': 'commands.md',
|
||||
'cordis': 'self-modification.md',
|
||||
'credentials': 'credentials.md',
|
||||
'domain': 'storage.md',
|
||||
'fs': 'filesystem.md',
|
||||
@@ -306,6 +321,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
CommandDescriptor: 'commands.md',
|
||||
CommandId: 'commands.md',
|
||||
CommandResult: 'commands.md',
|
||||
CommandSurface: 'commands.md',
|
||||
LspProvider: 'lsp.md',
|
||||
LspQueryRequest: 'lsp.md',
|
||||
LspQueryResult: 'lsp.md',
|
||||
LlmAdapter: 'llm-streaming.md',
|
||||
PreparedLlmCall: 'llm-streaming.md',
|
||||
LlmRuntime: 'llm-streaming.md',
|
||||
@@ -497,6 +516,42 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',
|
||||
CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts',
|
||||
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts',
|
||||
ClientResponse: 'wire response message is owned by packages/host/apiproxy/src/api/rpc.ts',
|
||||
ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectProviderManifest: 'Cordis inspect provider manifest is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectProviderView: 'Cordis inspect provider view is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectQueryRequest: 'Cordis inspect transport payload is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectQueryResolution: 'Cordis inspect query result is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectQueryResolved: 'Cordis inspect transport payload is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectRequestId: 'Cordis inspect request identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisInspectResolveAck: 'Cordis inspect resolution acknowledgement is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisDynamicPackageId: 'dynamic Package identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisDynamicPluginId: 'dynamic Plugin identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisDynamicPluginRunId: 'dynamic Plugin run identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
CordisDynamicRunMode: 'dynamic Plugin activation mode is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisClientSource: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisDefineReceipt: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisDefineRequest: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisHostHalfResult: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisInventoryRow: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisInvokeResult: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisPackageInspection: 'dynamic Package source inspection is owned by packages/extensions/cordis-host-runner/src/registry.ts',
|
||||
DynamicCordisPluginInspection: 'dynamic Plugin inspection is owned by packages/extensions/cordis-host-runner/src/registry.ts',
|
||||
DynamicCordisRequestResolved: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisRetracted: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisRunRequest: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisPackage: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisReference: 'dynamic Plugin reference is owned by packages/extensions/cordis-host-runner/src/registry.ts',
|
||||
DynamicCordisRenderFailure: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisResolveAck: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisRunResolution: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisRunResponse: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisSnapshotRow: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisStopResponse: 'dynamic Plugin stop result is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
DynamicCordisUndefineReceipt: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
HostCordisInspectProviderRegistration: 'Host inspect provider registration is owned by packages/extensions/cordis-host-runner/src/inspect-registry.ts',
|
||||
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
|
||||
CommandExecution: 'executor return contract is owned by packages/interaction/commands/src/index.ts',
|
||||
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
|
||||
@@ -509,9 +564,12 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/runtime-diagnostics/invariants/README.md',
|
||||
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
|
||||
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
RpcReceipt: 'carrier-layer receipt is owned by packages/host/apiproxy/src/api/rpc.ts',
|
||||
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
@@ -526,6 +584,40 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
linkedTypePages: LINK_MAP,
|
||||
foundationTypeNames: FOUNDATION_TYPE_NAMES,
|
||||
typeLinkExemptions: TYPE_LINK_EXEMPTIONS,
|
||||
runtimeServiceExclusions: new Set(['dynamicCordisRunner']),
|
||||
runtimeServices: [{
|
||||
key: 'timer',
|
||||
type: 'TimerService',
|
||||
abstract: false,
|
||||
doc: 'Disposable timer helpers mixed into Cordis contexts.',
|
||||
source: 'vendor/timer/src/index.ts:12',
|
||||
methods: [
|
||||
{
|
||||
signature: 'timeout(callback: () => void, delay: number): () => void',
|
||||
jsDoc: '/** Run a callback once and return its disposer. */',
|
||||
},
|
||||
{
|
||||
signature: 'timeout(delay: number): Promise<void>',
|
||||
jsDoc: '/** Resolve after a delay; disposal rejects the pending promise. */',
|
||||
},
|
||||
{
|
||||
signature: 'interval(callback: () => void, delay: number): () => void',
|
||||
jsDoc: '/** Run a callback repeatedly and return its disposer. */',
|
||||
},
|
||||
{
|
||||
signature: 'interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>',
|
||||
jsDoc: '/** Return an async iterator of timer ticks. */',
|
||||
},
|
||||
{
|
||||
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
|
||||
jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */',
|
||||
},
|
||||
{
|
||||
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
|
||||
jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */',
|
||||
},
|
||||
],
|
||||
}],
|
||||
inheritedEvents: [
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
|
||||
@@ -551,7 +643,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
|
||||
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the four supported helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
||||
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
|
||||
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' },
|
||||
],
|
||||
|
||||
57
scripts/gen-cordis-inspect-catalog.ts
Normal file
57
scripts/gen-cordis-inspect-catalog.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** Generate model-visible Host/Client Service and Event inspect catalogs. */
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogModel, ServiceMethodEntry } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { CORDIS_CATALOG_POLICY } from './gen-cordis-catalog.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-catalog.ts'
|
||||
|
||||
const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
|
||||
layout: ['toggleSidebar', 'openDetails', 'closeDetails'],
|
||||
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'register', 'bind'],
|
||||
sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
|
||||
slots: ['register', 'inject'],
|
||||
theme: ['getTheme', 'setTheme', 'register', 'overrideTokens'],
|
||||
workspaces: [
|
||||
'connectWorkspace', 'startSession', 'create', 'pickDirectory', 'listDirectory', 'createDirectory',
|
||||
'openPath', 'rename', 'delete', 'insertSessionBefore', 'archiveSession',
|
||||
],
|
||||
}
|
||||
|
||||
const CLIENT_EVENTS = new Set([
|
||||
'connection/reset',
|
||||
'locale/change',
|
||||
'slots/changed',
|
||||
'theme/change',
|
||||
])
|
||||
|
||||
function methodName(method: ServiceMethodEntry): string | undefined {
|
||||
return /^(?:declare\s+)?(?:readonly\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)/.exec(method.signature)?.[1]
|
||||
}
|
||||
|
||||
function clientModel(model: CordisCatalogModel): CordisCatalogModel {
|
||||
return {
|
||||
services: model.services.flatMap((service) => {
|
||||
const allowed = CLIENT_SERVICES[service.key]
|
||||
if (allowed === undefined) return []
|
||||
const names = new Set(allowed)
|
||||
return [{ ...service, methods: service.methods.filter(method => names.has(methodName(method) ?? '')) }]
|
||||
}),
|
||||
events: model.events.filter(event => CLIENT_EVENTS.has(event.name)),
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY, 'client')
|
||||
const destination = resolve(root, CLIENT_OUT)
|
||||
const source = projector.renderRuntimeApi(clientModel(model))
|
||||
.replaceAll('@deepseek-ai/dsh-tool-cordis/api-catalog', '@deepseek-ai/dsh-cordis-client-runner/client/api-catalog')
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, source)
|
||||
console.log(`gen-cordis-inspect-catalog: wrote ${CLIENT_OUT}`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -530,6 +530,31 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-workflow', 'tool-ralph'],
|
||||
note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
|
||||
},
|
||||
{
|
||||
key: 'lsp',
|
||||
pkg: 'lsp',
|
||||
title: 'Language-server navigation seam',
|
||||
mode: 'seam',
|
||||
implementations: ['lsp-local'],
|
||||
consumers: ['tool-lsp'],
|
||||
note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.',
|
||||
},
|
||||
{
|
||||
key: 'apiProxy',
|
||||
pkg: 'apiproxy',
|
||||
title: 'Host API dispatch',
|
||||
mode: 'core',
|
||||
consumers: ['connection'],
|
||||
note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.',
|
||||
},
|
||||
{
|
||||
key: 'dynamicCordisRunner',
|
||||
pkg: 'cordis-host-runner',
|
||||
title: 'Dynamic Cordis package host runner',
|
||||
mode: 'core',
|
||||
consumers: ['tool-cordis'],
|
||||
note: 'Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace.',
|
||||
},
|
||||
]
|
||||
|
||||
function generatedHeader(title: string): string[] {
|
||||
|
||||
@@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
@@ -135,7 +136,7 @@ async function mountCatalogChildScope(
|
||||
* prompt and registry; each recipe supplies only package-specific seams and
|
||||
* config, while `dir` participates in the completeness check.
|
||||
*/
|
||||
interface ToolPackage {
|
||||
export interface ToolPackage {
|
||||
/** The npm package name, used as the catalog section heading. */
|
||||
pkg: string
|
||||
/** The `packages/<group>/<dir>` leaf name — matched by the completeness guard. */
|
||||
@@ -256,13 +257,14 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
source: 'packages/extensions/tool-cordis/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'],
|
||||
requires: ['ctx.tools', 'ctx.dynamicCordisRunner'],
|
||||
writes: ['tool/call', 'tool/result', 'process-local dynamic package lifecycle'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(CordisHostRunner)
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
|
||||
'Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash-persistent',
|
||||
@@ -587,6 +589,29 @@ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert one manifest entry actually registered a tool.
|
||||
*
|
||||
* A tool package that boots without registering anything is a broken boot, not
|
||||
* an empty catalog section. The usual cause is an `inject` the entry's `mount`
|
||||
* does not satisfy: cordis leaves the plugin PENDING, every step here still
|
||||
* succeeds, and the generator writes a catalog missing that package's tools —
|
||||
* with the freshness gate green on it, because the omission is now what the
|
||||
* generator produces. {@link assertManifestComplete} cannot see this: the
|
||||
* package IS listed, it just contributed nothing.
|
||||
* @param entry - the manifest entry that was booted.
|
||||
* @param harvested - how many schemas its boot registered.
|
||||
* @throws when the boot registered no tool at all.
|
||||
*/
|
||||
export function assertToolsHarvested(entry: ToolPackage, harvested: number): void {
|
||||
if (harvested > 0) return
|
||||
throw new Error(
|
||||
`gen-tool-catalog: ${entry.pkg} booted without registering a single tool. `
|
||||
+ 'Its plugin is most likely PENDING on a service this manifest entry does not mount — '
|
||||
+ `compare the plugin's inject with mount() and requires: ${entry.requires.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot each tool package on a fresh Context and harvest its model-facing
|
||||
* schemas. A fresh Context per package keeps attribution clean (each entry's
|
||||
@@ -606,6 +631,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {})
|
||||
await entry.mount(ctx)
|
||||
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
assertToolsHarvested(entry, schemas.length)
|
||||
catalog.push({
|
||||
pkg: entry.pkg,
|
||||
sources: Object.fromEntries(schemas.map(schema => [
|
||||
|
||||
@@ -581,6 +581,7 @@ function docSyncLeafGates(options: {
|
||||
? []
|
||||
: [pnpmScript('doc-typecheck', options.docTypecheckScript ?? 'doc-typecheck', docTypecheckOptions)],
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('client-catalog', 'verify-client-catalog', { label: 'client catalog' }),
|
||||
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
|
||||
429
scripts/slot-walk.ts
Normal file
429
scripts/slot-walk.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* AST helpers for the client slot surface: the `SlotMap` declaration merges
|
||||
* that type every slot, and the `slots.register` call sites that say who
|
||||
* already occupies one. Both readings are lexical (no type-checker program):
|
||||
* the client catalog generator consumes them, and the same scan doubles as its
|
||||
* own exhaustiveness backstop because it reads every source file rather than a
|
||||
* reachable-export closure.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** The module whose `SlotMap` / standard-kit interfaces every slot owner merges into. */
|
||||
const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
|
||||
const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/
|
||||
|
||||
/** Cheap textual prefilter for a registration call site. */
|
||||
const REGISTER_HEAD = /\.register\(/
|
||||
|
||||
/** One `SlotMap` member: the slot's contract as its owning package declares it. */
|
||||
export interface SlotDeclaration {
|
||||
/** SlotMap key, e.g. `settings.section`. */
|
||||
key: string
|
||||
/** Cardinality literal (`single` / `list` / `keyed` / `chain`), or '' when not a literal. */
|
||||
kind: string
|
||||
/** Data-scope literal (`root` / `session` / `session-maybe`), or '' when not a literal. */
|
||||
scope: string
|
||||
/** Type name of the owner-supplied props share, absent when the slot declares none. */
|
||||
ownerType?: string
|
||||
/** Source text of the `keyProps` member (keyed slots), absent otherwise. */
|
||||
keyProps?: string
|
||||
/** Source text of the `hookContext` member, absent otherwise. */
|
||||
hookContext?: string
|
||||
/** Type name of the slot-level inject face, absent when the slot declares none. */
|
||||
injectType?: string
|
||||
/** The member's JSDoc with container indentation removed, '' when undocumented. */
|
||||
jsDoc: string
|
||||
/** Workspace package that declares the contract. */
|
||||
package: string
|
||||
/** Source pointer `packages/…/file.ts:line`. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One `slots.register({ name, … }, Component)` call site. */
|
||||
export interface SlotRegistration {
|
||||
/** Target SlotMap key the entry contributes into. */
|
||||
key: string
|
||||
/** Workspace package that registers the entry. */
|
||||
package: string
|
||||
/** Component argument as written (identifier, or a trimmed expression). */
|
||||
component: string
|
||||
/** `id` literal of a list entry, absent otherwise. */
|
||||
id?: string
|
||||
/** `key` literal of a keyed entry, absent otherwise. */
|
||||
entryKey?: string
|
||||
/** SlotMap keys this registration declares as children (they exist while it is mounted). */
|
||||
children: string[]
|
||||
/** Source pointer `packages/…/file.ts:line`. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One exported type declaration, retained with its JSDoc for catalog projection. */
|
||||
export interface TypeDeclaration {
|
||||
/** Declared name. */
|
||||
name: string
|
||||
/** Full declaration text INCLUDING its JSDoc (member docs are the teaching text). */
|
||||
text: string
|
||||
/** Source pointer `packages/…/file.ts:line`. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One scanned source file with the artifacts the catalog reads from it. */
|
||||
export interface ScannedFile {
|
||||
/** Repo-relative, `/`-normalized path. */
|
||||
rel: string
|
||||
/** Workspace package name that owns the file. */
|
||||
package: string
|
||||
/** Parsed source file. */
|
||||
sf: ts.SourceFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every file matching `patterns`, keeping the ones that carry a slot
|
||||
* contract merge or a registration call. Files without either are skipped so
|
||||
* the scan stays cheap over the whole workspace.
|
||||
* @param scanRoot - repository root the patterns resolve against.
|
||||
* @param patterns - glob(s) selecting the TypeScript/TSX files to scan.
|
||||
* @returns one entry per interesting file, in path order.
|
||||
*/
|
||||
export function scanSlotFiles(scanRoot: string, patterns: readonly string[]): ScannedFile[] {
|
||||
const out: ScannedFile[] = []
|
||||
const names = new Map<string, string>()
|
||||
const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
|
||||
.map(path => path.split(sep).join('/')))].sort()
|
||||
for (const rel of rels) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!MERGE_HEAD.test(text) && !REGISTER_HEAD.test(text)) continue
|
||||
out.push({
|
||||
rel,
|
||||
package: packageNameOf(scanRoot, rel, names),
|
||||
sf: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, scriptKindOf(rel)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every exported type declaration of the scanned packages, keeping JSDoc.
|
||||
* The catalog resolves owner-props and inject-face shapes through this index
|
||||
* instead of a type-checker program: the declaration text with its member
|
||||
* documentation IS the teaching material a registrant needs.
|
||||
* @param scanRoot - repository root the patterns resolve against.
|
||||
* @param patterns - glob(s) selecting the TypeScript/TSX files to index.
|
||||
* @returns name → declaration, with names declared more than once dropped as ambiguous.
|
||||
*/
|
||||
export function indexExportedTypes(scanRoot: string, patterns: readonly string[]): Map<string, TypeDeclaration> {
|
||||
const index = new Map<string, TypeDeclaration>()
|
||||
const ambiguous = new Set<string>()
|
||||
const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
|
||||
.map(path => path.split(sep).join('/')))].sort()
|
||||
for (const rel of rels) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, scriptKindOf(rel))
|
||||
for (const statement of sf.statements) {
|
||||
if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) continue
|
||||
if (!statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
||||
const name = statement.name.text
|
||||
if (index.has(name)) {
|
||||
ambiguous.add(name)
|
||||
continue
|
||||
}
|
||||
index.set(name, {
|
||||
name,
|
||||
text: declarationText(statement, sf),
|
||||
source: `${rel}:${String(lineOf(sf, statement))}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const name of ambiguous) index.delete(name)
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every `SlotMap` member declared in one scanned file.
|
||||
* @param file - a file returned by {@link scanSlotFiles}.
|
||||
* @returns the declared slots, in source order.
|
||||
*/
|
||||
export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
|
||||
const out: SlotDeclaration[] = []
|
||||
for (const body of slotModuleBodies(file.sf)) {
|
||||
for (const statement of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'SlotMap') continue
|
||||
for (const member of statement.members) {
|
||||
if (!ts.isPropertySignature(member) || member.type === undefined) continue
|
||||
const key = ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
|
||||
? member.name.text
|
||||
: member.name.getText(file.sf)
|
||||
const entry = ts.isTypeLiteralNode(member.type) ? member.type : undefined
|
||||
const ownerType = memberTypeText(entry, 'owner', file.sf)
|
||||
const keyProps = memberTypeText(entry, 'keyProps', file.sf)
|
||||
const hookContext = memberTypeText(entry, 'hookContext', file.sf)
|
||||
const injectType = memberTypeText(entry, 'inject', file.sf)
|
||||
out.push({
|
||||
key,
|
||||
kind: literalMember(entry, 'kind'),
|
||||
scope: literalMember(entry, 'scope'),
|
||||
...ownerType === undefined ? {} : { ownerType },
|
||||
...keyProps === undefined ? {} : { keyProps },
|
||||
...hookContext === undefined ? {} : { hookContext },
|
||||
...injectType === undefined ? {} : { injectType },
|
||||
jsDoc: jsDocOf(member, file.sf),
|
||||
package: file.package,
|
||||
source: `${file.rel}:${String(lineOf(file.sf, member))}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every registration call site in one scanned file: which slot it
|
||||
* occupies, with which component and cell identity, and which child slots it
|
||||
* declares. A call whose `name` is not a string literal is skipped — the
|
||||
* shipped composition always names its target literally, and a computed name
|
||||
* carries no catalog fact.
|
||||
* @param file - a file returned by {@link scanSlotFiles}.
|
||||
* @returns the registrations, in source order.
|
||||
*/
|
||||
export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
|
||||
const out: SlotRegistration[] = []
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)
|
||||
&& ts.isPropertyAccessExpression(node.expression)
|
||||
&& node.expression.name.text === 'register'
|
||||
&& isSlotsReceiver(node.expression.expression, file.sf)
|
||||
&& node.arguments.length >= 1) {
|
||||
const options = node.arguments[0]
|
||||
if (options !== undefined && ts.isObjectLiteralExpression(options)) {
|
||||
const key = stringProperty(options, 'name')
|
||||
if (key !== undefined) {
|
||||
const id = stringProperty(options, 'id')
|
||||
const entryKey = stringProperty(options, 'key')
|
||||
out.push({
|
||||
key,
|
||||
package: file.package,
|
||||
component: componentText(node.arguments[1], file.sf),
|
||||
...id === undefined ? {} : { id },
|
||||
...entryKey === undefined ? {} : { entryKey },
|
||||
children: childKeys(options),
|
||||
source: `${file.rel}:${String(lineOf(file.sf, node))}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(file.sf)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one standard-kit interface's members from the scanned files: the props
|
||||
* a slot component receives for free from the framework at a given scope.
|
||||
* @param files - scanned files to search.
|
||||
* @param interfaceName - `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps`.
|
||||
* @returns `member: type` texts in declaration order, merged across declaring files.
|
||||
*/
|
||||
export function standardKitMembers(files: readonly ScannedFile[], interfaceName: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const file of files) {
|
||||
for (const body of slotModuleBodies(file.sf)) {
|
||||
for (const statement of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) continue
|
||||
for (const member of statement.members) {
|
||||
if (!ts.isPropertySignature(member)) continue
|
||||
const type = member.type === undefined ? 'unknown' : member.type.getText(file.sf)
|
||||
out.push(`${member.name.getText(file.sf)}${member.questionToken === undefined ? '' : '?'}: ${collapse(type)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Names in the type index that seed texts mention, word-bounded — ONE level, not
|
||||
* a transitive closure. The catalog expands an owner-props contract exactly one
|
||||
* step: the owner interface carries the interaction protocol in its own member
|
||||
* documentation, while the shapes its fields reference belong to the subsystems
|
||||
* that own them and would otherwise drag the entire session model into a single
|
||||
* slot's report.
|
||||
* @param seeds - declaration or signature texts to search.
|
||||
* @param index - the type index from {@link indexExportedTypes}.
|
||||
* @returns the mentioned names, sorted.
|
||||
*/
|
||||
export function referencedTypeNames(
|
||||
seeds: readonly string[],
|
||||
index: ReadonlyMap<string, TypeDeclaration>,
|
||||
): string[] {
|
||||
const found: string[] = []
|
||||
for (const name of index.keys()) {
|
||||
const pattern = new RegExp(`\\b${name}\\b`)
|
||||
if (seeds.some(text => pattern.test(text))) found.push(name)
|
||||
}
|
||||
return found.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve declarations by name, dropping names the index does not hold.
|
||||
* @param names - type names to resolve.
|
||||
* @param index - the type index from {@link indexExportedTypes}.
|
||||
* @returns the resolved declarations, sorted by name.
|
||||
*/
|
||||
export function declaredTypes(
|
||||
names: readonly string[],
|
||||
index: ReadonlyMap<string, TypeDeclaration>,
|
||||
): TypeDeclaration[] {
|
||||
return [...names]
|
||||
.flatMap(name => index.get(name) ?? [])
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Every slot-contract module block in one file, in source order. */
|
||||
function slotModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const statement of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)) continue
|
||||
if (statement.name.text !== SLOTS_MODULE) continue
|
||||
if (statement.body !== undefined && ts.isModuleBlock(statement.body)) bodies.push(statement.body)
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `X.register(...)` receiver is the slots service. Every other
|
||||
* registry in the repo (`ctx.tools`, `ctx.commands`, `ctx.settings`, …) also
|
||||
* takes an options object with a `name`, so the receiver is what separates a
|
||||
* slot occupancy fact from an unrelated registration.
|
||||
*/
|
||||
function isSlotsReceiver(receiver: ts.Expression, sf: ts.SourceFile): boolean {
|
||||
const text = receiver.getText(sf)
|
||||
return text === 'slots' || text.endsWith('.slots')
|
||||
}
|
||||
|
||||
/** The workspace package name owning a repo-relative file, memoized per package root. */
|
||||
function packageNameOf(scanRoot: string, rel: string, cache: Map<string, string>): string {
|
||||
let dir = dirname(resolve(scanRoot, rel))
|
||||
while (dir.length > scanRoot.length) {
|
||||
const cached = cache.get(dir)
|
||||
if (cached !== undefined) return cached
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
|
||||
if (typeof manifest.name === 'string') {
|
||||
cache.set(dir, manifest.name)
|
||||
return manifest.name
|
||||
}
|
||||
} catch {
|
||||
// No manifest at this level: keep walking up to the owning package root.
|
||||
}
|
||||
dir = dirname(dir)
|
||||
}
|
||||
return '(unknown package)'
|
||||
}
|
||||
|
||||
/** TSX must parse as TSX; a `.ts` file with JSX-looking generics must not. */
|
||||
function scriptKindOf(rel: string): ts.ScriptKind {
|
||||
return rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
}
|
||||
|
||||
/** 1-based line of a node's first character. */
|
||||
function lineOf(sf: ts.SourceFile, node: ts.Node): number {
|
||||
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
|
||||
}
|
||||
|
||||
/** Declaration text including leading JSDoc, with container indentation removed. */
|
||||
function declarationText(statement: ts.Node, sf: ts.SourceFile): string {
|
||||
return dedent(sf.text.slice(statement.getStart(sf, true), statement.getEnd()))
|
||||
}
|
||||
|
||||
/** One member's JSDoc comment text, '' when the member has none. */
|
||||
function jsDocOf(member: ts.Node, sf: ts.SourceFile): string {
|
||||
// getStart(includeJsDoc) brackets exactly the doc comment: with it the range
|
||||
// opens at `/**`, without it at the member itself.
|
||||
const withDoc = member.getStart(sf, true)
|
||||
const withoutDoc = member.getStart(sf, false)
|
||||
if (withDoc >= withoutDoc) return ''
|
||||
return dedent(sf.text.slice(withDoc, withoutDoc).trimEnd())
|
||||
}
|
||||
|
||||
/** Strip the shared leading indentation of a multi-line source slice. */
|
||||
function dedent(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
const indents = lines.slice(1).filter(line => line.trim() !== '')
|
||||
.map(line => (/^\s*/.exec(line) as RegExpExecArray)[0].length)
|
||||
const shared = indents.length === 0 ? 0 : Math.min(...indents)
|
||||
return [lines[0] ?? '', ...lines.slice(1).map(line => line.slice(shared))].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
/** Collapse a type text to one line so catalog rows stay one row. */
|
||||
function collapse(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** A type-literal member's string-literal type text, '' when absent or computed. */
|
||||
function literalMember(entry: ts.TypeLiteralNode | undefined, name: string): string {
|
||||
const member = namedMember(entry, name)
|
||||
if (member?.type === undefined) return ''
|
||||
return ts.isLiteralTypeNode(member.type) && ts.isStringLiteral(member.type.literal)
|
||||
? member.type.literal.text
|
||||
: ''
|
||||
}
|
||||
|
||||
/** A type-literal member's type text on one line, absent when the member is. */
|
||||
function memberTypeText(
|
||||
entry: ts.TypeLiteralNode | undefined,
|
||||
name: string,
|
||||
sf: ts.SourceFile,
|
||||
): string | undefined {
|
||||
const member = namedMember(entry, name)
|
||||
return member?.type === undefined ? undefined : collapse(member.type.getText(sf))
|
||||
}
|
||||
|
||||
/** One named property signature of a type literal. */
|
||||
function namedMember(entry: ts.TypeLiteralNode | undefined, name: string): ts.PropertySignature | undefined {
|
||||
if (entry === undefined) return undefined
|
||||
for (const member of entry.members) {
|
||||
if (ts.isPropertySignature(member) && memberName(member.name) === name) return member
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** A property name's text, quotes removed. */
|
||||
function memberName(name: ts.PropertyName): string {
|
||||
return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : name.getText()
|
||||
}
|
||||
|
||||
/** One string-literal property of an options object literal. */
|
||||
function stringProperty(options: ts.ObjectLiteralExpression, name: string): string | undefined {
|
||||
for (const property of options.properties) {
|
||||
if (!ts.isPropertyAssignment(property)) continue
|
||||
if (memberName(property.name) !== name) continue
|
||||
if (ts.isStringLiteral(property.initializer)) return property.initializer.text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The SlotMap keys a registration's `children` table declares. */
|
||||
function childKeys(options: ts.ObjectLiteralExpression): string[] {
|
||||
for (const property of options.properties) {
|
||||
if (!ts.isPropertyAssignment(property)) continue
|
||||
if (memberName(property.name) !== 'children') continue
|
||||
if (!ts.isObjectLiteralExpression(property.initializer)) return []
|
||||
return property.initializer.properties
|
||||
.flatMap(child => (child.name === undefined ? [] : [memberName(child.name)]))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** The component argument as written; a non-identifier expression is collapsed. */
|
||||
function componentText(argument: ts.Expression | undefined, sf: ts.SourceFile): string {
|
||||
if (argument === undefined) return '(none)'
|
||||
const text = collapse(argument.getText(sf))
|
||||
return text.length > 60 ? `${text.slice(0, 57)}…` : text
|
||||
}
|
||||
@@ -79,6 +79,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-commands': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
|
||||
'packages/client/ui-model-selection': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
|
||||
'packages/extensions/ui-cordis': { kind: 'indirect', reason: 'The definition card drives the host dynamic run/stop verbs that the model\'s cordis_run/cordis_stop tools also reach; the runner owns any model-visible effect.' },
|
||||
'packages/client/ui-permission-presets': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
|
||||
'packages/client/ui-settings-plugins': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' },
|
||||
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
|
||||
|
||||
Reference in New Issue
Block a user