mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/ci-native-windows-20260808
# Conflicts: # vendor/README.md
This commit is contained in:
@@ -21,13 +21,13 @@ describe('Cordis core API generation', () => {
|
||||
it('renders the five detailed pages from pinned vendor declarations', () => {
|
||||
const pages = renderCordisCoreApiPages()
|
||||
expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out))
|
||||
expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)')
|
||||
expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode')
|
||||
expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta')
|
||||
expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin')
|
||||
expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig')
|
||||
expect(pages.get('docs/cordis-api/context.md')).toContain('### ctx.extend(meta?)')
|
||||
expect(pages.get('docs/cordis-api/events.md')).toContain('## DispatchMode')
|
||||
expect(pages.get('docs/cordis-api/fiber.md')).toContain('## EffectMeta')
|
||||
expect(pages.get('docs/cordis-api/registry.md')).toContain('## Plugin')
|
||||
expect(pages.get('docs/cordis-api/service.md')).toContain('### Service.resolveConfig')
|
||||
|
||||
const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? ''
|
||||
const fiber = pages.get('docs/cordis-api/fiber.md') ?? ''
|
||||
expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.')
|
||||
expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.')
|
||||
expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.')
|
||||
@@ -39,7 +39,7 @@ describe('Cordis core API generation', () => {
|
||||
mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true })
|
||||
writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n')
|
||||
const page: CordisCoreApiPage = {
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
out: 'docs/cordis-api/service.md',
|
||||
title: 'Service',
|
||||
intro: 'Service API.',
|
||||
sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }],
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface CordisCoreApiPage {
|
||||
/** Explicit editorial grouping for the pinned Cordis core surface. */
|
||||
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/context.md',
|
||||
out: 'docs/cordis-api/context.md',
|
||||
title: 'Context',
|
||||
intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).',
|
||||
sections: [
|
||||
@@ -35,9 +35,9 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/events.md',
|
||||
out: 'docs/cordis-api/events.md',
|
||||
title: 'Events',
|
||||
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).',
|
||||
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated into each owning [subsystem page](../subsystems/core.md).',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
|
||||
@@ -45,7 +45,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/fiber.md',
|
||||
out: 'docs/cordis-api/fiber.md',
|
||||
title: 'Fiber',
|
||||
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.',
|
||||
sections: [
|
||||
@@ -59,7 +59,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/registry.md',
|
||||
out: 'docs/cordis-api/registry.md',
|
||||
title: 'Registry',
|
||||
intro: 'Plugin loading and dependency injection.',
|
||||
sections: [
|
||||
@@ -69,7 +69,7 @@ export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
out: 'docs/cordis-api/service.md',
|
||||
title: 'Service',
|
||||
intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.<name>`.',
|
||||
sections: [
|
||||
@@ -357,7 +357,7 @@ function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { do
|
||||
|
||||
function sourceLink(source: string): string {
|
||||
const [file, line] = source.split(':')
|
||||
return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})`
|
||||
return `[Source](../../${file}${line === undefined ? '' : `#L${line}`})`
|
||||
}
|
||||
|
||||
function unlink(text: string): string {
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
/** Locate the Cordis module merge used by the vendored core API projector. */
|
||||
/**
|
||||
* AST helpers shared by the Cordis generators: locate the Cordis module merge
|
||||
* in a source file and enumerate the `interface Context` keys it declares.
|
||||
* The vendored core API projector consumes the merge body; the per-subsystem
|
||||
* region generator's exhaustiveness backstop consumes the key scan.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/**
|
||||
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually mentions `interface Context`, yielding each file's cordis
|
||||
* module-merge body. Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the pattern is resolved against.
|
||||
* @param pattern - Glob selecting the TypeScript files to scan.
|
||||
* @returns One entry per file with a cordis module merge, in path order.
|
||||
*/
|
||||
export function contextMergeFiles(
|
||||
scanRoot: string,
|
||||
pattern: string,
|
||||
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
|
||||
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
|
||||
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('interface Context')) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
out.push({ rel, sf, text, body })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
@@ -13,3 +45,22 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `key: Type` property a `declare module 'cordis'` Context merge
|
||||
* declares in one module body.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for text extraction).
|
||||
* @returns key → declared type-name text, in declaration order.
|
||||
*/
|
||||
export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
@@ -777,7 +777,7 @@ function requiresLine(inject: string[]): string {
|
||||
}
|
||||
|
||||
/** Render one reference as a link: another plugin's config type → its section,
|
||||
* a curated core-data-structures name → its page, any other workspace type →
|
||||
* a curated subsystems name → its page, any other workspace type →
|
||||
* its source file, an external type → named with its module, unlinked. */
|
||||
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
const target = byName.get(ref.specifier)
|
||||
@@ -785,7 +785,7 @@ function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
|
||||
}
|
||||
const page = LINK_MAP[ref.imported]
|
||||
if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
|
||||
if (page) return `[\`${ref.alias}\`](subsystems/${page})`
|
||||
if (target) return `[\`${ref.alias}\`](../${target.entry})`
|
||||
return `\`${ref.alias}\` (\`${ref.specifier}\`)`
|
||||
}
|
||||
@@ -819,7 +819,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
'',
|
||||
'# Plugin Config Catalog',
|
||||
'',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the generated `cordis-surface` region on each [subsystem page](subsystems/core.md), the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [subsystems/](subsystems/core.md) documents the types these declarations reference.',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
|
||||
'',
|
||||
|
||||
144
scripts/gen-cordis-catalog-record.spec.ts
Normal file
144
scripts/gen-cordis-catalog-record.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Negative-path coverage for the guarded pair auto-record
|
||||
* (`maybeRecordPair`): the safety property is that regeneration re-records a
|
||||
* pair's `.i18n.yaml` ONLY for a region-confined write over a well-formed,
|
||||
* previously-consistent record — every other state is left for the pairing
|
||||
* gate to report.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { maybeRecordPair, REGION_BEGIN, REGION_END, spliceRegion } from './gen-cordis-catalog.ts'
|
||||
import { blobHash, renderPairMeta } from './translation-pairing.ts'
|
||||
|
||||
const PAGE = 'docs/subsystems/fix.md'
|
||||
const ZH = 'docs/subsystems/fix.zh.md'
|
||||
const META = 'docs/subsystems/fix.i18n.yaml'
|
||||
|
||||
function page(prose: string, region: string): string {
|
||||
return `# Fix\n\n${prose}\n\n${REGION_BEGIN}\n${region}\n${REGION_END}\n`
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Lay out a pair on disk and return { root, before } for a regeneration that already wrote `current`. */
|
||||
function setup(options: {
|
||||
beforeEn: string
|
||||
beforeZh: string
|
||||
currentEn: string
|
||||
currentZh: string
|
||||
meta?: string | null
|
||||
omitZhSnapshot?: boolean
|
||||
}): { root: string; before: Map<string, Buffer> } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'record-guard-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'docs/subsystems'), { recursive: true })
|
||||
writeFileSync(join(root, PAGE), options.currentEn)
|
||||
writeFileSync(join(root, ZH), options.currentZh)
|
||||
const meta = options.meta === undefined
|
||||
? renderPairMeta(PAGE, blobHash(Buffer.from(options.beforeEn)), ZH, blobHash(Buffer.from(options.beforeZh)))
|
||||
: options.meta
|
||||
if (meta !== null) writeFileSync(join(root, META), meta)
|
||||
const before = new Map<string, Buffer>([[PAGE, Buffer.from(options.beforeEn)]])
|
||||
if (!options.omitZhSnapshot) before.set(ZH, Buffer.from(options.beforeZh))
|
||||
return { root, before }
|
||||
}
|
||||
|
||||
describe('maybeRecordPair', () => {
|
||||
const beforeEn = page('prose.', 'old region')
|
||||
const beforeZh = page('散文。', 'old region')
|
||||
const currentEn = page('prose.', 'new region')
|
||||
const currentZh = page('散文。', 'new region')
|
||||
|
||||
it('re-records a region-confined write over a consistent record', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(true)
|
||||
expect(readFileSync(join(root, META), 'utf8'))
|
||||
.toBe(renderPairMeta(PAGE, blobHash(Buffer.from(currentEn)), ZH, blobHash(Buffer.from(currentZh))))
|
||||
})
|
||||
|
||||
it('refuses when the pair was already out of sync before the run', () => {
|
||||
const stale = renderPairMeta(PAGE, blobHash(Buffer.from('drifted long ago\n')), ZH, blobHash(Buffer.from(beforeZh)))
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: stale })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(stale)
|
||||
})
|
||||
|
||||
it('refuses a malformed record even when its hashes are current', () => {
|
||||
// A renamed key with preserved hashes must stay the pairing gate's error,
|
||||
// never become valid through regeneration.
|
||||
const renamedKeys = [
|
||||
'# comment',
|
||||
`fixXmd: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
|
||||
'',
|
||||
].join('\n')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: renamedKeys })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(renamedKeys)
|
||||
})
|
||||
|
||||
it('refuses a record with extra entries', () => {
|
||||
const extra = renderPairMeta(PAGE, blobHash(Buffer.from(beforeEn)), ZH, blobHash(Buffer.from(beforeZh)))
|
||||
+ `other.md: ${blobHash(Buffer.from(beforeEn))}\n`
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: extra })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a record with a duplicated expected key', () => {
|
||||
// Map#set would collapse the duplicate back to size 2; the parser must
|
||||
// reject the repeat instead of letting the guard accept the record.
|
||||
const duplicated = [
|
||||
`fix.md: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.md: ${blobHash(Buffer.from(beforeEn))}`,
|
||||
`fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
|
||||
'',
|
||||
].join('\n')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: duplicated })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
expect(readFileSync(join(root, META), 'utf8')).toBe(duplicated)
|
||||
})
|
||||
|
||||
it('refuses when prose drifted alongside the region write', () => {
|
||||
const proseDrift = page('prose, edited by a human.', 'new region')
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn: proseDrift, currentZh })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a brand-new pair with no record', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: null })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when a side has no pre-write snapshot', () => {
|
||||
const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, omitZhSnapshot: true })
|
||||
expect(maybeRecordPair(PAGE, before, root)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spliceRegion', () => {
|
||||
it('replaces exactly the cordis-surface region', () => {
|
||||
const doc = `# T\n\nprose\n\n${REGION_BEGIN}\nold\n${REGION_END}\ntail\n`
|
||||
expect(spliceRegion(doc, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toBe(`# T\n\nprose\n\n${REGION_BEGIN}\nnew\n${REGION_END}\ntail\n`)
|
||||
})
|
||||
|
||||
it('fails loud on a page carrying only some other generator\'s region', () => {
|
||||
// Another generator's markers satisfy the generic region grammar but must
|
||||
// never be overwritten by THIS generator's splice.
|
||||
const foreign = '# T\n\n<!-- BEGIN GENERATED other-surface (other-gen.ts) — do not edit between markers -->\ntheirs\n<!-- END GENERATED other-surface -->\n'
|
||||
expect(() => spliceRegion(foreign, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toThrow('expected exactly 1 cordis-surface region, found 0 BEGIN/0 END')
|
||||
})
|
||||
|
||||
it('fails loud on duplicate cordis-surface markers', () => {
|
||||
const doubled = `${REGION_BEGIN}\na\n${REGION_END}\n${REGION_BEGIN}\nb\n${REGION_END}\n`
|
||||
expect(() => spliceRegion(doubled, `${REGION_BEGIN}\nnew\n${REGION_END}`))
|
||||
.toThrow('found 2 BEGIN/2 END')
|
||||
})
|
||||
})
|
||||
@@ -1,51 +1,186 @@
|
||||
/**
|
||||
* Generate committed Cordis artifacts from the Typert catalog projector and
|
||||
* the independent vendored-core projector.
|
||||
* Generate the per-subsystem Cordis service/event reference regions from the
|
||||
* Typert catalog projection. Every harness `ctx.<key>` service and event scope
|
||||
* maps to exactly one `docs/subsystems/` page through the curated tables below;
|
||||
* the generator injects each page's surface between its GENERATED markers —
|
||||
* byte-identically into both language sides of the pair — and re-records a
|
||||
* pair's `.i18n.yaml` only when nothing outside the region changed. The
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
projectCordisCatalog,
|
||||
renderEvents,
|
||||
renderServices,
|
||||
renderInheritedPage,
|
||||
renderPageRegion,
|
||||
REGION_BEGIN,
|
||||
REGION_END,
|
||||
} from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { contextKeyMap, contextMergeFiles } from './cordis-walk.ts'
|
||||
import {
|
||||
blobHash,
|
||||
parsePairMeta,
|
||||
partitionGeneratedRegions,
|
||||
renderPairMeta,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
const OUT_SERVICES = 'docs/cordis-catalog/services.md'
|
||||
const OUT_RUNTIME_API = 'packages/cordis/tool-cordis/src/api-catalog.ts'
|
||||
const SUBSYSTEMS_DIR = 'docs/subsystems'
|
||||
const OUT_INHERITED = 'docs/cordis-api/inherited.md'
|
||||
const OUT_RUNTIME_API = 'packages/self-modification/tool-cordis/src/api-catalog.ts'
|
||||
|
||||
/** One primary core-data-structures page per project type used by a generated signature. */
|
||||
export { REGION_BEGIN, REGION_END }
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness `ctx.<key>` service the
|
||||
* projection discovers. Fail-closed both ways: a discovered key absent here
|
||||
* and an entry whose key the projection no longer discovers are both hard
|
||||
* errors, so the partition can never silently drift from the service surface.
|
||||
*/
|
||||
export const SERVICE_PAGE: Record<string, string> = {
|
||||
agentLoop: 'core.md',
|
||||
agents: 'core.md',
|
||||
approval: 'approval.md',
|
||||
bash: 'bash.md',
|
||||
bashEnv: 'bash.md',
|
||||
clientModuleHost: 'client-modules.md',
|
||||
codeRuntime: 'code-runtime.md',
|
||||
commands: 'commands.md',
|
||||
compact: 'compaction.md',
|
||||
credentials: 'credentials.md',
|
||||
directoryPicker: 'workspace.md',
|
||||
e2b: 'subprocess.md',
|
||||
fs: 'filesystem.md',
|
||||
goals: 'goal.md',
|
||||
httpServer: 'http-server.md',
|
||||
invariants: 'invariants.md',
|
||||
llm: 'llm-streaming.md',
|
||||
permission: 'permission.md',
|
||||
planMode: 'plan.md',
|
||||
pty: 'pty.md',
|
||||
sandbox: 'sandbox.md',
|
||||
sandboxPolicy: 'sandbox.md',
|
||||
sessionPersistence: 'persistence.md',
|
||||
sessionQuery: 'session-query.md',
|
||||
sessionReferences: 'session-reference.md',
|
||||
sessionProjectionCache: 'session-projection.md',
|
||||
sessionProjections: 'session-projection.md',
|
||||
sessions: 'session.md',
|
||||
settings: 'settings.md',
|
||||
sessionTitle: 'session-title.md',
|
||||
skills: 'skills.md',
|
||||
spillStore: 'spill.md',
|
||||
storage: 'storage.md',
|
||||
storageDomain: 'storage.md',
|
||||
subagents: 'subagent.md',
|
||||
subprocess: 'subprocess.md',
|
||||
systemPrompt: 'system-prompt.md',
|
||||
tasks: 'tasks.md',
|
||||
telemetry: 'telemetry.md',
|
||||
tokenMeter: 'token-meter.md',
|
||||
toolResultPrune: 'compaction.md',
|
||||
tools: 'tools.md',
|
||||
typert: 'typert.md',
|
||||
typertGateway: 'typert.md',
|
||||
userInteraction: 'user-interaction.md',
|
||||
web: 'web.md',
|
||||
workflows: 'workflow.md',
|
||||
workspace: 'workspace.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* Context merge under `packages/x/x/src/*.ts` — not only root `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.
|
||||
* 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.
|
||||
*/
|
||||
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',
|
||||
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract',
|
||||
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract',
|
||||
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
||||
headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract',
|
||||
launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract',
|
||||
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface',
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness event scope (the segment
|
||||
* before the first `/`). Fail-closed exactly like {@link SERVICE_PAGE}.
|
||||
* `slash` lives with the human-command surface: the client slash-input
|
||||
* protocol parses toward command invocation and `dsh-ui-slash` owns the
|
||||
* declarations, but commands.md owns the cross-package command story.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent': 'core.md',
|
||||
'agent-loop': 'core.md',
|
||||
'approval': 'approval.md',
|
||||
'commands': 'commands.md',
|
||||
'credentials': 'credentials.md',
|
||||
'domain': 'storage.md',
|
||||
'fs': 'filesystem.md',
|
||||
'goal': 'goal.md',
|
||||
'llm': 'llm-streaming.md',
|
||||
'session': 'session.md',
|
||||
'settings': 'settings.md',
|
||||
'skills': 'skills.md',
|
||||
'subagent': 'subagent.md',
|
||||
'system-prompt': 'system-prompt.md',
|
||||
'telemetry': 'telemetry.md',
|
||||
'tools': 'tools.md',
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* One primary subsystems page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
* reuse the type-equivalence manifest's map-symbol entries and some symbols
|
||||
* appear on more than one page.
|
||||
*/
|
||||
export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
Agent: 'core.md',
|
||||
AgentCancelCause: 'core.md',
|
||||
AgentFactory: 'core.md',
|
||||
AgentHandle: 'core.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
ContinuationDecision: 'core.md',
|
||||
ContinuationStop: 'core.md',
|
||||
GenerateOptions: 'core.md',
|
||||
MessageId: 'core.md',
|
||||
HookContext: 'core.md',
|
||||
ContentBlock: 'llm-streaming.md',
|
||||
CreateAgentOptions: 'core.md',
|
||||
GenerateOptions: 'llm-streaming.md',
|
||||
InboxItem: 'core.md',
|
||||
InboxPlacement: 'core.md',
|
||||
MessageId: 'llm-streaming.md',
|
||||
ResumeAgentOptions: 'core.md',
|
||||
SettleReason: 'core.md',
|
||||
AdapterRegistrationHandle: 'core.md',
|
||||
DirectoryRegistrationHandle: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
LlmModelContext: 'core.md',
|
||||
LlmModelReasoningInfo: 'core.md',
|
||||
LlmResolvedModelInfo: 'core.md',
|
||||
AdapterRegistrationHandle: 'llm-streaming.md',
|
||||
DirectoryRegistrationHandle: 'llm-streaming.md',
|
||||
LlmCallConfig: 'llm-streaming.md',
|
||||
LlmModelContext: 'llm-streaming.md',
|
||||
LlmModelReasoningInfo: 'llm-streaming.md',
|
||||
LlmResolvedModelInfo: 'llm-streaming.md',
|
||||
LlmFailure: 'llm-streaming.md',
|
||||
LlmModelInfo: 'core.md',
|
||||
LlmProviderInfo: 'core.md',
|
||||
LlmConfigurableProvider: 'core.md',
|
||||
LlmModelDiscoveryRequest: 'core.md',
|
||||
LlmDiscoveredModel: 'core.md',
|
||||
LlmModelInfo: 'llm-streaming.md',
|
||||
LlmProviderInfo: 'llm-streaming.md',
|
||||
LlmConfigurableProvider: 'llm-streaming.md',
|
||||
LlmModelDiscoveryRequest: 'llm-streaming.md',
|
||||
LlmDiscoveredModel: 'llm-streaming.md',
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
Message: 'llm-streaming.md',
|
||||
MessageSource: 'llm-streaming.md',
|
||||
UserMessage: 'session.md',
|
||||
PreStepDecision: 'core.md',
|
||||
PreStepContext: 'core.md',
|
||||
@@ -54,7 +189,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PreparedReferencedMessage: 'session-reference.md',
|
||||
SessionReferenceCandidate: 'session-reference.md',
|
||||
SessionReferenceInput: 'session-reference.md',
|
||||
SessionEvent: 'core.md',
|
||||
SessionEvent: 'session.md',
|
||||
SessionId: 'core.md',
|
||||
SessionStartSource: 'core.md',
|
||||
SessionLogSnapshot: 'session-query.md',
|
||||
@@ -92,12 +227,12 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
FsWriteIntent: 'filesystem.md',
|
||||
FsWriteOutcome: 'filesystem.md',
|
||||
CreateGoalRequest: 'goal.md',
|
||||
CreateGoalResult: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
CreateGoalResult: 'goal.md',
|
||||
CommandDefinition: 'commands.md',
|
||||
CommandDescriptor: 'commands.md',
|
||||
CommandResult: 'commands.md',
|
||||
@@ -230,8 +365,34 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
WebSearchRequest: 'web.md',
|
||||
WebSearchResult: 'web.md',
|
||||
WorkflowRun: 'workflow.md',
|
||||
PresetOption: 'permission.md',
|
||||
PresetSpec: 'permission.md',
|
||||
InvariantInstaller: 'invariants.md',
|
||||
WebRoute: 'http-server.md',
|
||||
StorageBackend: 'storage.md',
|
||||
StorageForms: 'storage.md',
|
||||
Domain: 'storage.md',
|
||||
DomainSpec: 'storage.md',
|
||||
DomainChanged: 'storage.md',
|
||||
DomainFacility: 'storage.md',
|
||||
Workspace: 'workspace.md',
|
||||
WorkspaceId: 'workspace.md',
|
||||
WebBootGraph: 'client-modules.md',
|
||||
TelemetryRecord: 'telemetry.md',
|
||||
WorkflowRunInfo: 'workflow.md',
|
||||
WorkflowStartRequest: 'workflow.md',
|
||||
ProjectionDefinition: 'session-projection.md',
|
||||
SessionProjectionMap: 'session-projection.md',
|
||||
ProjectionChangeListener: 'session-projection.md',
|
||||
ProjectionSnapshot: 'session-projection.md',
|
||||
ProjectionCheckpoint: 'session-projection.md',
|
||||
DirectoryPickerCapability: 'workspace.md',
|
||||
TypertContribution: 'invariants.md',
|
||||
TypertFace: 'invariants.md',
|
||||
TypertPackageFilter: 'invariants.md',
|
||||
TypertPackageRecord: 'invariants.md',
|
||||
TypertSchemaFilter: 'invariants.md',
|
||||
TypertSchemaRecord: 'invariants.md',
|
||||
}
|
||||
|
||||
/** TypeScript lib and pinned framework types with no repository-owned data page. */
|
||||
@@ -248,69 +409,39 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Readonly',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the core-data catalog. */
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
|
||||
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
|
||||
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
|
||||
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
|
||||
DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
|
||||
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
|
||||
DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md',
|
||||
StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts',
|
||||
StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
|
||||
ProjectionDefinition: 'projection unit contract is owned by packages/session-projection/session-projection/README.md',
|
||||
SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts',
|
||||
ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts',
|
||||
CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts',
|
||||
TypertContribution: 'registry contribution contract is owned by packages/typert/registry/README.md',
|
||||
TypertFace: 'registry face identity is owned by packages/typert/registry/README.md',
|
||||
TypertPackageFilter: 'registry package query filter is owned by packages/typert/registry/README.md',
|
||||
TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/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',
|
||||
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
|
||||
WebRoute: 'route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/ui/permission/src/types.ts',
|
||||
KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
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',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
Workspace: 'workspace entity contract is owned by packages/workspace/workspace/README.md',
|
||||
WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md',
|
||||
}
|
||||
|
||||
/** Repository data policy consumed by the Cordis catalog projector. */
|
||||
@@ -328,8 +459,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' },
|
||||
{ name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
||||
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
||||
@@ -350,15 +480,171 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
],
|
||||
}
|
||||
|
||||
/** CLI entry: default writes every artifact; `--check` reports stale files.
|
||||
|
||||
/**
|
||||
* Splice a page's generated cordis-surface region into its Markdown content.
|
||||
* The page must contain exactly one cordis-surface region (the markers are
|
||||
* part of the hand-owned page skeleton once, then owned by the generator);
|
||||
* zero or several is a partition error the caller reports with the page path.
|
||||
* The match is on THIS generator's exact markers, not the generic region
|
||||
* grammar, so a page carrying only some other generator's region fails loud
|
||||
* instead of having that region overwritten.
|
||||
* @param content - the page's current full Markdown text.
|
||||
* @param region - the freshly rendered marker-delimited region.
|
||||
* @returns the page text with the region replaced.
|
||||
*/
|
||||
export function spliceRegion(content: string, region: string): string {
|
||||
const lines = content.split('\n')
|
||||
const begins = lines.flatMap((line, index) => (line === REGION_BEGIN ? [index] : []))
|
||||
const ends = lines.flatMap((line, index) => (line === REGION_END ? [index] : []))
|
||||
if (begins.length !== 1 || ends.length !== 1) {
|
||||
throw new Error(`expected exactly 1 cordis-surface region, found ${begins.length} BEGIN/${ends.length} END; add the BEGIN/END cordis-surface markers once`)
|
||||
}
|
||||
const begin = begins[0] ?? -1
|
||||
const end = ends[0] ?? -1
|
||||
if (end < begin) throw new Error('cordis-surface END marker precedes its BEGIN')
|
||||
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every generated artifact: the inherited-tier page, the model-facing
|
||||
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
||||
* documents with the injected region. Fail-loud partition checks live here: an
|
||||
* unmapped service/event scope, a mapping whose page file does not exist, a
|
||||
* curated entry whose key/scope the projection no longer discovers, and a
|
||||
* mapped page missing its markers are all aggregated errors.
|
||||
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
||||
*/
|
||||
export function computeOutputs(): [string, string][] {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const services = [...model.services]
|
||||
const events = [...model.events]
|
||||
const problems: string[] = []
|
||||
|
||||
const discoveredKeys = new Set(services.map(s => s.key))
|
||||
const discoveredScopes = new Set(events.map(e => e.scope))
|
||||
for (const s of services) {
|
||||
if (!Object.hasOwn(SERVICE_PAGE, s.key)) problems.push(`service ctx.${s.key} (${s.source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of discoveredScopes) {
|
||||
if (!Object.hasOwn(EVENT_SCOPE_PAGE, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_PAGE)) {
|
||||
if (!discoveredKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(EVENT_SCOPE_PAGE)) {
|
||||
if (!discoveredScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. This independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
const declaredKeys = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/*.ts')) {
|
||||
for (const key of contextKeyMap(body, sf).keys()) {
|
||||
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
||||
}
|
||||
}
|
||||
for (const [key, rel] of declaredKeys) {
|
||||
const rendered = discoveredKeys.has(key)
|
||||
const exempt = Object.hasOwn(SERVICE_WALK_EXEMPTIONS, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_WALK_EXEMPTIONS)) {
|
||||
if (!declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
|
||||
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
||||
const outputs: [string, string][] = [
|
||||
[OUT_INHERITED, renderInheritedPage(CORDIS_CATALOG_POLICY)],
|
||||
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
|
||||
]
|
||||
for (const page of pages) {
|
||||
const region = renderPageRegion(
|
||||
page,
|
||||
services.filter(s => SERVICE_PAGE[s.key] === page),
|
||||
events.filter(e => EVENT_SCOPE_PAGE[e.scope] === page),
|
||||
CORDIS_CATALOG_POLICY,
|
||||
)
|
||||
for (const side of [page, page.replace(/\.md$/, '.zh.md')]) {
|
||||
const rel = `${SUBSYSTEMS_DIR}/${side}`
|
||||
let current: string
|
||||
try {
|
||||
current = readFileSync(resolve(root, rel), 'utf8')
|
||||
} catch {
|
||||
// Both pair sides must exist before a region can be injected; the
|
||||
// pairing gate owns pair completeness, this generator names the miss.
|
||||
problems.push(`${rel}: mapped subsystems page does not exist.`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
outputs.push([rel, spliceRegion(current, region)])
|
||||
} catch (error) {
|
||||
problems.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} page violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
return outputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-record a pair's `.i18n.yaml` after a region write ONLY when the write is
|
||||
* region-confined: both sides' region-stripped content must be byte-equal to
|
||||
* the region-stripped previous content whose hashes the record holds. The
|
||||
* caller supplies the previous bytes (read before writing); human-content
|
||||
* drift leaves the record untouched so the pairing gate still demands the
|
||||
* normal translation flow.
|
||||
* @param pageRel - repo-relative English page path (`docs/subsystems/x.md`).
|
||||
* @param before - pre-write bytes per repo-relative path.
|
||||
* @param scanRoot - repository root override for tests.
|
||||
* @returns true when the record was refreshed.
|
||||
*/
|
||||
export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, scanRoot: string = root): boolean {
|
||||
const zhRel = pageRel.replace(/\.md$/, '.zh.md')
|
||||
const metaRel = pageRel.replace(/\.md$/, '.i18n.yaml')
|
||||
const metaAbs = resolve(scanRoot, metaRel)
|
||||
let meta: string
|
||||
try {
|
||||
meta = readFileSync(metaAbs, 'utf8')
|
||||
} catch {
|
||||
// No record yet: a brand-new pair is recorded by the author's --write
|
||||
// after review, never silently by regeneration.
|
||||
return false
|
||||
}
|
||||
// The record must be exactly the well-formed two-entry shape for THIS pair;
|
||||
// a malformed or renamed-key sidecar is the pairing gate's problem to
|
||||
// report, never something regeneration silently repairs into validity.
|
||||
const recorded = parsePairMeta(meta)
|
||||
const names = [pageRel, zhRel].map(rel => rel.split('/').at(-1) ?? rel)
|
||||
if (!recorded || recorded.size !== 2 || !names.every(name => recorded.has(name))) return false
|
||||
for (const rel of [pageRel, zhRel]) {
|
||||
const previous = before.get(rel)
|
||||
if (!previous) return false
|
||||
if (recorded.get(rel.split('/').at(-1) ?? rel) !== blobHash(previous)) return false
|
||||
const current = readFileSync(resolve(scanRoot, rel))
|
||||
const strippedBefore = partitionGeneratedRegions(previous.toString('utf8')).stripped
|
||||
const strippedAfter = partitionGeneratedRegions(current.toString('utf8')).stripped
|
||||
if (strippedBefore !== strippedAfter) return false
|
||||
}
|
||||
const source = readFileSync(resolve(scanRoot, pageRel))
|
||||
const zh = readFileSync(resolve(scanRoot, zhRel))
|
||||
writeFileSync(metaAbs, renderPairMeta(pageRel, blobHash(source), zhRel, blobHash(zh)))
|
||||
return true
|
||||
}
|
||||
|
||||
/** CLI entry: default regenerates every artifact, `--check` fails if any is
|
||||
* stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed files nor calls process.exit.
|
||||
* @returns nothing; writes files or reports freshness through the process.
|
||||
*/
|
||||
export function main(): void {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const outputs: [string, string][] = [
|
||||
[OUT_EVENTS, renderEvents([...model.events], CORDIS_CATALOG_POLICY)],
|
||||
[OUT_SERVICES, renderServices([...model.services], CORDIS_CATALOG_POLICY)],
|
||||
[OUT_RUNTIME_API, projector.renderRuntimeApi(model)],
|
||||
...computeOutputs(),
|
||||
...renderCordisCoreApiPages(),
|
||||
]
|
||||
if (process.argv.includes('--check')) {
|
||||
@@ -368,25 +654,51 @@ export function main(): void {
|
||||
try {
|
||||
committed = readFileSync(resolve(root, out), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT is expected; either read failure has the same remedy.
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed !== content) stale.push(out)
|
||||
}
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s)/region(s) are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
||||
console.error(`gen-cordis-catalog: stale — ${stale.join(', ')}. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const before = new Map<string, Buffer>()
|
||||
for (const [out] of outputs) {
|
||||
try {
|
||||
before.set(out, readFileSync(resolve(root, out)))
|
||||
} catch {
|
||||
// First generation of this artifact; nothing to guard, nothing to record.
|
||||
}
|
||||
}
|
||||
let changedPages = 0
|
||||
let recorded = 0
|
||||
for (const [out, content] of outputs) {
|
||||
const destination = resolve(root, out)
|
||||
if (before.get(out)?.toString('utf8') === content) continue
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
changedPages++
|
||||
}
|
||||
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
|
||||
for (const page of [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])]) {
|
||||
const rel = `${SUBSYSTEMS_DIR}/${page}`
|
||||
const zhRel = rel.replace(/\.md$/, '.zh.md')
|
||||
const wroteEither = [rel, zhRel].some((side) => {
|
||||
const previous = before.get(side)
|
||||
return previous !== undefined && previous.toString('utf8') !== readFileSync(resolve(root, side), 'utf8')
|
||||
})
|
||||
if (wroteEither && maybeRecordPair(rel, before)) recorded++
|
||||
}
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} artifact(s) computed, ${changedPages} written, ${recorded} pair record(s) refreshed.`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main()
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -1344,7 +1344,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
|
||||
return [
|
||||
...generatedHeader('Documentation Graph Index'),
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).',
|
||||
'',
|
||||
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'',
|
||||
|
||||
@@ -31,7 +31,7 @@ const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** Primary core-data-structures page for linked payload types. */
|
||||
/** Primary subsystems page for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
CallId: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
@@ -330,7 +330,7 @@ function typeLinks(payload: string): string {
|
||||
if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
|
||||
}
|
||||
if (seen.size === 0) return ''
|
||||
const links = [...seen].sort().map(n => `[${n}](core-data-structures/${LINK_MAP[n]})`)
|
||||
const links = [...seen].sort().map(n => `[${n}](subsystems/${LINK_MAP[n]})`)
|
||||
return `Types: ${links.join(' · ')}`
|
||||
}
|
||||
|
||||
@@ -352,11 +352,11 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'# Session Persistence Event Catalog',
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('tierExternalDeps', () => {
|
||||
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'package.json': { devDependencies: { shared: '^1' } },
|
||||
'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'packages/interaction/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
|
||||
})
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
source: 'packages/ui/tool-ask-user/src/index.ts',
|
||||
source: 'packages/interaction/tool-ask-user/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.userInteraction'],
|
||||
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
|
||||
async mount(ctx) {
|
||||
@@ -226,7 +226,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
source: 'packages/cordis/tool-cordis/src/index.ts',
|
||||
source: 'packages/self-modification/tool-cordis/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'],
|
||||
async mount(ctx) {
|
||||
@@ -608,7 +608,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'# Tool Schema Catalog',
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [subsystem pages](subsystems/core.md) (the types plus each page\'s generated `cordis-surface` wiring region) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -269,28 +269,41 @@ describe('docsPages locale routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('projects translated core-data pages while retaining explicit English fallbacks', () => {
|
||||
it('indexes every subsystem page in both sides of the folder README', () => {
|
||||
const pages = globSync(join(repositoryRoot, 'docs/subsystems/*.md'))
|
||||
.map(page => basename(page))
|
||||
.filter(page => !page.endsWith('.zh.md') && page !== 'README.md')
|
||||
.sort()
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
for (const readme of ['README.md', 'README.zh.md']) {
|
||||
const rows = readFileSync(join(repositoryRoot, 'docs/subsystems', readme), 'utf8')
|
||||
const missing = pages.filter(page => !rows.includes(`| [${page}](${page}) |`))
|
||||
expect(missing, `${readme} must carry one table row per subsystem page`).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('projects translated subsystem pages while retaining explicit English fallbacks', () => {
|
||||
const rootPages = docsPages.filter(page => (
|
||||
page.locale === 'root' && page.route.startsWith('reference/core-data-structures/')
|
||||
page.locale === 'root' && page.route.startsWith('reference/subsystems/')
|
||||
))
|
||||
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
|
||||
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
|
||||
|
||||
expect(translated).toHaveLength(20)
|
||||
expect(translated).toHaveLength(39)
|
||||
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
|
||||
expect(fallbacks.map(page => page.source).sort()).toEqual([
|
||||
'docs/core-data-structures/commands.md',
|
||||
'docs/core-data-structures/goal.md',
|
||||
'docs/core-data-structures/pty.md',
|
||||
'docs/subsystems/commands.md',
|
||||
'docs/subsystems/goal.md',
|
||||
'docs/subsystems/pty.md',
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes the Cordis core API under matching locale structures', () => {
|
||||
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
|
||||
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md', 'inherited.md']
|
||||
for (const file of files) {
|
||||
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
|
||||
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
|
||||
expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`)
|
||||
expect(root?.source).toBe(`docs/cordis-api/${file}`)
|
||||
expect(root?.section).toBe('Cordis API')
|
||||
expect(english?.source).toBe(root?.source)
|
||||
expect(english?.section).toBe('Cordis Core API')
|
||||
|
||||
@@ -131,6 +131,12 @@ function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'
|
||||
return { start, end: rawNode.length }
|
||||
}
|
||||
|
||||
// `#fragment` suffixes pass through verbatim. Generated cordis-surface
|
||||
// headings carry explicit `<a id>` anchors with the GitHub slug, so those
|
||||
// fragments resolve on the published site too; hand-written headings rely on
|
||||
// VitePress's own slugger, which differs from GitHub's for punctuation-heavy
|
||||
// text — hand-authored cross-page fragments should prefer plain-text headings
|
||||
// or explicit anchors.
|
||||
function splitTarget(url: string): { path: string; suffix: string } {
|
||||
const boundary = url.search(/[?#]/)
|
||||
if (boundary === -1) return { path: url, suffix: '' }
|
||||
|
||||
@@ -312,7 +312,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
pnpmExec('dsh-source-launch-smoke', [
|
||||
'vitest',
|
||||
@@ -619,7 +619,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/scaffold/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
'packages/api/remotes/tests/built-lib.e2e.ts',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@
|
||||
"docs/agent-lifecycle.md",
|
||||
"docs/capability-seams.md",
|
||||
"docs/config-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/cordis-api/",
|
||||
"docs/event-producer-consumer.md",
|
||||
"docs/graph-atlas.md",
|
||||
"docs/i18n/style-samples.md",
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
translationPairPaths,
|
||||
} from './translation-pairing-record.ts'
|
||||
import {
|
||||
blobHash,
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
partitionGeneratedRegions,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
@@ -292,3 +294,42 @@ describe('pair CLI arguments', () => {
|
||||
expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generated regions', () => {
|
||||
const BEGIN = '<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->'
|
||||
const END = '<!-- END GENERATED cordis-surface -->'
|
||||
|
||||
it('partitions marker-delimited regions from the hand-owned remainder', () => {
|
||||
const doc = `# T\n\nprose\n\n${BEGIN}\ninjected\n${END}\ntail\n`
|
||||
const { regions, stripped } = partitionGeneratedRegions(doc)
|
||||
expect(regions).toEqual([`${BEGIN}\ninjected\n${END}`])
|
||||
expect(stripped).toBe('# T\n\nprose\n\ntail\n')
|
||||
})
|
||||
|
||||
it('treats a document without markers as one hand-owned remainder', () => {
|
||||
const { regions, stripped } = partitionGeneratedRegions('# T\n\nprose\n')
|
||||
expect(regions).toEqual([])
|
||||
expect(stripped).toBe('# T\n\nprose\n')
|
||||
})
|
||||
|
||||
it('rejects unbalanced or nested markers', () => {
|
||||
expect(() => partitionGeneratedRegions(`${END}\n`)).toThrow('without a BEGIN')
|
||||
expect(() => partitionGeneratedRegions(`${BEGIN}\n`)).toThrow('without an END')
|
||||
expect(() => partitionGeneratedRegions(`${BEGIN}\n${BEGIN}\n${END}\n`)).toThrow('nested')
|
||||
})
|
||||
|
||||
it('rejects mismatched slugs and malformed marker lines', () => {
|
||||
expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a -->\nx\n<!-- END GENERATED b -->\n'))
|
||||
.toThrow("END slug 'b' does not match its BEGIN slug 'a'")
|
||||
expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a --> trailing\nx\n<!-- END GENERATED a -->\n'))
|
||||
.toThrow('malformed generated region marker line')
|
||||
expect(() => partitionGeneratedRegions('x\n<!-- END GENERATED a --> tail\n'))
|
||||
.toThrow('malformed generated region marker line')
|
||||
})
|
||||
|
||||
it('computes the exact git blob hash', () => {
|
||||
// `git hash-object` of the empty file and of "x\n" — pinned upstream values.
|
||||
expect(blobHash(Buffer.from(''))).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391')
|
||||
expect(blobHash(Buffer.from('x\n'))).toBe('587be6b4c3f93f93c489c0111bba5596147a26cb')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,122 @@
|
||||
* Pure parsing and structural helpers for the bilingual-document pairing
|
||||
* gate. Kept separate from the CLI so corpus discovery and signature behavior
|
||||
* can be regression-tested without reading or mutating the repository tree.
|
||||
* Also the one home of the generated-region grammar and the pair-record
|
||||
* primitives, shared by the pairing gate and the region-injecting generators.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { basename } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** Complete opening marker line: `<!-- BEGIN GENERATED <slug> … -->` (slug captured). */
|
||||
const GENERATED_REGION_BEGIN_LINE = /^<!-- BEGIN GENERATED (\S+)(?: [^>]*)? -->$/
|
||||
/** Complete closing marker line: `<!-- END GENERATED <slug> -->` (slug captured). */
|
||||
const GENERATED_REGION_END_LINE = /^<!-- END GENERATED (\S+) -->$/
|
||||
/** Loose marker detector: any line that LOOKS like a region marker must parse as one. */
|
||||
const GENERATED_REGION_MARKER_HINT = /^<!-- (?:BEGIN|END) GENERATED /
|
||||
|
||||
/**
|
||||
* Extract every generated region (markers included) and the document with
|
||||
* those regions removed. Regions are line-delimited: a marker occupies its
|
||||
* whole line, must be a complete well-formed marker, and the closing slug
|
||||
* must match the opener. The stripped form is what "human content" means for
|
||||
* the region-aware pair-record guard.
|
||||
*
|
||||
* @param content - Full Markdown document text.
|
||||
* @returns The regions in document order and the region-free remainder.
|
||||
* @throws Error on an unopened END, unclosed BEGIN, nested BEGIN, malformed
|
||||
* marker line, or a closing slug that does not match its opener.
|
||||
*/
|
||||
export function partitionGeneratedRegions(content: string): { regions: string[]; stripped: string } {
|
||||
const lines = content.split('\n')
|
||||
const regions: string[] = []
|
||||
const kept: string[] = []
|
||||
let open: { slug: string; lines: string[] } | null = null
|
||||
for (const line of lines) {
|
||||
const begin = GENERATED_REGION_BEGIN_LINE.exec(line)
|
||||
if (begin?.[1]) {
|
||||
if (open) throw new Error('generated region BEGIN marker nested inside an open region')
|
||||
open = { slug: begin[1], lines: [line] }
|
||||
continue
|
||||
}
|
||||
const end = GENERATED_REGION_END_LINE.exec(line)
|
||||
if (end?.[1]) {
|
||||
if (!open) throw new Error('generated region END marker without a BEGIN')
|
||||
if (end[1] !== open.slug) throw new Error(`generated region END slug '${end[1]}' does not match its BEGIN slug '${open.slug}'`)
|
||||
open.lines.push(line)
|
||||
regions.push(open.lines.join('\n'))
|
||||
open = null
|
||||
continue
|
||||
}
|
||||
if (GENERATED_REGION_MARKER_HINT.test(line)) {
|
||||
throw new Error(`malformed generated region marker line: ${JSON.stringify(line)}`)
|
||||
}
|
||||
if (open) open.lines.push(line)
|
||||
else kept.push(line)
|
||||
}
|
||||
if (open) throw new Error('generated region BEGIN marker without an END')
|
||||
return { regions, stripped: kept.join('\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full git blob hash of file content (what `git hash-object` prints).
|
||||
* @param content - Exact file bytes.
|
||||
* @returns The 40-hex-digit SHA-1 blob hash.
|
||||
*/
|
||||
export function blobHash(content: Buffer): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(`blob ${content.byteLength}\0`)
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
|
||||
/**
|
||||
* Parse a `foo.i18n.yaml` consistency record into basename → recorded blob
|
||||
* hash, or undefined when any non-comment line deviates from the exact
|
||||
* `<basename>.md: <40-hex>` shape or repeats a key. Consumers must
|
||||
* additionally require exactly the two expected basenames — a renamed key is
|
||||
* a malformed record, never a silently-missing entry.
|
||||
* @param content - Sidecar file text.
|
||||
* @returns The recorded map, or undefined for a malformed record.
|
||||
*/
|
||||
export function parsePairMeta(content: string): Map<string, string> | undefined {
|
||||
const out = new Map<string, string>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
const match = PAIR_META_LINE.exec(line)
|
||||
if (!match?.[1] || !match[2]) return undefined
|
||||
if (out.has(match[1])) return undefined
|
||||
out.set(match[1], match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `foo.i18n.yaml` consistency record.
|
||||
* @param source - Repo-relative English path.
|
||||
* @param sourceHash - Blob hash of the English side.
|
||||
* @param zh - Repo-relative Chinese path.
|
||||
* @param zhHash - Blob hash of the Chinese side.
|
||||
* @returns The exact sidecar file content.
|
||||
*/
|
||||
export function renderPairMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
|
||||
return [
|
||||
'# 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 ${source}`,
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
/** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Find stale root-relative `packages/...` references in repo-authored prose and
|
||||
* TypeScript. A missing path is reported only when it names a real package leaf;
|
||||
* globs, placeholders, hypothetical packages, and unbuilt `lib/` output are
|
||||
* outside the check.
|
||||
* TypeScript. A missing path is reported only when it names a real package leaf
|
||||
* outside its own explaining group directory; globs, placeholders, hypothetical
|
||||
* packages, and unbuilt `lib/` output are outside the check.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
@@ -66,7 +66,15 @@ function isDriftedPackageReference(ref: string): boolean {
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
|
||||
// A missing reference is drift only when a path segment names a live package.
|
||||
return ref.split('/').slice(1).some(segment => packageNames.has(segment))
|
||||
// A leading segment that is itself an existing group directory is explained by
|
||||
// the group, not by a relocated leaf sharing its name (`client` is both the
|
||||
// client-modules group and the scaffold leaf), so only later segments count.
|
||||
const segments = ref.split('/').slice(1)
|
||||
const [group] = segments
|
||||
const scanned = group !== undefined && segments.length > 1 && existsSync(resolve(root, 'packages', group))
|
||||
? segments.slice(1)
|
||||
: segments
|
||||
return scanned.some(segment => packageNames.has(segment))
|
||||
}
|
||||
|
||||
/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */
|
||||
|
||||
@@ -103,14 +103,14 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/e2b/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/sdk/sdk-client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/scaffold/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/scaffold/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/scaffold/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/scaffold/client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/scaffold/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/scaffold/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
|
||||
@@ -118,8 +118,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' },
|
||||
'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' },
|
||||
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
@@ -138,10 +138,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
|
||||
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
|
||||
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
|
||||
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' },
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
partitionGeneratedRegions,
|
||||
isTranslationScopeFile,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
translationStructureDiff,
|
||||
@@ -227,6 +228,27 @@ for (const source of [...pairAnchors].sort()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generated regions are language-invariant: the exact same generator output
|
||||
// (markers included) must appear in both sides, in the same order. The
|
||||
// structural signature below compares the region content again as part of
|
||||
// the whole document; this dedicated check exists to name the divergence
|
||||
// precisely and to reject a region grammar violation on either side.
|
||||
let sourceRegions: { regions: string[]; stripped: string }
|
||||
let zhRegions: { regions: string[]; stripped: string }
|
||||
try {
|
||||
sourceRegions = partitionGeneratedRegions(sourceContent.toString('utf8'))
|
||||
zhRegions = partitionGeneratedRegions(zhContent.toString('utf8'))
|
||||
} catch (error) {
|
||||
errors.push(`${source} ↔ ${zh}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
state.set(source, 'out-of-sync')
|
||||
continue
|
||||
}
|
||||
if (sourceRegions.regions.length !== zhRegions.regions.length
|
||||
|| sourceRegions.regions.some((region, index) => region !== zhRegions.regions[index])) {
|
||||
errors.push(`${source} ↔ ${zh}: generated regions differ between the pair — regenerate (the generator writes both sides byte-identically)`)
|
||||
state.set(source, 'out-of-sync')
|
||||
}
|
||||
|
||||
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
||||
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
||||
if (!linksTo(zhTree, basename(source))) {
|
||||
|
||||
Reference in New Issue
Block a user