Merge refreshed docs/i18n-batch-core into docs/i18n-batch-cds-postmortem

# Conflicts:
#	.agents/notes/README.i18n.yaml
#	.agents/notes/README.zh.md
#	docs/core-data-structures/bash.md
#	docs/core-data-structures/code-runtime.md
#	docs/core-data-structures/compaction.md
#	docs/core-data-structures/scope.md
#	docs/core-data-structures/session-query.md
#	docs/core-data-structures/user-interaction.md
#	docs/core-data-structures/web.md
#	docs/rfc/README.md
#	scripts/translation-pairing.manifest.json
#	scripts/type-equiv.manifest.json
This commit is contained in:
Tianyi Cui
2026-07-22 22:26:24 +08:00
2571 changed files with 199400 additions and 32710 deletions

3
scripts/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# AGENTS.md — Repository scripts
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer.

View File

@@ -0,0 +1,78 @@
/**
* Shared structural source of truth for the Agent Note tree. Lifecycle and class
* sets are closed under `.agents/notes/README.md`; importing this module is pure.
*/
import { globSync, readdirSync } from 'node:fs'
import { resolve, sep } from 'node:path'
export const agentNoteRoot = resolve(import.meta.dirname, '../.agents/notes')
/** The closed set of Agent Note lifecycles (top-level folders under .agents/notes/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of Agent Note classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-Agent Note Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** One Agent Note file, as discovered by the walker. */
export interface AgentNote {
lifecycle: string
/** Path relative to .agents/notes. */
rel: string
/** `yyyy-mm-dd` from the filename. */
date: string
}
/**
* Walk the Agent Note tree, enforcing the structure rules. Returns every valid Agent Note
* plus one error string per violation (unknown lifecycle or class folder, bad
* depth, or bad filename). Callers treat a non-empty error list as fatal.
*/
export function walkAgentNoteTree(): { notes: AgentNote[]; errors: string[] } {
const notes: AgentNote[] = []
const errors: string[] = []
// The lifecycle set is closed too: any directory under .agents/notes/ that is not
// a known lifecycle would otherwise hold Agent Notes invisible to the walk below.
for (const entry of readdirSync(agentNoteRoot, { withFileTypes: true })) {
if (entry.name === 'INDEX.md') {
errors.push('structure: INDEX.md — centralized Agent Note indexes are forbidden; browse the lifecycle/class tree or search the repository')
continue
}
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
}
}
for (const lifecycle of LIFECYCLES) {
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: agentNoteRoot }).map(path => path.split(sep).join('/')).sort()) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME Agent Note,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
notes.push({ lifecycle, rel: match, date: base.slice(0, 10) })
}
}
return { notes, errors }
}

View File

@@ -1,7 +1,7 @@
/**
* Build the SDK runtime executables and Python node carrier. The fixed
* `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
* .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
* The staged closure is symlink-free, and whole-tree assets cover Cordis's
* runtime imports that pkg cannot discover statically.
*/
@@ -69,7 +69,7 @@ class Target {
readonly nodeRange: string,
/**
* pkg platform tag. Windows is a documented non-goal
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
* (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*/
readonly platform: Platform,
/** pkg CPU tag. */
@@ -190,7 +190,7 @@ class BuildCli {
' --dry-run print every command and config patch without executing.',
' --help print this help.',
'',
`Build route: ${PKG_SPEC} --sea; see docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
`Build route: ${PKG_SPEC} --sea; see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
].join('\n')
}

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Vendored upstream paths follow vendor/README.md instead of repository naming policy.
root=$(git rev-parse --show-toplevel)
candidate_file=$(mktemp)
trap 'unlink "$candidate_file"' EXIT
git -C "$root" ls-files -z -- \
':(icase,glob)*golden*' \
':(icase,glob)**/*golden*' \
':(exclude,glob)vendor/**' > "$candidate_file"
violations=()
while IFS= read -r -d '' path; do
violations+=("$path")
done < "$candidate_file"
if (( ${#violations[@]} == 0 )); then
echo 'check-expected-filenames: no tracked non-vendor filename contains "golden".'
exit 0
fi
echo 'check-expected-filenames: tracked non-vendor filenames must not contain "golden":' >&2
printf ' %s\n' "${violations[@]}" >&2
echo 'Rename each file with an accurate term such as "expected".' >&2
exit 1

View File

@@ -40,10 +40,12 @@ interface PackageManifest {
bin?: string | Record<string, string>
exports?: Record<
string,
| string
| {
types?: string
default?: string
}
| null
| undefined
>
files?: string[]
@@ -93,40 +95,61 @@ function workspaceManifests(): WorkspaceManifest[] {
return manifests
}
const dshPackageFiles = [
'lib/index.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const dshBinPackageFiles = [
'lib/index.js',
'lib/bin.js',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const dshWorkerPackageFiles = [
'lib/index.js',
'lib/worker.cjs',
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
] as const
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-helper': ['lib/assets'],
'@deepseek-ai/dsh-scripts': [
'lib/dev/tsdown-config.js',
'lib/local-plugin-loader-hooks.js',
'lib/assets',
],
}
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
}
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
if (manifest.bin) return dshBinPackageFiles
// A declared "./worker" subpath export sanctions the one extra runtime
// bundle a worker-thread entry needs (and NodeNext/publint then validate
// that subpath's targets like any other export).
if (manifest.exports?.['./worker']) return dshWorkerPackageFiles
return dshPackageFiles
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
return [
'lib/index.js',
// Every package publishes its invariant ownership companion as a separate
// bundle; the package-invariant gate validates the companion itself.
'lib/invariant.js',
...manifest.bin ? ['lib/bin.js'] : [],
...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [],
// UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
// a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
...extras,
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
// browser-safe source channels rehomed off src so plain Node can import
// them without type stripping) publish the emitted JS alongside the
// declarations.
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
'lib/types/**/*.d.ts',
'lib/types/**/*.d.ts.map',
'src',
]
}
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
const entry = manifest.exports?.[subpath]
if (typeof entry === 'string') return entry
if (typeof entry === 'object' && entry !== null) return entry.default
return undefined
}
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
return Object.keys(manifest.exports ?? {}).some(subpath =>
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
}
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
@@ -162,12 +185,25 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
if (manifest.types !== 'lib/types/index.d.ts') {
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') {
const rootExport = manifest.exports?.['.']
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
if (rootEntry?.types !== './lib/types/index.d.ts') {
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
}
if (manifest.exports?.['.']?.default !== './lib/index.js') {
if (rootEntry?.default !== './lib/index.js') {
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
}
const invariantRaw = manifest.exports?.['./invariant']
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
}
if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') {
errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`)
}
if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) {
errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`)
}
const expectedFiles = expectedDshPackageFiles(manifest)
if (!sameStringList(manifest.files, expectedFiles)) {
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)

View File

@@ -0,0 +1,60 @@
/**
* Pins the client-bundle purity gate (tsdown preset resolveId classifier):
* a bare-name import of a module-table package must rewrite to its /client
* external form (inlining it duplicates runtime identity — the P0
/* leak that is not an
* inline-safe wire layer must fail the build loudly.
*/
import { describe, expect, it } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
function purityResolveId(): ResolveId {
// libEntry is spelled at every call site (no default) so the
// package-invariants text check can see the invariant entry per package.
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
return gate.resolveId as ResolveId
}
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves table entries and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
id: '@deepseek-ai/dsh-client-connection/client',
external: true,
})
expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
id: '@deepseek-ai/dsh-client-ui-layout/client',
external: true,
})
})
it('lets inline-safe wire layers inline', () => {
expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
})
it('throws on any other @deepseek-ai leak', () => {
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
for (const entry of CLIENT_EXTERNALS) {
if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
}
})
})

View File

@@ -0,0 +1,49 @@
/** Tests for the generated Cordis core API reference. */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CORDIS_CORE_API_PAGES,
renderCordisCoreApiPage,
renderCordisCoreApiPages,
type CordisCoreApiPage,
} from './cordis-core-api.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
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')
const fiber = pages.get('docs/cordis-catalog/core/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.')
})
it('rejects a public core class without source JSDoc', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-core-api-'))
roots.push(root)
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',
title: 'Service',
intro: 'Service API.',
sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }],
}
expect(() => renderCordisCoreApiPage(page, root)).toThrow('class Service')
})
})

433
scripts/cordis-core-api.ts Normal file
View File

@@ -0,0 +1,433 @@
/** Generate detailed Cordis core API pages from pinned vendor declarations. */
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
import { cordisModuleBody } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const FENCE = 'ts cordis-catalog'
/** One declaration group rendered on a Cordis core API page. */
type CordisCoreApiSection =
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
| { kind: 'context-merge'; file: string; heading?: string }
| { kind: 'decl'; file: string; symbol: string }
/** One generated Cordis core API page. */
export interface CordisCoreApiPage {
out: string
title: string
intro: string
sections: CordisCoreApiSection[]
}
/** Explicit editorial grouping for the pinned Cordis core surface. */
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
{
out: 'docs/cordis-catalog/core/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: [
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
],
},
{
out: 'docs/cordis-catalog/core/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).',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
],
},
{
out: 'docs/cordis-catalog/core/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: [
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
],
},
{
out: 'docs/cordis-catalog/core/registry.md',
title: 'Registry',
intro: 'Plugin loading and dependency injection.',
sections: [
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
],
},
{
out: 'docs/cordis-catalog/core/service.md',
title: 'Service',
intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.<name>`.',
sections: [
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
],
},
]
interface MemberDoc {
name: string
heading: string
signatures: string[]
jsDoc: string
doc: string
params: { name: string; text: string }[]
returns: string | null
source: string
}
interface RenderContext {
scanRoot: string
cache: Map<string, { sf: ts.SourceFile; text: string }>
violations: string[]
}
function load(ctx: RenderContext, rel: string): { sf: ts.SourceFile; text: string } {
const cached = ctx.cache.get(rel)
if (cached !== undefined) return cached
const text = readFileSync(resolve(ctx.scanRoot, rel), 'utf8')
const entry = { sf: ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true), text }
ctx.cache.set(rel, entry)
return entry
}
function sourceJsDoc(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (raw === '') return ''
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, node.getStart(sf))
return raw.split('\n')
.map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent)
? sourceLine.slice(indent.length)
: sourceLine)
.join('\n')
}
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
const full = member.getText(sf)
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
?? (member as { initializer?: ts.Node }).initializer
const signature = tail
? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '')
: full
return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
const names = parameters
.filter(parameter => !(ts.isIdentifier(parameter.name) && parameter.name.text === 'this'))
.map((parameter) => {
const rest = parameter.dotDotDotToken ? '...' : ''
const optional = parameter.questionToken || parameter.initializer ? '?' : ''
return `${rest}${parameter.name.getText(sf)}${optional}`
})
return `(${names.join(', ')})`
}
function isPublicInstance(member: ts.ClassElement): boolean {
const modifiers = ts.getCombinedModifierFlags(member)
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
function isPublicStatic(member: ts.ClassElement): boolean {
const modifiers = ts.getCombinedModifierFlags(member)
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
if (!(modifiers & ts.ModifierFlags.Static)) return false
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
return !member.name.getText().startsWith('_')
}
type Member = ts.MethodDeclaration
| ts.MethodSignature
| ts.PropertyDeclaration
| ts.PropertySignature
| ts.GetAccessorDeclaration
function memberDoc(ctx: RenderContext, where: string, name: string, group: Member[], rel: string): MemberDoc {
const { sf, text } = load(ctx, rel)
const first = group[0]
if (first === undefined) throw new Error(`cordis-core-api: empty member group for ${name}.`)
const rawDocs = group.map(member => sourceJsDoc(text, sf, member))
const docIndex = rawDocs.findIndex(raw => parseJsDoc(raw).doc !== '')
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
const doc = parseJsDoc(raw).doc
if (doc === '') ctx.violations.push(`${where} has no JSDoc prose.`)
const { params: tags, returns } = parseTags(raw)
const functionMembers = group.filter((member): member is ts.MethodDeclaration | ts.MethodSignature =>
ts.isMethodDeclaration(member) || ts.isMethodSignature(member))
const docCarrier = functionMembers[docIndex === -1 ? 0 : docIndex]
const params: { name: string; text: string }[] = []
if (docCarrier !== undefined) {
checkParams(where, 'cordis-core-api', docCarrier.parameters, tags, sf,
parameter => ts.isIdentifier(parameter.name) && parameter.name.text === 'this', ctx.violations)
if (docCarrier.type !== undefined) {
checkReturns(where, docCarrier.type, returns, sf, ctx.violations)
} else if (returns === null && ts.isMethodDeclaration(docCarrier)) {
ctx.violations.push(`${where} has no return type annotation; document the result with @returns.`)
}
for (const parameter of docCarrier.parameters) {
if (!ts.isIdentifier(parameter.name) || parameter.name.text === 'this') continue
const text = tags.get(parameter.name.text)
if (text !== undefined) params.push({ name: parameter.name.text, text })
}
}
const headingSource = docCarrier ?? functionMembers[0]
const signatures = ts.isMethodDeclaration(first) && functionMembers.length > 1
? functionMembers.filter(member => ts.isMethodDeclaration(member) && member.body === undefined)
: group
return {
name,
heading: headingSource === undefined ? '' : headingParams(headingSource.parameters, sf),
signatures: signatures.map(member => signatureOf(member, sf)),
jsDoc: raw,
doc,
params,
returns,
source: pointer(rel, sf, first),
}
}
function heritageMembers(
statement: ts.InterfaceDeclaration,
sf: ts.SourceFile,
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
): void {
for (const clause of statement.heritageClauses ?? []) {
for (const type of clause.types) {
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
const [target, keys] = type.typeArguments ?? []
if (target === undefined || keys === undefined || !ts.isTypeReferenceNode(target)) continue
const targetName = target.typeName.getText(sf)
const cls = sf.statements.find(
(entry): entry is ts.ClassDeclaration => ts.isClassDeclaration(entry) && entry.name?.text === targetName,
)
if (cls === undefined) continue
const picked = new Set<string>()
const collect = (node: ts.TypeNode): void => {
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
}
collect(keys)
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
const name = member.name.getText(sf)
if (!picked.has(name)) continue
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
}
}
function contextMergeMembers(ctx: RenderContext, rel: string): MemberDoc[] {
const { sf } = load(ctx, rel)
const body = cordisModuleBody(sf)
if (body === null) throw new Error(`cordis-core-api: ${rel} has no Context module merge.`)
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
for (const statement of body.statements) {
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'Context') continue
heritageMembers(statement, sf, groups)
for (const member of statement.members) {
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
if (ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = groups.get(name) ?? []
group.push(member)
groups.set(name, group)
}
}
return [...groups.entries()].map(([name, group]) =>
memberDoc(ctx, `ctx.${name} (${rel})`, name, group, rel))
}
function classMembers(ctx: RenderContext, rel: string, className: string): {
doc: string
instance: MemberDoc[]
statics: MemberDoc[]
source: string
} {
const { sf, text } = load(ctx, rel)
const cls = sf.statements.find(
(statement): statement is ts.ClassDeclaration =>
ts.isClassDeclaration(statement) && statement.name?.text === className,
)
if (cls === undefined) throw new Error(`cordis-core-api: class ${className} not found in ${rel}.`)
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
if (doc === '') ctx.violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
const instance = new Map<string, Member[]>()
const statics = new Map<string, Member[]>()
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member) && !ts.isGetAccessorDeclaration(member)) continue
const name = member.name.getText(sf)
if (isPublicInstance(member)) {
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
const group = statics.get(name) ?? []
group.push(member)
statics.set(name, group)
}
}
const declaration = sf.statements.find(
(statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) && statement.name.text === className,
)
for (const member of declaration?.members ?? []) {
if (!ts.isPropertySignature(member) || ts.isComputedPropertyName(member.name)) continue
const name = member.name.getText(sf)
const group = instance.get(name) ?? []
group.push(member)
instance.set(name, group)
}
const render = (groups: Map<string, Member[]>, prefix: string): MemberDoc[] =>
[...groups.entries()].map(([name, group]) => memberDoc(ctx, `${prefix}${name} (${rel})`, name, group, rel))
return {
doc,
instance: render(instance, `${className}#`),
statics: render(statics, `${className}.`),
source: pointer(rel, sf, cls),
}
}
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
const cuts: { start: number; end: number }[] = []
const visit = (entry: ts.Node): void => {
const functionLike = ts.isMethodDeclaration(entry)
|| ts.isConstructorDeclaration(entry)
|| ts.isFunctionDeclaration(entry)
|| ts.isGetAccessorDeclaration(entry)
|| ts.isSetAccessorDeclaration(entry)
if (functionLike && entry.body !== undefined) {
const signatureEnd = (entry.type ?? entry.parameters.at(-1) ?? entry).getEnd()
cuts.push({ start: signatureEnd, end: entry.body.getEnd() })
return
}
entry.forEachChild(visit)
}
visit(node)
const base = node.getStart(sf)
let output = node.getText(sf)
for (const cut of cuts.sort((left, right) => right.start - left.start)) {
const head = output.slice(0, cut.start - base)
const between = output.slice(cut.start - base, cut.end - base)
const bodyBrace = between.indexOf('{')
output = head + between.slice(0, bodyBrace).trimEnd() + output.slice(cut.end - base)
}
return output
}
function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { doc: string; code: string; source: string } {
const { sf, text } = load(ctx, rel)
const matches = sf.statements.filter((statement) => {
const named = ts.isInterfaceDeclaration(statement)
|| ts.isTypeAliasDeclaration(statement)
|| ts.isClassDeclaration(statement)
|| ts.isEnumDeclaration(statement)
|| ts.isModuleDeclaration(statement)
return named && statement.name?.getText(sf) === symbol
})
const first = matches[0]
if (first === undefined) throw new Error(`cordis-core-api: declaration ${symbol} not found in ${rel}.`)
const doc = parseJsDoc(sourceJsDoc(text, sf, first)).doc
const code = matches.map((statement) => {
const jsDoc = sourceJsDoc(text, sf, statement)
const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '')
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
}).join('\n\n')
return { doc, code, source: pointer(rel, sf, first) }
}
function sourceLink(source: string): string {
const [file, line] = source.split(':')
return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})`
}
function unlink(text: string): string {
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_match, target: string, label?: string) => {
const name = label?.trim()
return name && name !== '' ? name : `\`${target}\``
})
}
function prose(doc: string): string[] {
const paragraphs = unlink(doc)
.split(/\n\s*\n/)
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
.filter(paragraph => paragraph !== '')
return paragraphs.flatMap((paragraph, index) => index === 0 ? [paragraph] : ['', paragraph])
}
function renderMember(prefix: string, member: MemberDoc): string[] {
const lines = [`### ${prefix}${member.name}${member.heading}`, '', `\`\`\`${FENCE}`]
if (member.jsDoc !== '') lines.push(member.jsDoc)
lines.push(...member.signatures, '```', '')
if (member.doc !== '') lines.push(...prose(member.doc), '')
for (const parameter of member.params) lines.push(`- \`${parameter.name}\`${unlink(parameter.text)}`)
if (member.params.length > 0) lines.push('')
if (member.returns !== null && member.returns !== '') lines.push(`**Returns** ${unlink(member.returns)}`, '')
lines.push(sourceLink(member.source), '')
return lines
}
/** Render one detailed Cordis core API page and reject undocumented members. */
export function renderCordisCoreApiPage(
page: CordisCoreApiPage,
scanRoot: string = root,
): string {
const ctx: RenderContext = { scanRoot, cache: new Map(), violations: [] }
const lines = [
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
'',
`# ${page.title}`,
'',
page.intro,
'',
]
for (const section of page.sections) {
if (section.kind !== 'decl' && section.heading !== undefined) lines.push(`## ${section.heading}`, '')
if (section.kind === 'context-merge') {
for (const member of contextMergeMembers(ctx, section.file)) lines.push(...renderMember('ctx.', member))
} else if (section.kind === 'class') {
const cls = classMembers(ctx, section.file, section.symbol)
if (cls.doc !== '') lines.push(...prose(cls.doc), '')
lines.push(sourceLink(cls.source), '')
const prefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
for (const member of cls.instance) lines.push(...renderMember(prefix, member))
if (cls.statics.length > 0) {
lines.push('## Static members', '')
for (const member of cls.statics) lines.push(...renderMember(`${section.symbol}.`, member))
}
} else {
const declaration = declarationPaste(ctx, section.file, section.symbol)
lines.push(`## ${section.symbol}`, '')
if (declaration.doc !== '') lines.push(...prose(declaration.doc), '')
lines.push(`\`\`\`${FENCE}`, declaration.code, '```', '', sourceLink(declaration.source), '')
}
}
reportViolations('gen-cordis-catalog', ctx.violations)
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
}
/** Render every detailed Cordis core API page. */
export function renderCordisCoreApiPages(scanRoot: string = root): Map<string, string> {
return new Map(CORDIS_CORE_API_PAGES.map(page => [page.out, renderCordisCoreApiPage(page, scanRoot)]))
}

91
scripts/cordis-walk.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* AST walkers for the Cordis catalog generator: locate the Cordis module merge
* in a source file, enumerate its `interface Events` members, and resolve the
* `interface Context` service keys to their service classes.
*/
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
/** 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. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
return null
}
/** Every `interface Events` method member of a cordis module merge, with the
* event name resolved from its (possibly string-literal) property name. */
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
const out: { name: string; member: ts.MethodSignature }[] = []
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
out.push({ name, member })
}
}
return out
}
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
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
}
/** One `ctx.<key>` service class resolved from a Context merge. */
export interface ServiceClass {
key: string
type: string
cls: ts.ClassDeclaration
abstract: boolean
/** Class-level JSDoc prose (empty string when missing — also reported). */
doc: string
}
/**
* Resolve each `ctx.<key>` of a merge to the service class declared in the
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
* timer helpers) is skipped. A class without JSDoc prose is reported into
* `violations` (named `where` by the caller's gate).
*
* @param body — the cordis module merge body.
* @param sf — the source file containing the merge.
* @param rel — repo-relative path of `sf`, for violation pointers.
* @param violations — sink for JSDoc-completeness violations.
* @returns the resolved service classes, in Context-declaration order.
*/
export function serviceClasses(
body: ts.ModuleBlock,
sf: ts.SourceFile,
rel: string,
violations: string[],
): ServiceClass[] {
const text = sf.getFullText()
const out: ServiceClass[] = []
for (const [key, type] of contextKeyMap(body, sf)) {
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!cls) continue // a Pick-mixin member, not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
out.push({ key, type, cls, abstract, doc })
}
return out
}

View File

@@ -1,22 +1,20 @@
/**
* Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Each overlay
* Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay
* includes its base example, selects Code Mode, and adds the worker runtime.
* Both require a DeepSeek API key; unsupported arguments fail with usage.
* All require a DeepSeek API key; unsupported arguments fail with usage.
*/
import { spawn } from 'node:child_process'
// Each UI's node invocation, verbatim what its base demo script runs plus
// the overlay config (the stdio bin keeps --expose-internals for the cordis
// Loader's HMR path).
// Each UI's node invocation matches its base demo script plus the overlay config.
const UIS = new Map([
['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
])
const ui = process.argv[2] ?? 'repl'
const ui = process.argv[2] ?? 'tui'
const args = UIS.get(ui)
if (!args || process.argv.length > 3) {
console.error('usage: pnpm run demo:code-mode [repl|acp]')
console.error('usage: pnpm run demo:code-mode [tui|acp]')
process.exit(2)
}

View File

@@ -1,11 +1,11 @@
{
"AGENTS.md": 1370,
"docs/AGENTS.md": 1100,
"docs/architecture.md": 1790,
"AGENTS.md": 1600,
"docs/AGENTS.md": 1150,
"docs/architecture.md": 1800,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 200,
"packages/AGENTS.md": 290,
"docs/testing.md": 960,
"examples/AGENTS.md": 310,
"packages/AGENTS.md": 650,
"packages/README.md": 760
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
describe('builtDeclarationPath', () => {
it('maps package source directories and exact entry files to built declarations', () => {
expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types')
expect(builtDeclarationPath('./packages/support/invariants/src/index.ts'))
.toBe('./packages/support/invariants/lib/types/index.d.ts')
expect(builtDeclarationPath('./packages/core/session/src/invariant.ts'))
.toBe('./packages/core/session/lib/types/invariant.d.ts')
})
it('rejects aliases without a supported source target', () => {
expect(() => builtDeclarationPath('./packages/support/invariants/source/index.ts'))
.toThrow('cannot map workspace source path')
})
})

View File

@@ -0,0 +1,22 @@
/** Map one workspace source alias target to its declaration-build target. */
export function builtDeclarationPath(candidate: string): string {
// Two workspace shapes exist: whole-package entries end in /src, subpath
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
if (candidate.endsWith('/src')) {
return `${candidate.slice(0, -'/src'.length)}/lib/types`
}
if (candidate.endsWith('/src/*')) {
return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
}
const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
if (sourceFile?.[1] && sourceFile[2]) {
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
}
// Directory subpath entries (web-react's /store, runtime's /client): the
// source dir maps to the same dir under lib/types (index resolution applies).
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
if (sourceDir?.[1] && sourceDir[2]) {
return `${sourceDir[1]}/lib/types/${sourceDir[2]}`
}
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
}

View File

@@ -1,13 +1,15 @@
/**
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
* fences are reported as opt-outs; generated catalog fragments and
* `type-equiv` blocks are skipped here because their owning gates verify them.
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
* opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their
* owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
*/
import { execFileSync } from 'node:child_process'
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
import { extractFences } from './md-fences.ts'
const root = resolve(import.meta.dirname, '..')
@@ -27,60 +29,119 @@ interface Block {
code: string
}
/** The info-string → kind table this gate tracks. */
const KIND_BY_INFO: Record<string, BlockKind> = {
'ts': 'check',
'ts ignore-check': 'ignore',
'ts type-equiv': 'type-equiv',
'ts public-api': 'type-equiv',
'ts cordis-catalog': 'cordis-catalog',
'ts persistence-catalog': 'persistence-catalog',
'ts config-catalog': 'config-catalog',
}
/** Extract every recognized TypeScript fence from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const text = readFileSync(absPath, 'utf8')
const lines = text.split('\n')
const file = relative(root, absPath)
const blocks: Block[] = []
let open: { line: number; kind: BlockKind; body: string[] } | null = null
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
}
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
// closing fence
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
// Ignore non-TypeScript fences.
const info = (fence[2] ?? '').trim()
const kind: BlockKind | null =
info === 'ts' ? 'check'
: info === 'ts ignore-check' ? 'ignore'
: info === 'ts type-equiv' ? 'type-equiv'
: info === 'ts cordis-catalog' ? 'cordis-catalog'
: info === 'ts persistence-catalog' ? 'persistence-catalog'
: info === 'ts config-catalog' ? 'config-catalog'
: null
if (kind) open = { line: i + 1, kind, body: [] }
const configHost: ts.ParseConfigFileHost = {
...ts.sys,
getCurrentDirectory: () => root,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
}
/** Load root settings and redirect workspace aliases to declarations from the coordinated build. */
function builtTypeCompilerOptions(): ts.CompilerOptions {
const configPath = join(root, 'tsconfig.json')
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
if (!parsed) throw new Error(`doc-typecheck: cannot parse ${configPath}`)
if (parsed.errors.length > 0) {
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths')
const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [
specifier,
candidates.map(builtDeclarationPath),
]))
const options: ts.CompilerOptions = {
...parsed.options,
paths,
noEmit: true,
composite: false,
incremental: false,
declaration: false,
declarationMap: false,
sourceMap: false,
noUnusedLocals: false,
noUnusedParameters: false,
}
delete options.tsBuildInfoFile
return options
}
/** Compile Markdown blocks as virtual files against declarations from the coordinated build. */
function compileBlocksAgainstBuiltTypes(blocks: Block[]): readonly ts.Diagnostic[] {
const options = builtTypeCompilerOptions()
const sources = new Map<string, string>()
for (const [index, block] of blocks.entries()) {
const fileName = resolve(root, '.doc-typecheck', `block-${index}.ts`)
sources.set(fileName, block.code.endsWith('\n') ? block.code : `${block.code}\n`)
}
const baseHost = ts.createCompilerHost(options, true)
const host: ts.CompilerHost = {
...baseHost,
fileExists(fileName) {
return sources.has(resolve(fileName)) || baseHost.fileExists(fileName)
},
readFile(fileName) {
return sources.get(resolve(fileName)) ?? baseHost.readFile(fileName)
},
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
const source = sources.get(resolve(fileName))
if (source !== undefined) return ts.createSourceFile(fileName, source, languageVersion, true)
return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile)
},
writeFile() {
throw new Error('doc-typecheck: noEmit compilation attempted to write output')
},
}
const program = ts.createProgram([...sources.keys()], options, host)
return ts.getPreEmitDiagnostics(program)
}
/** Render compiler diagnostics with virtual block paths mapped back to Markdown. */
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[]): string {
const formatted = ts.formatDiagnostics(diagnostics, {
getCanonicalFileName: fileName => fileName,
getCurrentDirectory: () => root,
getNewLine: () => ts.sys.newLine,
})
return blocks
return remapBlockPaths(formatted, blocks)
}
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
function workspaceReferences(): { path: string }[] {
const file = join(root, 'tsconfig.json')
// Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip:
// a regex strip mistakes the `/*/` in a wildcard path candidate
// (`./packages/core/*/src`) for a block comment and corrupts the map.
const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8'))
// Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path
// candidate in the workspace wildcard.
const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8'))
if (result.error) {
throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`)
}
// `config` is typed `any` by the TS API; narrow it to the one field we read.
const { references } = result.config as { compilerOptions: { paths: Record<string, string[]> }; references: { path: string }[] }
return references.map(({ path }) => {
const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`
return { path: relativeToTemp }
})
// `config` is typed `any` by the TS API; narrow it to the one field read here.
const { references } = result.config as { references: { path: string }[] }
return references.map(({ path }) => ({
path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`,
}))
}
/** The standalone tsconfig for the temp typecheck project. */
/** The standalone temp project used when no coordinated build owns declaration freshness. */
function tempTsconfig(): string {
return JSON.stringify({
extends: '../tsconfig.json',
@@ -94,7 +155,40 @@ function tempTsconfig(): string {
})
}
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** Compile blocks through project references for the standalone command. */
function compileBlocksStandalone(blocks: Block[]): string | undefined {
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
try {
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
for (const [index, block] of blocks.entries()) {
writeFileSync(join(tmp, `block-${index}.ts`), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
}
try {
// Invoke tsc's JS entry through Node instead of a platform-specific shell shim.
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], {
cwd: root,
stdio: 'pipe',
})
return undefined
} catch (error: unknown) {
const failed = error as { stdout?: Buffer; stderr?: Buffer }
return remapBlockPaths(`${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`, blocks)
}
} finally {
rmSync(tmp, { recursive: true, force: true })
}
}
/** Map virtual or temporary block paths back to their owning Markdown fences. */
function remapBlockPaths(output: string, blocks: Block[]): string {
return output.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_match, index: string, line: string, column: string) => {
const block = blocks[Number(index)]
if (!block) return `block-${index}.ts(${line},${column})`
return `${block.file} (block at line ${block.line}, +${line}:${column})`
})
}
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const files: string[] = []
for (const pattern of markdownGlobs) {
@@ -114,45 +208,24 @@ if (checked.length === 0) {
process.exit(0)
}
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
try {
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
const fileForBlock = new Map<string, Block>()
checked.forEach((block, i) => {
const name = `block-${i}.ts`
writeFileSync(join(tmp, name), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
fileForBlock.set(name, block)
})
try {
// tsc's JS entry via the current node, not the .bin shim: the extensionless
// shim is not spawnable on Windows (the CVE-2024-27980 class the sibling
// scripts hit), and the .cmd variant would need shell:true, which
// concatenates args UNESCAPED — a hazard for the temp project path. The JS
// entry behaves identically on every platform.
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
} catch (error: unknown) {
const failed = error as { stdout?: Buffer; stderr?: Buffer }
const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`
// Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage.
const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => {
const block = fileForBlock.get(`block-${idx}.ts`)
if (!block) return `block-${idx}.ts(${ln},${col})`
return `${block.file} (block at line ${block.line}, +${ln}:${col})`
})
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
console.error(remapped)
process.exit(1)
}
const ratio = ignored.length / ratioDenominator
const skipped = all.length - ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
// Guard against the escape hatch becoming the norm.
if (ratioDenominator >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
process.exit(1)
}
} finally {
rmSync(tmp, { recursive: true, force: true })
const useBuiltTypes = process.env.DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT === '1'
const compilationError = useBuiltTypes
? (() => {
const diagnostics = compileBlocksAgainstBuiltTypes(checked)
return diagnostics.length === 0 ? undefined : formatDiagnostics(diagnostics, checked)
})()
: compileBlocksStandalone(checked)
if (compilationError !== undefined) {
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
console.error(compilationError)
process.exit(1)
}
const ratio = ignored.length / ratioDenominator
const skipped = all.length - ratioDenominator
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
// Guard against the escape hatch becoming the norm.
if (ratioDenominator >= 4 && ratio > 0.5) {
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
process.exit(1)
}

View File

@@ -837,7 +837,7 @@ export function render(entries: CatalogEntry[]): string {
'',
'## Seam packages (not directly loadable)',
'',
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).',
'',
...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
'',

View File

@@ -1,8 +1,9 @@
/**
* Generate the model-facing Cordis API data module from the same event/service
* collector as the documentation catalogs. It emits first-sentence docs, raw
* signatures, transitive public type shapes, and inherited context entries,
* without source pointers; output is deterministic and `--check` verifies it.
* collector as the documentation catalogs. It emits original declaration
* JSDoc, first-sentence summaries, raw signatures, transitive public type
* shapes, and inherited context entries, without source pointers; output is
* deterministic and `--check` verifies it.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
@@ -80,7 +81,7 @@ function referencedTypes(seeds: string[], decls: Map<string, string>): { name: s
function render(): string {
const services = collectServices()
const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls())
const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls())
const lines: string[] = [
'/**',
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
@@ -88,22 +89,30 @@ function render(): string {
' * `pnpm run verify-cordis-api` in doc-sync).',
' *',
' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
' * model: harness services (summary + public method signatures), harness',
' * events (mode + signature), and the inherited `ctx` surface. Produced by',
' * model: harness services (summary + public method signatures/JSDoc),',
' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by',
' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
' * docs cannot diverge.',
' *',
' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
' */',
'',
'/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */',
'/** One public service method and its source-owned contract. */',
'export interface ServiceApiMethod {',
' /** Public method signature with its body stripped. */',
' signature: string',
' /** Original method JSDoc, with only container indentation removed. */',
' jsDoc: string',
'}',
'',
'/** One harness `ctx.<key>` service: its one-line summary and public methods. */',
'export interface ServiceApiEntry {',
' /** The `ctx.<key>` name, e.g. `tools`. */',
' key: string',
' /** First sentence of the service class JSDoc. */',
' summary: string',
' /** Public method signatures, bodies stripped, in source order. */',
' methods: readonly string[]',
' /** Public methods, bodies stripped, in source order. */',
' methods: readonly ServiceApiMethod[]',
'}',
'',
'/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
@@ -114,6 +123,8 @@ function render(): string {
' mode: string',
' /** The exact listener signature, whitespace-normalized. */',
' signature: string',
' /** Original event JSDoc, with only container indentation removed. */',
' jsDoc: string',
' /** First sentence of the event JSDoc. */',
' summary: string',
'}',
@@ -145,7 +156,12 @@ function render(): string {
lines.push(' methods: [],')
} else {
lines.push(' methods: [')
for (const method of service.methods) lines.push(` ${quote(method)},`)
for (const method of service.methods) {
lines.push(' {')
lines.push(` signature: ${quote(method.signature)},`)
lines.push(` jsDoc: ${quote(method.jsDoc)},`)
lines.push(' },')
}
lines.push(' ],')
}
lines.push(' },')
@@ -161,6 +177,7 @@ function render(): string {
lines.push(` name: ${quote(event.name)},`)
lines.push(` mode: ${quote(event.mode)},`)
lines.push(` signature: ${quote(event.signature)},`)
lines.push(` jsDoc: ${quote(event.jsDoc)},`)
lines.push(` summary: ${quote(firstSentence(event.doc))},`)
lines.push(' },')
}

View File

@@ -1,14 +1,16 @@
/**
* Generate the Cordis event and service catalogs from static declarations.
* The walk enforces event modes plus JSDoc parameter/return completeness;
* inherited Cordis services come from the curated table below. `--check`
* verifies both committed artifacts.
* The walk enforces event modes, JSDoc parameter/return completeness, and
* signature type-link coverage; inherited Cordis services come from the
* curated table below. `--check` verifies both committed artifacts.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import ts from 'typescript'
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
const root = resolve(import.meta.dirname, '..')
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
@@ -19,49 +21,226 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
const FENCE = 'ts cordis-catalog'
/**
* One primary core-data-structures page per signature type, shared by the
* Cordis and config catalogs; union names intentionally do not reuse the
* type-equivalence manifest's map-symbol entries.
* One primary core-data-structures 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.
*/
// TODO(catalog-type-links): verify or generate link-map coverage.
export const LINK_MAP: Record<string, string> = {
Agent: 'core.md',
AgentCancelCause: 'core.md',
AgentOptions: 'core.md',
AgentStatus: 'core.md',
ContentBlock: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
ContinuationDecision: 'core.md',
ContinuationStop: 'core.md',
GenerateOptions: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
Message: 'core.md',
MessageSource: 'core.md',
PromptDecision: 'core.md',
RequestError: 'core.md',
RequestErrorDecision: 'core.md',
SessionEvent: 'core.md',
SessionId: 'core.md',
SessionStartSource: 'core.md',
StreamChunk: 'llm-streaming.md',
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',
ApprovalOutcome: 'approval.md',
ApprovalPolicy: 'approval.md',
ApprovalRequest: 'approval.md',
ApprovalService: 'approval.md',
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashProcess: 'bash.md',
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
ConfinedArgv: 'sandbox.md',
SandboxMode: 'sandbox.md',
SandboxPolicy: 'sandbox.md',
DshEnvironment: 'bash.md',
CodeRunRequest: 'code-runtime.md',
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
CompactionTrigger: 'compaction.md',
PruneResult: 'compaction.md',
FileReadOutcome: 'filesystem.md',
FsDirEntry: 'filesystem.md',
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
FsPathInfo: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FsTarget: 'filesystem.md',
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FileReadOutcome: 'filesystem.md',
CreateGoalRequest: 'goal.md',
EditGoalRequest: 'goal.md',
GoalBlockReason: 'goal.md',
GoalChanged: 'goal.md',
GoalRef: 'goal.md',
GoalView: 'goal.md',
CommandDefinition: 'commands.md',
CommandDescriptor: 'commands.md',
CommandResult: 'commands.md',
CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
CreateSessionOptions: 'persistence.md',
SessionHeader: 'persistence.md',
SessionLocation: 'persistence.md',
ConfinedArgv: 'sandbox.md',
SandboxExecutionPolicy: 'sandbox.md',
SandboxMode: 'sandbox.md',
SandboxPolicy: 'sandbox.md',
SandboxPolicyRequest: 'sandbox.md',
ScopeKey: 'scope.md',
Scoped: 'scope.md',
EpochHeader: 'session.md',
OutOfBandSessionEventType: 'session.md',
Session: 'session.md',
SessionEventMap: 'session.md',
TurnEndReason: 'session.md',
TurnTrigger: 'session.md',
SessionEventReadRequest: 'session-query.md',
SessionEventRecord: 'session-query.md',
SessionEventTrace: 'session-query.md',
SessionEventTraceRequest: 'session-query.md',
SessionEventWindow: 'session-query.md',
SessionLineageTrace: 'session-query.md',
SessionRecord: 'session-query.md',
SessionTitleProvider: 'session-title.md',
SessionTitleSnapshot: 'session-title.md',
SkillDefinition: 'skills.md',
SkillLookupOptions: 'skills.md',
SkillProvider: 'skills.md',
SkillRegistration: 'skills.md',
SkillSummary: 'skills.md',
SaveTextSpill: 'spill.md',
SpillRef: 'spill.md',
SubagentProvider: 'subagent.md',
SubagentRun: 'subagent.md',
SubagentService: 'subagent.md',
SubagentStartRequest: 'subagent.md',
AssembleContext: 'system-prompt.md',
PromptSection: 'system-prompt.md',
SystemPrompt: 'system-prompt.md',
ToolProviderResult: 'system-prompt.md',
TaskDoneListener: 'tasks.md',
TaskId: 'tasks.md',
TaskRead: 'tasks.md',
TaskSnapshot: 'tasks.md',
TaskStart: 'tasks.md',
TokenMeasurement: 'token-meter.md',
PostToolDecision: 'tools.md',
PreToolDecision: 'tools.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolDispatchExecution: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionMode: 'tools.md',
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',
ToolGuard: 'tools.md',
ToolRegistry: 'tools.md',
ToolRestriction: 'tools.md',
ToolSchema: 'tools.md',
AskUserQuestionAnswer: 'user-interaction.md',
AskUserQuestionRequest: 'user-interaction.md',
UserInteractionProvider: 'user-interaction.md',
WebFetchProvider: 'web.md',
WebFetchRequest: 'web.md',
WebFetchResult: 'web.md',
WebSearchProvider: 'web.md',
WebSearchRequest: 'web.md',
WebSearchResult: 'web.md',
WorkflowRun: 'workflow.md',
WorkflowRunInfo: 'workflow.md',
WorkflowStartRequest: 'workflow.md',
}
/** TypeScript lib and pinned framework types that have no repository-owned data page. */
const FOUNDATION_TYPE_NAMES = new Set([
'AbortSignal',
'AsyncIterable',
'Context',
'Error',
'Pick',
'Promise',
'Readonly',
])
/** Project types deliberately documented outside the core-data catalog. */
const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
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',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/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',
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/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',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/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',
}
/** Collect named references from parameter, generic-constraint/default, and return types. */
function signatureTypeNames(member: ts.MethodSignature | ts.MethodDeclaration, sf: ts.SourceFile): string[] {
const declared = new Set(member.typeParameters?.map(parameter => parameter.name.text) ?? [])
const referenced = new Set<string>()
const visit = (node: ts.Node): void => {
if (ts.isTypeReferenceNode(node)) referenced.add(node.typeName.getText(sf))
if (ts.isTypeQueryNode(node)) referenced.add(node.exprName.getText(sf))
ts.forEachChild(node, visit)
}
for (const parameter of member.typeParameters ?? []) {
if (parameter.constraint) visit(parameter.constraint)
if (parameter.default) visit(parameter.default)
}
for (const parameter of member.parameters) {
if (parameter.type) visit(parameter.type)
}
if (member.type) visit(member.type)
return [...referenced].filter(name => !declared.has(name)).sort()
}
/** Append fail-closed signature type-link violations with actionable ownership choices. */
function checkTypeLinks(
where: string,
member: ts.MethodSignature | ts.MethodDeclaration,
sf: ts.SourceFile,
violations: string[],
): void {
for (const name of signatureTypeNames(member, sf)) {
if (Object.hasOwn(LINK_MAP, name)
|| FOUNDATION_TYPE_NAMES.has(name)
|| Object.hasOwn(TYPE_LINK_EXEMPTIONS, name)) continue
violations.push(
`${where} references unclassified type '${name}'. Add it to LINK_MAP with its core-data-structures page, `
+ 'to FOUNDATION_TYPE_NAMES if TypeScript or Cordis owns it, or to TYPE_LINK_EXEMPTIONS with '
+ 'the non-catalog documentation owner.',
)
}
}
/** Throw one aggregated diagnostic for every unclassified signature type. */
function reportTypeLinkViolations(gate: string, violations: string[]): void {
if (violations.length === 0) return
throw new Error(
`${gate}: ${violations.length} signature type-link coverage violation(s):\n`
+ violations.map(violation => ` ${violation}`).join('\n'),
)
}
/** One harness event, extracted from an `interface Events` block. */
@@ -72,6 +251,8 @@ interface EventEntry {
scope: string
/** Full signature text (the method-signature member, JSDoc stripped). */
signature: string
/** Original declaration JSDoc, dedented from its containing interface. */
jsDoc: string
/** Dispatch mode from the `@mode` tag. */
mode: Mode
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
@@ -80,6 +261,14 @@ interface EventEntry {
source: string
}
/** One public service method and the source contract attached to it. */
interface ServiceMethodEntry {
/** Public method signature (body stripped). */
signature: string
/** Original method JSDoc, dedented from its containing class. */
jsDoc: string
}
/** One harness service, extracted from an `interface Context` block. */
interface ServiceEntry {
/** The `ctx.<key>` name, e.g. `llm`. */
@@ -90,8 +279,8 @@ interface ServiceEntry {
abstract: boolean
/** Class-level JSDoc prose, one line per paragraph. */
doc: string
/** Public method signatures (bodies stripped), in source order. */
methods: string[]
/** Public methods (bodies stripped), in source order. */
methods: ServiceMethodEntry[]
/** Source pointer of the class declaration. */
source: string
}
@@ -104,15 +293,7 @@ interface InheritedEntry {
source: string
}
/** Find the `declare module 'cordis'` body in a source file, or null. */
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
for (const stmt of sf.statements) {
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
}
}
return null
}
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
/** The signature text of a method-signature member (everything but a body). */
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
@@ -122,6 +303,22 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
}
/**
* Copy a node's original JSDoc while removing only the indentation imposed by
* its containing interface or class.
*/
function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
if (!raw) return ''
const start = text.lastIndexOf(raw, node.getStart(sf))
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return raw.split('\n')
.map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
}
/** Walk every harness `interface Events` block and extract its events, hard-
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
* contradicted `@mode`, missing description prose, or an undocumented payload
@@ -129,6 +326,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
export function collectEvents(scanRoot: string = root): EventEntry[] {
const entries: EventEntry[] = []
const violations: string[] = []
const typeLinkViolations: string[] = []
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
@@ -136,41 +334,38 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!ts.isMethodSignature(member)) continue
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
const signature = memberSignature(member, sf)
const raw = rawJsDoc(text, member)
const { doc, mode } = parseJsDoc(raw)
const src = pointer(rel, sf, member)
const where = `event '${name}' (${src})`
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
// distinguishable, so it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (mode && hasNext && mode !== 'waterfall') {
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (mode && !hasNext && mode === 'waterfall') {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
for (const { name, member } of eventMembers(body, sf)) {
const signature = memberSignature(member, sf)
const raw = rawJsDoc(text, member)
const { doc, mode } = parseJsDoc(raw)
const src = pointer(rel, sf, member)
const where = `event '${name}' (${src})`
checkTypeLinks(where, member, sf, typeLinkViolations)
if (!mode) {
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
}
// Conclusive structural check: a trailing `next: () => …` parameter is a
// waterfall. (emit vs parallel vs serial is not structurally
// distinguishable, so it is trusted from the tag.)
const last = member.parameters.at(-1)
const hasNext = !!last && last.name.getText(sf) === 'next'
if (mode && hasNext && mode !== 'waterfall') {
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
}
if (mode && !hasNext && mode === 'waterfall') {
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
}
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
// Payload parameters need a non-empty @param. The `this` receiver is not
// payload, and a waterfall's trailing `next` is covered by its mode.
const { params } = parseTags(raw)
checkParams(where, 'event', member.parameters, params, sf,
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src })
}
}
reportViolations('gen-cordis-catalog', violations)
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
return entries
}
@@ -183,6 +378,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
export function collectServices(scanRoot: string = root): ServiceEntry[] {
const entries: ServiceEntry[] = []
const violations: string[] = []
const typeLinkViolations: string[] = []
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
@@ -190,27 +386,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
// The ctx key → type mapping(s) declared in this file's interface Context.
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
const key = member.name.getText(sf)
keyToType.set(key, member.type.getText(sf))
}
}
if (keyToType.size === 0) continue
// Find each service class declared in the same file and emit an entry.
for (const [key, type] of keyToType) {
const cls = sf.statements.find(
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
)
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
const methods: string[] = []
// Resolve each ctx key to its service class (shared walk) and emit an entry.
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
const methods: ServiceMethodEntry[] = []
for (const member of cls.members) {
if (!ts.isMethodDeclaration(member)) continue
// Only instance methods callable through `ctx.<key>` are surface;
@@ -223,9 +401,10 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
if (nonPublic) continue
const memberName = member.name.getText(sf)
if (memberName.startsWith('[')) continue // computed/symbol members
methods.push(memberSignature(member, sf))
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
checkTypeLinks(where, member, sf, typeLinkViolations)
const raw = rawJsDoc(text, member)
methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) })
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
const { params, returns } = parseTags(raw)
@@ -247,6 +426,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
}
}
reportViolations('gen-cordis-catalog', violations)
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
return entries.sort((a, b) => a.key.localeCompare(b.key))
}
@@ -260,14 +440,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
* sibling check is N/A; keep them current on a vendor bump.
*/
const INHERITED_EVENTS: InheritedEntry[] = [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
{ 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' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ 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:21' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
@@ -278,12 +458,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
]
export const INHERITED_SERVICES: InheritedEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ 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:35' },
{ 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.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
@@ -305,7 +485,7 @@ function typeLinks(signature: string): string {
function renderEvent(e: EventEntry): string[] {
const out = [`### \`${e.name}\`${e.mode}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, e.signature, '```', '')
out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
const links = typeLinks(e.signature)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
@@ -318,8 +498,13 @@ function renderService(s: ServiceEntry): string[] {
const out = [`## \`ctx.${s.key}\`\`${s.type}\`${kind}`, '']
if (s.doc) out.push(s.doc, '')
if (s.methods.length) {
out.push('```' + FENCE, ...s.methods, '```', '')
const links = typeLinks(s.methods.join('\n'))
const declarations = s.methods.flatMap((method, index) => [
...(index > 0 ? [''] : []),
method.jsDoc,
method.signature,
])
out.push('```' + FENCE, ...declarations, '```', '')
const links = typeLinks(s.methods.map(method => method.signature).join('\n'))
if (links) out.push(links, '')
}
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
@@ -334,19 +519,19 @@ const BANNER = [
]
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.'
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
/** Render the events catalog (pure, deterministic given sorted inputs). */
function renderEvents(events: EventEntry[]): string {
export function renderEvents(events: EventEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Events Catalog',
'',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
'',
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
'',
@@ -372,16 +557,16 @@ function renderEvents(events: EventEntry[]): string {
}
/** Render the services catalog (pure, deterministic given sorted inputs). */
function renderServices(services: ServiceEntry[]): string {
export function renderServices(services: ServiceEntry[]): string {
const lines: string[] = [
...BANNER,
'# Cordis Services Catalog',
'',
'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
'',
GATE_NOTICE,
'',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.',
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
'',
]
for (const s of services) lines.push(...renderService(s))
@@ -405,6 +590,7 @@ function main(): void {
const outputs: [string, string][] = [
[OUT_EVENTS, renderEvents(collectEvents())],
[OUT_SERVICES, renderServices(collectServices())],
...renderCordisCoreApiPages(),
]
if (process.argv.includes('--check')) {
const stale: string[] = []
@@ -421,15 +607,19 @@ function main(): void {
if (committed !== content) stale.push(out)
}
if (stale.length === 0) {
console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
console.log(`gen-cordis-catalog: ${outputs.length} generated file(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.`)
process.exit(1)
}
for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
for (const [out, content] of outputs) {
const destination = resolve(root, out)
mkdirSync(dirname(destination), { recursive: true })
writeFileSync(destination, content)
}
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
}
// Run only when invoked as a script, not when imported by a test.

View File

@@ -58,18 +58,24 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'sandbox',
'fs',
'skill',
'compact',
'subagent',
'tasks',
'workflow',
'web',
'spill',
'todo',
'plan',
'cordis',
'hooks',
'session-persistence',
'session-query',
'session-title',
'support',
'ui',
]
@@ -84,29 +90,61 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'compact-basic'],
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
},
{
key: 'tokenMeter',
pkg: 'token-meter',
title: 'Replay token measurement',
mode: 'core',
consumers: ['compact-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
{
key: 'toolResultPrune',
pkg: 'compact-tool-result-prune',
title: 'Model-free tool-result pruning',
mode: 'core',
consumers: ['compact-basic'],
note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
},
{
key: 'sessions',
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
key: 'invariants',
pkg: 'invariants',
title: 'Package-owned invariant registry',
mode: 'core',
consumers: ['session', 'agent', 'scope', 'agent-loop'],
note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.',
},
{
key: 'sessionPersistence',
pkg: 'session-persistence',
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
consumers: ['agent-loop', 'acp', 'session-query'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'sessionQuery',
pkg: 'session-query',
title: 'Exact session-history reads',
title: 'Exact session-history reads and traces',
mode: 'seam',
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.',
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
},
{
key: 'sessionTitle',
pkg: 'session-title',
title: 'Log-backed session titles',
mode: 'seam',
implementations: ['session-title-first-message-llm', 'session-title-all-messages-llm'],
note: 'Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration.',
},
{
key: 'systemPrompt',
@@ -129,10 +167,26 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'user-interaction',
title: 'Human question/answer seam',
mode: 'seam',
implementations: ['stdio-demo', 'acp'],
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
implementations: ['tui', 'acp'],
consumers: ['tool-ask-user', 'tui', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'planMode',
pkg: 'plan-mode',
title: 'Plan collaboration state',
mode: 'core',
consumers: ['acp'],
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
},
{
key: 'commands',
pkg: 'commands',
title: 'Human command registry',
mode: 'core',
consumers: ['tui', 'acp'],
note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
},
{
key: 'skills',
pkg: 'skill',
@@ -145,10 +199,10 @@ const SERVICE_ROLES: ServiceRole[] = [
{
key: 'agents',
pkg: 'agent',
title: 'Agent registry',
title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
note: 'Owns live Agent handles and the create/resume factory seam.',
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentLoop',
@@ -158,6 +212,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-spine-demo'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'goals',
pkg: 'goal',
title: 'Same-session goal domain',
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'bash',
pkg: 'bash',
@@ -167,6 +228,13 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
},
{
key: 'bashEnv',
pkg: 'tool-bash',
title: 'Managed bash environment registry',
mode: 'core',
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
},
{
key: 'sandbox',
pkg: 'sandbox',
@@ -176,6 +244,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['bash-sandbox'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
},
{
key: 'sandboxPolicy',
pkg: 'sandbox-policy',
title: 'Sandbox policy home',
mode: 'core',
implementations: [],
consumers: ['bash-sandbox', 'fs-sandbox'],
note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
},
{
key: 'approval',
pkg: 'approval',
@@ -208,10 +285,10 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'fs',
title: 'Filesystem provider seam',
mode: 'seam',
implementations: ['fs-local'],
implementations: ['fs-local', 'fs-sandbox'],
consumers: ['tool-fs'],
companions: ['fs-policy'],
note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate.',
},
{
key: 'compact',
@@ -220,16 +297,24 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'seam',
implementations: ['compact-basic'],
consumers: ['compact-basic'],
note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.',
note: 'The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred.',
},
{
key: 'subagents',
pkg: 'subagent',
title: 'Subagent provider registry',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'],
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
consumers: ['tool-subagent', 'tool-ralph'],
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
},
{
key: 'tasks',
pkg: 'tasks',
title: 'Background task registry',
mode: 'core',
consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'],
note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
},
{
key: 'web',
@@ -240,14 +325,23 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
{
key: 'spillStore',
pkg: 'spill',
title: 'Spill storage seam',
mode: 'seam',
implementations: ['spill-local'],
consumers: ['spill-policy'],
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
{
key: 'workflows',
pkg: 'workflow',
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-workerthread'],
consumers: ['tool-workflow'],
note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
consumers: ['tool-workflow', 'tool-ralph'],
note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
},
]
@@ -384,20 +478,20 @@ function stripYamlScalar(value: string): string {
const APP_EXAMPLES = [
{
id: 'echo',
rel: 'examples/echo-agent/composition.md',
title: 'Echo Agent App Composition',
label: 'examples/echo-agent',
config: 'examples/echo-agent/cordis.yml',
summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
id: 'tui',
rel: 'examples/tui-agent/composition.md',
title: 'TUI Agent App Composition',
label: 'examples/tui-agent',
config: 'examples/tui-agent/cordis.yml',
summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
},
{
id: 'coding',
rel: 'examples/coding-agent/composition.md',
title: 'Coding Agent App Composition',
label: 'examples/coding-agent',
config: 'examples/coding-agent/cordis.yml',
summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
id: 'headless',
rel: 'examples/headless-agent/composition.md',
title: 'Headless Agent App Composition',
label: 'examples/headless-agent',
config: 'examples/headless-agent/cordis.yml',
summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.',
},
{
id: 'cordis',
@@ -424,8 +518,10 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
const jsonl = nodeId('bundle', 'jsonl')
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
if (pluginName === '@deepseek-ai/dsh-tui-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui<br/>pre-created main agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
}
@@ -452,7 +548,7 @@ function renderAppComposition(example: AppExample): string {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
renderAppExpansion(lines, pluginNode, plugin.name)
}
}
@@ -810,19 +906,40 @@ function renderLifecycle(): string {
' LLM-->>Driver: StreamChunk*',
` Driver->>Session: ${mermaidCode('assistant/chunk')}*`,
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
' alt final adapter or terminal in-band request failure',
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
' Hooks-->>Driver: retry in a new step or preserve the original error',
' else model request succeeded',
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
` Driver->>Session: ${mermaidCode('assistant/message')}`,
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: execute through pre and post waterfalls',
' Tools-->>Session: tool-owned events when applicable',
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
' Driver->>Tools: classify pending call by executionMode',
' loop barriers and bounded rolling pool, reclassify before start',
' opt call starts',
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: ordered pre, concurrent execute',
' Tools-->>Session: tool-owned events when applicable',
' end',
' opt next model-order result ready',
' Driver->>Tools: ordered post',
` Driver->>Session: ${mermaidCode('tool/result')}`,
' end',
' end',
' Driver->>Session: post-tool context and steering',
` Driver->>Hooks: ${mermaidCode('agent/post-step')} serial checkpoint`,
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
' end',
` Driver->>Session: ${mermaidCode('turn/end')}`,
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
'```',
'',
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
'',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
'',
...maintenanceFooter(maintenance),
@@ -850,9 +967,9 @@ function renderToolPipeline(): string {
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Buffered additionalContext<br/>context/message after all tool results"]',
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' toolCall --> presentCall',
@@ -878,7 +995,7 @@ function renderToolPipeline(): string {
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
'',
...maintenanceFooter(maintenance),
].join('\n')
@@ -897,14 +1014,14 @@ function renderSnapshotReplay(): string {
' participant Workspace',
' participant Replay as llm-replay adapter',
' participant ACP as acp-agent subprocess',
' participant Golden as stdout golden',
' participant Expected as stdout expected output',
' Recorder->>Fixture: session.jsonl + workspace inputs',
' Fixture->>Workspace: seed files and hook configs',
' Fixture->>Replay: recorded StreamChunk script',
` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`,
' ACP->>Workspace: bash, fs, and hook side effects',
' ACP->>Golden: normalized sessionUpdate stream',
' Golden-->>ACP: diff must be empty',
' ACP->>Expected: normalized sessionUpdate stream',
' Expected-->>ACP: diff must be empty',
'```',
'',
'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
@@ -930,8 +1047,8 @@ function renderDocs(): GraphDoc[] {
function renderIndex(docs: GraphDoc[]): string {
const labels: Record<string, string> = {
'docs/capability-seams.md': 'capability seams and core services',
'examples/echo-agent/composition.md': 'echo-agent app composition',
'examples/coding-agent/composition.md': 'coding-agent app composition',
'examples/headless-agent/composition.md': 'headless-agent app composition',
'examples/tui-agent/composition.md': 'tui-agent app composition',
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
'examples/acp-agent/composition.md': 'acp-agent app composition',
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
@@ -941,8 +1058,8 @@ function renderIndex(docs: GraphDoc[]): string {
}
const modes: Record<string, string> = {
'docs/capability-seams.md': 'hybrid generated',
'examples/echo-agent/composition.md': 'hybrid generated',
'examples/coding-agent/composition.md': 'hybrid generated',
'examples/headless-agent/composition.md': 'hybrid generated',
'examples/tui-agent/composition.md': 'hybrid generated',
'examples/cordis-agent/composition.md': 'hybrid generated',
'examples/acp-agent/composition.md': 'hybrid generated',
'docs/event-producer-consumer.md': 'hybrid generated',
@@ -963,7 +1080,7 @@ function renderIndex(docs: GraphDoc[]): string {
...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).',
'',
'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md).',
'',
'| Graph | Mode |',
'| --- | --- |',

View File

@@ -21,18 +21,22 @@ const GROUP_ORDER = [
'util',
'llm',
'core',
'goal',
'bash',
'fs',
'skill',
'compact',
'subagent',
'web',
'spill',
'timeout',
'todo',
'plan',
'cordis',
'hooks',
'session-persistence',
'session-query',
'session-title',
'support',
'ui',
]

View File

@@ -1,7 +1,7 @@
/**
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
* the owning `SurfaceEventType` union. This is the durable-record vocabulary,
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
* the owning event-envelope types. This is the durable-record vocabulary, not
* the live Cordis bus. Event declarations must be unique, explicitly typed,
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
*/
@@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
/** The fenced-block info string for generated payload blocks (skipped by
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
/** Primary core-data-structures page for linked payload types. */
const LINK_MAP: Record<string, string> = {
CallId: 'core.md',
@@ -31,6 +41,11 @@ const LINK_MAP: Record<string, string> = {
TodoItem: 'session.md',
TurnTrigger: 'session.md',
TurnEndReason: 'session.md',
SessionTitleEventData: 'session-title.md',
SessionTitleLlmRequestEventData: 'session-title.md',
SessionTitleModelProvenance: 'session-title.md',
SessionTitleProviderId: 'session-title.md',
SessionTitleSource: 'session-title.md',
}
/** One log event, extracted from a `SessionEventMap` declaration. */
@@ -41,6 +56,8 @@ export interface LogEventEntry {
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
@@ -53,6 +70,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
surface: boolean
}
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name: EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
const printer = ts.createPrinter({ removeComments: true })
/**
@@ -67,6 +94,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
.trim()
}
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
const nodeStart = node.getStart(sf)
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return text.slice(lineStart, node.end)
.split('\n')
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
.trimEnd()
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
@@ -177,7 +222,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
const declaration = declarationText(text, sf, member)
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
}
}
}
@@ -185,6 +231,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
return entries
}
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
const violations: string[] = []
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
const name = stmt.name.text as EventEnvelopeTypeName
const src = pointer(rel, sf, stmt)
const where = `event-envelope type '${name}' (${src})`
const prior = found.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
continue
}
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
violations.push(`${where} is not exported.`)
}
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
if (missing.length > 0) {
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
}
reportViolations('gen-persistence-catalog', violations)
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
const entry = found.get(name)
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
return entry
})
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
@@ -246,8 +337,7 @@ function typeLinks(payload: string): string {
/** Render one log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
const out = [`#### \`${e.name}\`${e.surface ? 'surface' : 'log-only'}`, '']
if (e.doc) out.push(e.doc, '')
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
out.push('```' + FENCE, e.declaration, '```', '')
const links = typeLinks(e.payload)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
@@ -255,18 +345,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(events: AnnotatedLogEventEntry[]): string {
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
const lines: string[] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
'# Persistence Log Event Catalog',
'# Session Persistence Event Catalog',
'',
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `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](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).',
'',
'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. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
'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/implemented/process/2026-07-04-persistence-log-catalog.md).',
'',
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: 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](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.',
'',
'## Event envelope',
'',
'```' + FENCE,
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
'```',
'',
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
'',
'## Events',
'',
@@ -285,7 +383,7 @@ export function render(events: AnnotatedLogEventEntry[]): string {
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
function main(): void {
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
if (process.argv.includes('--check')) {
let committed: string | null = null
try {

View File

@@ -1,36 +0,0 @@
/**
* Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the
* RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and
* rendering rules). The whole file is generated state; the curated prose lives
* in `docs/rfc/README.md`. Freshness is asserted by
* `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed
* index fails CI.
*
* Run: `pnpm run gen-rfc-index`.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
if (errors.length > 0) {
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)
}
const indexPath = resolve(rfcRoot, 'INDEX.md')
const next = renderIndex(rfcs)
let current: string | undefined
try {
current = readFileSync(indexPath, 'utf8')
} catch {
// Missing INDEX.md is the fresh-generation case, not an error: fall through and write it.
}
if (next === current) {
console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`)
} else {
writeFileSync(indexPath, next)
console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`)
}

View File

@@ -1,6 +1,6 @@
/**
* Generate the dev-invariants scoped-event resolver map from the
* repository TypeScript Program.
* Generate dsh-scope's invariant resolver map from the repository TypeScript
* Program.
*
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
* calls establish the routing-key type for that base. The generator searches
@@ -20,7 +20,7 @@ import { pointer, rawJsDoc } from './jsdoc.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
const OUT = 'packages/core/scope/src/scoped-events.generated.ts'
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
interface ScopeTargetContract {
@@ -39,7 +39,6 @@ interface SubjectCandidate {
interface ScopedEventResolver {
event: string
candidate: SubjectCandidate | null
ownerPackage: string
}
interface ScopeTag {
@@ -54,7 +53,6 @@ class ScopedEventGenerator {
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
private readonly scopedSymbol: ts.Symbol
private readonly violations: string[] = []
private readonly packageNames = new Map<string, string>()
constructor(private readonly project: TypeScriptProject) {
this.checker = project.checker
@@ -81,44 +79,25 @@ class ScopedEventGenerator {
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
)
}
const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
.sort()
.map(packageName => `import type {} from ${quote(packageName)}`)
return [
'/**',
' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.',
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
' *',
' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
' * @module @deepseek-ai/dsh-scope/scoped-events.generated',
' */',
'',
"import type { Events } from 'cordis'",
"import type { Scoped } from '@deepseek-ai/dsh-scope'",
...ownerImports,
'',
'type ScopedEventName = {',
' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
'}[keyof Events]',
'',
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
'',
'function adapt<K extends ScopedEventName>(',
' resolver: (args: Parameters<Events[K]>) => unknown,',
'): ScopedSubjectResolver {',
' return args => resolver(args as Parameters<Events[K]>)',
'}',
'',
'const scopedSubjectResolvers = Object.freeze({',
'const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({',
...resolvers.map(({ event, candidate }) => {
if (candidate === null) return ` '${event}': null,`
const subject = candidate.property === undefined
? `args[${candidate.parameter}]`
: `args[${candidate.parameter}].${candidate.property}`
return ` '${event}': adapt<'${event}'>(args => ${subject}),`
: `(args[${candidate.parameter}] as Record<string, unknown>)[${quote(candidate.property)}]`
return ` '${event}': args => ${subject},`
}),
'} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
'',
'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
'})',
'',
'/**',
' * Resolve the routing key named by one scoped event payload. A null',
@@ -129,7 +108,7 @@ class ScopedEventGenerator {
' * or undefined when the event is not scope-filtered.',
' */',
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
' return scopedSubjectResolverIndex[event]',
' return scopedSubjectResolvers[event]',
'}',
'',
].join('\n')
@@ -186,7 +165,6 @@ class ScopedEventGenerator {
const resolvers: ScopedEventResolver[] = []
for (const sourceFile of this.packageSources) {
const rel = this.project.relativePath(sourceFile)
const ownerPackage = this.packageName(packageRootFor(rel))
const visit = (node: ts.Node): void => {
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
for (const member of node.members) {
@@ -232,7 +210,7 @@ class ScopedEventGenerator {
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
)
}
resolvers.push({ event, candidate: null, ownerPackage })
resolvers.push({ event, candidate: null })
continue
}
if (tag.unsupported) {
@@ -241,7 +219,7 @@ class ScopedEventGenerator {
)
continue
}
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
resolvers.push({ event, candidate: candidates[0] ?? null })
}
}
ts.forEachChild(node, visit)
@@ -311,19 +289,6 @@ class ScopedEventGenerator {
return dedupeCandidates(candidates)
}
/** Read and cache one workspace package name. */
private packageName(packageRoot: string): string {
const cached = this.packageNames.get(packageRoot)
if (cached) return cached
const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
const name: unknown = typeof manifest === 'object' && manifest !== null
? Reflect.get(manifest, 'name')
: undefined
if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
this.packageNames.set(packageRoot, name)
return name
}
/** Compare exact Program type identities after removing null and undefined. */
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
const normalizedLeft = this.normalizedType(left)
@@ -398,13 +363,6 @@ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandi
})
}
/** Return the workspace package root owning one package source file. */
function packageRootFor(relativePath: string): string {
const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
return match[1]
}
/** Quote a generated property key as a single-quoted TypeScript string. */
function quote(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
@@ -419,7 +377,7 @@ export function renderScopedEvents(projectRoot: string = root): string {
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
}
/** Generate or freshness-check the fixed invariants source file. */
/** Generate or freshness-check the fixed dsh-scope source file. */
function main(): void {
const content = renderScopedEvents()
const output = resolve(root, OUT)

View File

@@ -3,38 +3,104 @@
* plugin. Runtime registration is the source of truth for computed schemas;
* the manifest is checked against every on-disk `tool-*` package. `--check`
* verifies the committed artifact. Rationale and ownership live in
* `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`.
* `.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md`.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService from '@deepseek-ai/dsh-goal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
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'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog.md'
const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/**
* Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search
* plugin now probes `rg` at registration time, but the generated catalog must
* remain independent of the host PATH and never execute a real search.
*/
class CatalogSearchBashExecutor extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? root,
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command !== CATALOG_RG_PROBE_COMMAND) {
throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`)
}
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('gen-tool-catalog: search schema harvest must not start background processes')
}
}
/**
* Register the descriptor needed to mount schema-producing consumers. Declares
* the full capability set of the shipped in-process providers so consumers
* mount under their shipped defaults (tool-subagent's default numeric maxDepth
* requires `depthLimit`).
*/
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
const provider: SubagentProvider = {
name,
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
}
ctx.subagents.registerProvider(provider)
}
/**
* Tool package plus its hand-maintained boot recipe. The caller mounts the
@@ -105,20 +171,32 @@ const TOOL_PACKAGES: ToolPackage[] = [
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-plan-mode',
dir: 'plan-mode',
source: 'packages/plan/plan-mode/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'],
writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'],
async mount(ctx) {
await ctx.plugin(PlanModeService, { section: 'Tool catalog schema harvest.' })
},
note:
'exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
source: 'packages/bash/tool-bash/src/index.ts',
requires: ['ctx.tools', 'ctx.bash'],
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
note:
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
},
{
pkg: '@deepseek-ai/dsh-tool-cordis',
@@ -130,7 +208,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolCordis)
},
note:
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
@@ -147,6 +225,67 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs-search',
dir: 'tool-fs-search',
source: 'packages/fs/tool-fs-search/src/index.ts',
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tools inject `bash` (search executes fixed `rg` commands through
// the executor seam, not ctx.fs). Use a catalog-only executor so the
// registration-time `rg` probe stays deterministic and the generator
// never depends on the host PATH. `ctx.spillStore` is optional (read via
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
await ctx.plugin(CatalogSearchBashExecutor)
await ctx.plugin(ToolFsSearch)
},
note:
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
},
{
pkg: '@deepseek-ai/dsh-tool-goal',
dir: 'tool-goal',
source: 'packages/goal/tool-goal/src/index.ts',
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
await ctx.plugin(ToolGoal)
},
note:
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
},
{
pkg: '@deepseek-ai/dsh-tool-lsp',
dir: 'tool-lsp',
source: 'packages/lsp/tool-lsp/src/index.ts',
requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// The tool registers from the seam alone; the schema does not depend on any provider.
await ctx.plugin(Lsp)
await ctx.plugin(ToolLsp)
},
note:
'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.',
},
{
pkg: '@deepseek-ai/dsh-tool-ralph',
dir: 'tool-ralph',
source: 'packages/workflow/tool-ralph/src/index.ts',
requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'],
writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'],
async mount(ctx) {
await ctx.plugin(SubagentService)
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
},
note:
'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',
dir: 'tool-skill',
@@ -171,12 +310,24 @@ const TOOL_PACKAGES: ToolPackage[] = [
shippedNames: ['subagent', 'subagent_fork'],
async mount(ctx) {
await ctx.plugin(SubagentService)
// Register a scripted provider under the name the tool delegates to.
await ctx.plugin(SubagentMock, { name: 'mock' })
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(ToolSubagent, { provider: 'mock' })
},
note:
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
},
{
pkg: '@deepseek-ai/dsh-tool-tasks',
dir: 'tool-tasks',
source: 'packages/tasks/tool-tasks/src/index.ts',
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
async mount(ctx) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
},
note:
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
},
{
pkg: '@deepseek-ai/dsh-tool-todo',
@@ -201,7 +352,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
// subagent provider to satisfy it. The schema does not depend on which
// provider backs the engine.
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentMock, { name: 'mock' })
registerCatalogSubagentProvider(ctx, 'mock')
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
await ctx.plugin(ToolWorkflow)
},
@@ -324,7 +475,7 @@ export function render(catalog: ToolCatalog): string {
'',
'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.',
'',
'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 RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
'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).',
'',
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
'',

284
scripts/install.sh Executable file
View File

@@ -0,0 +1,284 @@
#!/bin/sh
# dsh one-line installer.
#
# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
#
# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node,
# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build —
# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx),
# symlinks `dsh` onto PATH, records your API credentials in the Harness home
# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`.
#
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving
# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE
# to a different directory opts back into the normal clone/update path.
#
# When run through `curl | sh` the script text arrives on stdin, so every
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
# with no terminal the script prints the manual next steps instead.
#
# Overridable via environment:
# DSH_REF branch or tag to clone/checkout (default: master)
# DSH_REPO clone URL (default: the GitHub repo)
# DSH_SOURCE checkout location (default: ~/.dsh/source)
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
# DSH_HOME Harness home holding the personal config (default: ~/.dsh)
# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
# entrypoint; keep this POSIX shell file as the curl/source bootstrap.
set -eu
DSH_REF=${DSH_REF:-master}
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
# Remember whether the caller pinned a source location before defaulting it, so
# in-repo detection only repoints an unset DSH_SOURCE.
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
# --- in-repo detection ---------------------------------------------------------
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
# name and no file path resolves; running a checked-out copy (`sh
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing
# elsewhere opts back into the clone/update path.
IN_REPO=0
if [ -f "$0" ]; then
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
if [ -n "$_self_dir" ]; then
_repo_root=$(dirname -- "$_self_dir")
if [ "$(basename -- "$_self_dir")" = scripts ] \
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
IN_REPO=1
DSH_SOURCE=$_repo_root
fi
fi
fi
fi
# --- terminal-aware prompting --------------------------------------------------
# stdin is the piped script, so read the controlling terminal for input.
if { true </dev/tty; } 2>/dev/null; then
HAS_TTY=1
# Restore terminal echo on exit or interrupt: ask_secret disables echo between
# its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
# shell is killed by a signal, so the fatal signals need their own handler. A
# successful run ends in exec, which replaces this process and drops the traps.
trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
else
HAS_TTY=0
fi
# Colour only when writing to a terminal.
if [ -t 1 ]; then
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
else
B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
fi
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
ask() {
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
IFS= read -r _ans </dev/tty || _ans=''
[ -n "$_ans" ] || _ans=${2:-}
printf '%s' "$_ans"
}
# ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
ask_secret() {
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
stty -echo </dev/tty 2>/dev/null || true
IFS= read -r _sec </dev/tty || _sec=''
stty echo </dev/tty 2>/dev/null || true
printf '\n' >/dev/tty
printf '%s' "$_sec"
}
# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
confirm() {
_def=${2:-N}
if [ "$HAS_TTY" != 1 ]; then
[ "$_def" = Y ] # non-interactive: take the default
return
fi
if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
IFS= read -r _r </dev/tty || _r=''
[ -n "$_r" ] || _r=$_def
case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
}
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
printf '%ssource %s @ %s%s\n' "$DIM" "$DSH_SOURCE" "$DSH_REF" "$RST"
# --- 1. dependency check -------------------------------------------------------
step "Checking dependencies"
command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
info "git ... ok"
# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
node_ok() {
command -v node >/dev/null 2>&1 || return 1
_v=$(node -v 2>/dev/null) || return 1
_v=${_v#v}
_major=${_v%%.*}
_rest=${_v#*.}
_minor=${_rest%%.*}
case "$_major" in ''|*[!0-9]*) return 1 ;; esac
case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
[ "$_major" -ge 24 ] && return 0
[ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
return 1
}
if node_ok; then
info "node $(node -v) ... ok"
else
if command -v node >/dev/null 2>&1; then
die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
fi
die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
fi
# pnpm is the only dependency we offer to install for you.
if command -v pnpm >/dev/null 2>&1; then
info "pnpm $(pnpm --version 2>/dev/null) ... ok"
else
warn "pnpm is not installed."
if confirm "Install pnpm now?" Y; then
if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
info "enabled pnpm via corepack"
elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
info "installed pnpm via npm"
else
die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
fi
command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
else
die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
fi
fi
# --- 2. clone (or update) the source ------------------------------------------
if [ "$IN_REPO" = 1 ]; then
step "Using existing checkout at $DSH_SOURCE"
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
else
step "Fetching source into $DSH_SOURCE"
if [ -d "$DSH_SOURCE/.git" ]; then
info "existing checkout found — updating"
git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF"
# Reset the checkout to the freshly fetched tip. FETCH_HEAD (not
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
# the re-run idempotent whether or not DSH_REF changed since the last install.
git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD
else
mkdir -p "$(dirname "$DSH_SOURCE")"
git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE"
fi
fi
# --- 3. install dependencies (no build; the launcher runs from source) --------
step "Installing dependencies with pnpm (this can take a while)"
( cd "$DSH_SOURCE" && pnpm install )
[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
# --- 4. put `dsh` on PATH ------------------------------------------------------
step "Linking dsh into $DSH_BIN_DIR"
mkdir -p "$DSH_BIN_DIR"
ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh"
case ":$PATH:" in
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
*) ON_PATH=0 ;;
esac
if [ "$ON_PATH" = 0 ]; then
warn "$DSH_BIN_DIR is not on your PATH."
_line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
_rc=''
_sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
case "${_sh##*/}" in
zsh) _rc="$HOME/.zshrc" ;;
bash) _rc="$HOME/.bashrc" ;;
esac
if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
else
warn "add this line to your shell profile yourself:"
printf ' %s\n' "$_line"
fi
fi
# --- 5. credentials ------------------------------------------------------------
# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
if [ -n "${DSH_HOME:-}" ]; then
CONF="$DSH_HOME"
else
CONF="$HOME/.dsh"
fi
ENV_FILE="$CONF/.env"
step "Configuring credentials"
if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
info "DEEPSEEK_API_KEY already set in $ENV_FILE"
if ! confirm "Replace it?" N; then
SKIP_CREDS=1
fi
fi
if [ "${SKIP_CREDS:-0}" != 1 ]; then
if [ "$HAS_TTY" = 1 ]; then
API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
if [ -z "$API_KEY" ]; then
warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
else
BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
mkdir -p "$CONF"
# The installer owns exactly the two DEEPSEEK_* lines; any other lines the
# user keeps in this .env are preserved. The rewrite happens in a subshell
# so umask 077 (which closes the create-time permission race) does not leak
# into the exec'd dsh, and lands atomically via a same-dir temp + mv.
_tmp="$ENV_FILE.dsh.$$"
(
umask 077
if [ -f "$ENV_FILE" ]; then
grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
else
: >"$_tmp"
fi
printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
)
mv "$_tmp" "$ENV_FILE"
chmod 600 "$ENV_FILE" 2>/dev/null || true
info "wrote $ENV_FILE"
fi
else
warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
fi
fi
# --- 6. launch -----------------------------------------------------------------
step "Done"
if [ "$HAS_TTY" = 1 ]; then
info "launching dsh — run 'dsh' anytime to start again"
exec "$DSH_BIN_DIR/dsh" </dev/tty
else
info "install complete. Start it with:"
printf ' %s\n' "$DSH_BIN_DIR/dsh"
fi

55
scripts/md-fences.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Shared fenced-code-block extractor for the Markdown doc gates
* (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
* classification: each gate maps a fence info string (` ```ts `,
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
* classified block with its 1-based opening-fence line.
*/
import { readFileSync } from 'node:fs'
/** One extracted fenced block, classified by the caller's `classify`. */
export interface Fence<K> {
/** 1-based line of the opening fence. */
line: number
kind: K
code: string
}
/**
* Extract every fenced block of `absPath` whose info string `classify` maps
* to a kind. Blocks classified `null` are skipped (their bodies are still
* consumed, so an unrelated fence can never leak into a tracked one).
*
* @param absPath — absolute path of the Markdown file.
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
* null for fences this gate does not track.
* @returns the classified blocks in document order.
*/
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
const lines = readFileSync(absPath, 'utf8').split('\n')
const blocks: Fence<K>[] = []
let open: { line: number; kind: K; body: string[] } | null = null
let skipping = false
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
if (skipping) {
skipping = false
return
}
const kind = classify((fence[2] ?? '').trim())
if (kind !== null) open = { line: i + 1, kind, body: [] }
else skipping = true
})
return blocks
}

View File

@@ -0,0 +1,187 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectPackageInvariantViolations,
} from './package-invariants.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function handwrittenInvariant(packageName: string): string {
return `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => {
ctx.on('probe/value', (value) => {
if (value < 0) fail('observed values must be non-negative')
})
}
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install))
`
}
function fixture(options: {
packageName?: string
source?: string
invariantExport?: boolean
invariantDependency?: boolean
invariantReference?: boolean
buildEntry?: boolean
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
roots.push(root)
const dir = join(root, 'packages/core/probe')
mkdirSync(join(dir, 'src'), { recursive: true })
const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
const manifest = {
name: packageName,
exports: options.invariantExport === false ? {} : {
'./invariant': {
types: './lib/types/invariant.d.ts',
default: './lib/invariant.js',
},
},
files: ['lib/index.js', 'lib/invariant.js', 'src'],
peerDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': '^0.0.1',
},
devDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': 'workspace:^',
},
}
writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({
references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }],
}, null, 2)}\n`)
writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName))
writeFileSync(
join(dir, 'tsdown.config.ts'),
options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n",
)
return root
}
describe('package invariant gate', () => {
it('accepts a hand-owned checking companion with publication metadata', () => {
expect(collectPackageInvariantViolations(fixture())).toEqual([])
})
it('rejects missing publication metadata and build output', () => {
const violations = collectPackageInvariantViolations(fixture({
invariantExport: false,
invariantDependency: false,
invariantReference: false,
buildEntry: false,
}))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('exports["./invariant"]'),
expect.stringContaining('peerDependency'),
expect.stringContaining('devDependency'),
expect.stringContaining('TypeScript project references'),
expect.stringContaining('must bundle lib/types/invariant.js'),
]))
})
it('rejects foreign, duplicate, and unresolved registrations', () => {
const source = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const selected = process.env.PACKAGE_NAME
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => {
ctx.invariants.register('@deepseek-ai/dsh-foreign', install)
return ctx.invariants.register(selected!, install)
}
`
const violations = collectPackageInvariantViolations(fixture({ source }))
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
expect.stringContaining('must resolve to a local string constant'),
expect.stringContaining('must register exactly its own package name'),
]))
})
it('rejects generated markers and reporter-free executable installers', () => {
const generated = fixture({
source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`,
})
expect(collectPackageInvariantViolations(generated).map(violation => violation.message))
.toContain('invariant companions must be hand-owned and may not carry @generated markers')
const reporterFree = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = () => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message))
.toContain('install function must accept the bound failure reporter as its second parameter')
const unused = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 }
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
`,
})
expect(collectPackageInvariantViolations(unused).map(violation => violation.message))
.toContain('install function must use its bound failure reporter')
})
it('rejects registering a different installer than the checked local function', () => {
const decoy = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') }
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register('@deepseek-ai/dsh-probe', () => {})
`,
})
expect(collectPackageInvariantViolations(decoy).map(violation => violation.message))
.toContain('line 6: ctx.invariants.register must use the checked local install function')
})
it.each([
'export default { name, inject, apply }',
"export * as default from './probe.ts'",
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
.toContain('must not default-export; Loader must retain the companion namespace')
})
it('accepts explained empty installers and rejects unexplained ones', () => {
const explained = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
/** No runtime invariant: this pure package owns no events or mutable data. */
const install = () => {}
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register(PACKAGE_NAME, install)
`
expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([])
const unexplained = `
export const name = 'probe-invariant'
export const inject = ['invariants']
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
const install = () => {}
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register(PACKAGE_NAME, install)
`
expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message))
.toContain('empty install function must explain why with a "No runtime invariant:" comment')
})
})

View File

@@ -0,0 +1,340 @@
/**
* Package-invariant companion discovery and structural checks.
* The runtime registry stays product-independent; this gate makes ownership
* exhaustive across packages without centralizing package checks.
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
/** Required explanation marker for an intentionally empty installer. */
const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
interface PackageManifest {
name?: string
exports?: Record<string, { types?: string; default?: string } | string | undefined>
files?: string[]
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
/** One package and the files participating in its invariant publication contract. */
export interface PackageInvariantOwner {
readonly dir: string
readonly manifestPath: string
readonly sourcePath: string
readonly packageName: string
}
/** One gate violation with a repo-relative owner path. */
export interface PackageInvariantViolation {
readonly path: string
readonly message: string
}
/** Discover every package under the repository package tree. */
export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
return globSync('packages/*/*/package.json', { cwd: root })
.map(path => path.split(sep).join('/'))
.sort()
.map((manifestPath) => {
const manifest = readManifest(resolve(root, manifestPath))
if (manifest.name === undefined || manifest.name === '') {
throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
}
const dir = dirname(manifestPath)
return {
dir,
manifestPath,
sourcePath: `${dir}/src/invariant.ts`,
packageName: manifest.name,
}
})
}
/** Return all violations of the package-invariant companion contract. */
export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
const violations: PackageInvariantViolation[] = []
for (const owner of packageInvariantOwners(root)) {
const manifest = readManifest(resolve(root, owner.manifestPath))
checkManifest(owner, manifest, violations)
checkBuild(owner, root, violations)
checkSource(owner, root, violations)
}
return violations
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
}
function addViolation(
violations: PackageInvariantViolation[],
path: string,
message: string,
): void {
violations.push({ path, message })
}
function checkManifest(
owner: PackageInvariantOwner,
manifest: PackageManifest,
violations: PackageInvariantViolation[],
): void {
const invariantExport = manifest.exports?.['./invariant']
if (typeof invariantExport !== 'object'
|| invariantExport.types !== './lib/types/invariant.d.ts'
|| invariantExport.default !== './lib/invariant.js') {
addViolation(
violations,
owner.manifestPath,
'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
)
}
if (!manifest.files?.includes('lib/invariant.js')) {
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
}
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
addViolation(
violations,
owner.manifestPath,
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
)
}
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
addViolation(
violations,
owner.manifestPath,
'@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
)
}
}
function checkBuild(
owner: PackageInvariantOwner,
root: string,
violations: PackageInvariantViolation[],
): void {
const tsconfigPath = `${owner.dir}/tsconfig.json`
const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
references?: Array<{ path?: string }>
}
if (owner.packageName !== '@deepseek-ai/dsh-invariants'
&& !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
addViolation(
violations,
tsconfigPath,
'TypeScript project references must include ../../support/invariants',
)
}
const configPath = `${owner.dir}/tsdown.config.ts`
if (!existsSync(resolve(root, configPath))) return
const source = readFileSync(resolve(root, configPath), 'utf8')
if (!source.includes('lib/types/invariant.js')) {
addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
}
}
function checkSource(
owner: PackageInvariantOwner,
root: string,
violations: PackageInvariantViolation[],
): void {
const absolutePath = resolve(root, owner.sourcePath)
if (!existsSync(absolutePath)) {
addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion')
return
}
const sourceText = readFileSync(absolutePath, 'utf8')
if (sourceText.includes('@generated')) {
addViolation(
violations,
owner.sourcePath,
'invariant companions must be hand-owned and may not carry @generated markers',
)
}
const sourceFile = ts.createSourceFile(
absolutePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
)
const constants = topLevelStringConstants(sourceFile)
const registrations: string[] = []
const unresolved: number[] = []
const mismatchedInstallers: number[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
const argument = node.arguments[0]
const packageName = argument === undefined ? undefined : stringValue(argument, constants)
if (packageName === undefined) unresolved.push(line)
else registrations.push(packageName)
const installer = node.arguments[1]
if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
mismatchedInstallers.push(line)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
for (const line of unresolved) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
)
}
for (const line of mismatchedInstallers) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register must use the checked local install function`,
)
}
if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
addViolation(
violations,
owner.sourcePath,
`must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
)
}
for (const exportedName of ['name', 'inject', 'apply']) {
if (!hasNamedExport(sourceFile, exportedName)) {
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
}
}
if (hasDefaultExport(sourceFile)) {
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
}
checkInstaller(owner, sourceFile, sourceText, violations)
}
function checkInstaller(
owner: PackageInvariantOwner,
sourceFile: ts.SourceFile,
sourceText: string,
violations: PackageInvariantViolation[],
): void {
let initializer: ts.Expression | undefined
let declarationStatement: ts.VariableStatement | undefined
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)
&& declaration.name.text === 'install'
&& declaration.initializer !== undefined) {
initializer = declaration.initializer
declarationStatement = statement
}
}
}
const installer = initializer === undefined ? undefined : installerFunction(initializer)
if (installer === undefined) {
addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
return
}
if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
const declarationText = declarationStatement === undefined
? ''
: sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd())
if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) {
addViolation(
violations,
owner.sourcePath,
`empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`,
)
}
return
}
const reporter = installer.parameters[1]?.name
if (reporter === undefined || !ts.isIdentifier(reporter)) {
addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
return
}
if (!usesIdentifier(installer.body, reporter.text)) {
addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
}
}
function usesIdentifier(node: ts.Node, name: string): boolean {
return ts.isIdentifier(node) && node.text === name
|| node.getChildren().some(child => usesIdentifier(child, name))
}
function installerFunction(
initializer: ts.Expression,
): ts.ArrowFunction | ts.FunctionExpression | undefined {
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
if (ts.isCallExpression(initializer)
&& ts.isPropertyAccessExpression(initializer.expression)
&& ts.isIdentifier(initializer.expression.expression)
&& initializer.expression.expression.text === 'Object'
&& initializer.expression.name.text === 'assign') {
const target = initializer.arguments[0]
if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
}
return undefined
}
function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
const constants = new Map<string, string>()
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
const value = stringValue(declaration.initializer, constants)
if (value !== undefined) constants.set(declaration.name.text, value)
}
}
return constants
}
function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
if (ts.isIdentifier(node)) return constants.get(node.text)
return undefined
}
function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
return ts.isPropertyAccessExpression(expression)
&& expression.name.text === 'register'
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === 'invariants'
}
function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
return sourceFile.statements.some((statement) => {
if (!ts.isVariableStatement(statement)
|| !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
})
}
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
return sourceFile.statements.some((statement) => {
if (ts.isExportAssignment(statement)) return true
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
if (ts.isNamespaceExport(statement.exportClause)) {
return statement.exportClause.name.text === 'default'
}
return statement.exportClause.elements.some(element => element.name.text === 'default')
})
}
/** Format violations for the command-line gate. */
export function formatPackageInvariantViolation(
root: string,
violation: PackageInvariantViolation,
): string {
const path = resolve(root, violation.path)
return `${relative(root, path)}: ${violation.message}`
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Ubuntu's package transaction scans the hosted image's full dpkg database and
# runs post-install hooks. CI needs only the signed-archive payload, so pin and
# verify that payload before extracting it into the ephemeral runner directory.
readonly BUBBLEWRAP_VERSION='0.9.0-1ubuntu0.1'
readonly BUBBLEWRAP_SHA256='1b506492bd9c7fd0cdb4f02ac822f1d3e336b0aead5113c1239baf8db5db562a'
readonly BUBBLEWRAP_URL="https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
: "${RUNNER_TEMP:?prepare-ci-bubblewrap requires RUNNER_TEMP}"
: "${GITHUB_PATH:?prepare-ci-bubblewrap requires GITHUB_PATH}"
if [[ "$(uname -s)" != 'Linux' || "$(uname -m)" != 'x86_64' ]]; then
echo 'prepare-ci-bubblewrap supports only Linux x86_64 hosted runners' >&2
exit 1
fi
archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
root="${RUNNER_TEMP}/dsh-bubblewrap"
curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL"
printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status
mkdir -p "$root"
dpkg-deb --extract "$archive" "$root"
printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH"
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|| echo 'apparmor userns knob absent — the functional probe decides'
"$root/usr/bin/bwrap" --version
"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true
echo 'bubblewrap functional probe passed'

View File

@@ -0,0 +1,248 @@
/** Tests for the documentation website projection adapter. */
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { docsPages, type DocsPage } from '../website/docs.ts'
import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
const roots: string[] = []
const repositoryRoot = resolve(import.meta.dirname, '..')
function unexpectedWebsiteMarkdown(files: readonly string[]): string[] {
return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort()
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(): { root: string; pages: DocsPage[] } {
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
roots.push(root)
mkdirSync(join(root, 'docs'), { recursive: true })
mkdirSync(join(root, 'packages'), { recursive: true })
writeFileSync(join(root, 'docs/a.md'), '# A\n')
writeFileSync(join(root, 'docs/b.md'), '# B\n')
writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
return {
root,
pages: [
{ locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
{ locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
{ locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
{ locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
],
}
}
describe('website source layout', () => {
it('rejects Markdown outside the subtree instructions', () => {
expect(unexpectedWebsiteMarkdown([
'website/AGENTS.md',
'website/docs.ts',
'website/zh-CN/api/harness/service.md',
])).toEqual(['website/zh-CN/api/harness/service.md'])
})
it('contains no tracked or unignored documentation copies', () => {
const files = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'],
{ cwd: repositoryRoot, encoding: 'utf8' },
).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file)))
expect(
unexpectedWebsiteMarkdown(files),
'Keep canonical Markdown under docs/ and publish it through website/docs.ts.',
).toEqual([])
})
})
describe('rewriteMarkdown', () => {
it('maps published pages and pins unpublished source links', () => {
const { root, pages } = fixture()
const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(
'[B](./reference/b.md#part) '
+ '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
+ '[web](https://example.com)\n',
)
})
it('selects the published target in the current site locale', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('[B](b.md)\n', {
locale: 'root',
sourcePath: 'docs/a.md',
route: 'a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('[B](./reference-root/b.md)\n')
})
it('uses raw GitHub content for unpublished images', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n')
})
it('does not rewrite Markdown-looking text inside code fences', () => {
const { root, pages } = fixture()
const source = '```md\n[B](b.md)\n```\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(source)
})
it('replaces the destination token without changing repeated titles or escapes', () => {
const { root, pages } = fixture()
const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(
'[title](./reference/b.md "b.md") '
+ '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
)
})
it('routes a pair switcher across locales while ordinary links stay in locale', () => {
const { root, pages } = fixture()
writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
const paired = pages.filter(page => page.source !== 'docs/a.md')
paired.push(
{
locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
},
{
locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
},
)
expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', {
locale: 'root',
sourcePath: 'docs/a.zh.md',
route: 'guide/a.md',
pages: paired,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
})
it('fails loud when a relative target is missing', () => {
const { root, pages } = fixture()
expect(() => rewriteMarkdown('[missing](missing.md)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toThrow('links to missing path "missing.md"')
})
})
describe('docsPages locale routes', () => {
it('publishes every route in both locales and selects paired user sources', () => {
const byRoute = new Map(docsPages.map(page => [page.route, page]))
for (const page of docsPages.filter(page => page.locale === 'root')) {
const counterpart = byRoute.get(`en/${page.route}`)
expect(counterpart, page.route).toBeDefined()
expect(counterpart?.locale).toBe('en')
if (page.source.startsWith('docs/user/')) {
expect(page.source).toMatch(/\.zh\.md$/)
expect(page.contentLocale).toBe('zh-CN')
expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
expect(counterpart?.contentLocale).toBe('en-US')
} else {
expect(counterpart?.source).toBe(page.source)
expect(counterpart?.contentLocale).toBe(page.contentLocale)
}
}
})
it('publishes the Cordis core API under matching locale structures', () => {
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.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?.section).toBe('Cordis API')
expect(english?.source).toBe(root?.source)
expect(english?.section).toBe('Cordis Core API')
}
})
})
describe('addProjectionFrontmatter', () => {
it('adds frontmatter to an ordinary Markdown page', () => {
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
)
})
it('extends existing VitePress frontmatter', () => {
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
)
})
})
describe('projectedPageContent', () => {
const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
locale: 'root',
contentLocale: 'zh-CN',
source: 'docs/index.zh.md',
route: 'index.md',
label: 'Home',
sidebar,
section: 'Home',
order: 0,
})
it('omits the source-only body from locale home pages', () => {
expect(projectedPageContent(
'---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n',
page(null),
)).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n')
})
it('keeps the full body for ordinary pages', () => {
const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
})
it('rejects a locale home source without frontmatter', () => {
expect(() => projectedPageContent('# Harness\n', page(null)))
.toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
})
})

322
scripts/project-doc-site.ts Normal file
View File

@@ -0,0 +1,322 @@
/**
* Build-time projection from canonical repository Markdown into VitePress.
*
* The generated tree is disposable: sources stay in their owning `docs/`
* tier, while this adapter rewrites cross-source links for the public site.
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, extname, posix, relative, resolve, sep } 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'
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
const root = resolve(import.meta.dirname, '..')
const generatedRoot = resolve(root, 'website/.generated')
interface Replacement {
start: number
end: number
value: string
}
interface DestinationRange {
start: number
end: number
}
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
/** Inputs for rewriting one canonical Markdown page. */
export interface RewriteMarkdownOptions {
locale: DocsLocale
sourcePath: string
route: string
pages: DocsPage[]
repoRoot: string
repositoryRef: string
}
function repoPath(absPath: string, repoRoot: string): string {
return relative(repoRoot, absPath).split(sep).join('/')
}
function isExternalOrSiteAbsolute(url: string): boolean {
return url.startsWith('#')
|| url.startsWith('//')
|| url.startsWith('/')
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
}
function skipWhitespace(source: string, start: number): number {
let index = start
while (/\s/.test(source[index] ?? '')) index += 1
return index
}
function labelEnd(source: string): number {
const first = source.indexOf('[')
if (first === -1) return -1
let depth = 0
for (let index = first; index < source.length; index += 1) {
const char = source[index]
if (char === '\\') {
index += 1
} else if (char === '[') {
depth += 1
} else if (char === ']') {
depth -= 1
if (depth === 0) return index
}
}
return -1
}
function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
const endOfLabel = labelEnd(rawNode)
if (endOfLabel === -1) {
throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
}
let start: number
if (type === 'definition') {
const colon = rawNode.indexOf(':', endOfLabel + 1)
if (colon === -1) {
throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, colon + 1)
} else {
if (rawNode[endOfLabel + 1] !== '(') {
throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, endOfLabel + 2)
}
if (rawNode[start] === '<') {
for (let index = start + 1; index < rawNode.length; index += 1) {
if (rawNode[index] === '\\') index += 1
else if (rawNode[index] === '>') return { start: start + 1, end: index }
}
throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
}
let depth = 0
for (let index = start; index < rawNode.length; index += 1) {
const char = rawNode[index]
if (char === '\\') {
index += 1
} else if (char === '(') {
depth += 1
} else if (char === ')') {
if (depth === 0) return { start, end: index }
depth -= 1
} else if (/\s/.test(char ?? '') && depth === 0) {
return { start, end: index }
}
}
return { start, end: rawNode.length }
}
function splitTarget(url: string): { path: string; suffix: string } {
const boundary = url.search(/[?#]/)
if (boundary === -1) return { path: url, suffix: '' }
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
}
function decodePath(path: string): string {
try {
return decodeURIComponent(path)
} catch {
throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
}
}
function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
const target = posix.relative(posix.dirname(fromRoute), toRoute)
return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
}
function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
const map = new Map<string, Map<DocsLocale, DocsPage>>()
for (const page of pages) {
for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
if (localized.has(page.locale)) {
throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
}
localized.set(page.locale, page)
map.set(source, localized)
}
}
return map
}
function counterpartSource(source: string): string {
return source.endsWith('.zh.md')
? source.replace(/\.zh\.md$/, '.md')
: source.replace(/\.md$/, '.zh.md')
}
function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
const decoded = decodePath(rawPath)
let absPath = resolve(dirname(sourceAbs), decoded)
if (existsSync(absPath)) return { absPath }
const lineMatch = decoded.match(/:(\d+)$/)
if (lineMatch !== null) {
const lineText = lineMatch[1]
if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
}
if (extname(decoded) === '') {
const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
if (existsSync(markdown)) return { absPath: markdown }
const index = resolve(dirname(sourceAbs), decoded, 'index.md')
if (existsSync(index)) return { absPath: index }
}
throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
}
function githubTarget(
absPath: string,
line: number | undefined,
suffix: string,
repositoryRef: string,
repoRoot: string,
image: boolean,
): string {
const path = repoPath(absPath, repoRoot)
if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
const lineSuffix = line === undefined ? suffix : `#L${line}`
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
}
/**
* Rewrite repository-relative links without reserializing Markdown.
*
* @param source Markdown text from the canonical file.
* @param options Source, route, manifest, and repository context.
* @returns Markdown whose published links resolve inside the site or to GitHub.
*/
export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
const sourceAbs = resolve(options.repoRoot, options.sourcePath)
const published = sourceMap(options.pages)
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
const replacements: Replacement[] = []
const rewrite = (node: RewritableNode): void => {
if (isExternalOrSiteAbsolute(node.url)) return
const { path, suffix } = splitTarget(node.url)
if (path === '') return
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
const targetPath = repoPath(absPath, options.repoRoot)
const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
const targetLocale: DocsLocale = isLanguageSwitcher
? options.locale === 'root' ? 'en' : 'root'
: options.locale
const page = published.get(targetPath)?.get(targetLocale)
const nextUrl = page === undefined
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
: routeTarget(options.route, page.route, suffix)
const start = node.position?.start.offset
const end = node.position?.end.offset
if (start === undefined || end === undefined) {
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
}
const rawNode = source.slice(start, end)
const rawDestination = destinationRange(rawNode, node.type)
replacements.push({
start: start + rawDestination.start,
end: start + rawDestination.end,
value: nextUrl,
})
}
const visit = (node: Nodes): void => {
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
if ('children' in node) {
for (const child of node.children) visit(child)
}
}
visit(tree)
let projected = source
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
}
return projected
}
/**
* Record the canonical edit target in VitePress frontmatter.
*
* @param markdown Projected Markdown content.
* @param sourcePath Repository-relative canonical source path.
* @returns Markdown with an `editSource` frontmatter field.
*/
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
const field = `editSource: ${JSON.stringify(sourcePath)}`
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
return `---\n${field}\n---\n\n${markdown}`
}
/**
* Select the Markdown rendered for one published page.
*
* @param markdown Rewritten canonical Markdown content.
* @param page Publication manifest entry for the content.
* @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
*/
export function projectedPageContent(markdown: string, page: DocsPage): string {
if (page.sidebar !== null) return markdown
if (!markdown.startsWith('---\n')) {
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
}
const closingDelimiter = '\n---\n'
const closing = markdown.indexOf(closingDelimiter, 4)
if (closing === -1) {
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
}
return markdown.slice(0, closing + closingDelimiter.length)
}
/** Canonical Markdown files watched by the local VitePress dev server. */
export function docsSourceFiles(): string[] {
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
}
/** Rebuild the disposable VitePress source tree from the publication manifest. */
export function projectDocs(): void {
const routes = new Set<string>()
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
rmSync(generatedRoot, { recursive: true, force: true })
for (const page of docsPages) {
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
routes.add(page.route)
const sourceAbs = resolve(root, page.source)
if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
}
const output = resolve(generatedRoot, page.route)
mkdirSync(dirname(output), { recursive: true })
const markdown = readFileSync(sourceAbs, 'utf8')
const projected = rewriteMarkdown(markdown, {
sourcePath: page.source,
locale: page.locale,
route: page.route,
pages: docsPages,
repoRoot: root,
repositoryRef,
})
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
}
}

View File

@@ -0,0 +1,61 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(exportPath = './lib/index.js'): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
mkdirSync(join(packageDir, 'lib'), { recursive: true })
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
name: '@deepseek-ai/dsh-probe',
version: '0.0.1',
type: 'module',
license: 'MIT',
engines: { node: '>=22.19' },
sideEffects: false,
files: ['lib'],
exports: { '.': { default: exportPath } },
}, null, 2)}\n`)
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
return root
}
function run(root: string) {
return spawnSync(process.execPath, [
'--import', 'tsx', runner,
'--packages-root', root,
], {
cwd: repositoryRoot,
encoding: 'utf8',
timeout: 5_000,
})
}
describe('publint package runner', () => {
it('lints recursively declared files from an in-memory publication view', () => {
const result = run(fixture())
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('linting 1 package(s)')
expect(result.stdout).toContain('All good!')
})
it('rejects an export that exists in the workspace but is not published', () => {
const result = run(fixture('./unpublished.js'))
expect(result.status).toBe(1)
expect(result.stdout).toContain('unpublished.js')
})
})

View File

@@ -1,46 +1,53 @@
import { execFile } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
/** Run publint over the exact manifest-declared publication view of every package. */
import {
globSync,
readFileSync,
readdirSync,
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { promisify } from 'node:util'
import { dirname, relative, resolve, sep } from 'node:path'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
const execFileAsync = promisify(execFile)
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
// Discover harness packages at packages/<group>/<pkg>; group containers,
// examples, and private vendored sources are not package targets.
const root = resolve(import.meta.dirname, '..')
const packagesRoot = resolve(root, 'packages')
interface PackageTarget {
path: string
directory: string
manifest: PackageManifest
}
// Run publint's JS CLI through the current node, not the .bin shim: the
// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd
// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and
// breaks when the repo path contains spaces. The JS entry is identical on every
// platform (`bin` is `./src/cli.js` per publint's package.json).
const publintCli = resolve(root, 'node_modules/publint/src/cli.js')
interface PackageManifest {
name?: string
files?: unknown
}
type PublintResult =
| { path: string; status: 'passed'; stdout: string; stderr: string }
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
function workspacePackages(): string[] {
return readdirSync(packagesRoot, { withFileTypes: true })
.filter(group => group.isDirectory())
.flatMap(group =>
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
.filter(pkg => pkg.isDirectory())
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
.map(pkg => `packages/${group.name}/${pkg.name}`),
)
function workspacePackages(): PackageTarget[] {
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
.sort()
.map((manifestPath) => {
const absoluteManifestPath = resolve(packagesRoot, manifestPath)
const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest
return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest }
})
}
function publintConcurrency(total: number): number {
if (total === 0) return 0
const raw = process.env[CONCURRENCY_ENV]
if (raw !== undefined) {
if (raw !== undefined && raw !== '') {
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1) {
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return Math.min(total, parsed)
@@ -49,57 +56,106 @@ function publintConcurrency(total: number): number {
return Math.min(total, availableParallelism())
}
function outputText(value: unknown): string {
if (typeof value === 'string') return value
if (Buffer.isBuffer(value)) return value.toString()
return ''
function publicationFiles(target: PackageTarget): PackFile[] {
const paths = new Set<string>()
addPath(resolve(target.directory, 'package.json'), paths)
const declared = Array.isArray(target.manifest.files)
? target.manifest.files.filter((value): value is string => typeof value === 'string')
: []
for (const pattern of [
...declared,
'README*',
'LICENSE*',
'LICENCE*',
'CHANGELOG*',
'CHANGES*',
'HISTORY*',
'NOTICE*',
]) {
for (const match of globSync(pattern, { cwd: target.directory })) {
addPath(resolve(target.directory, match), paths)
}
}
return [...paths]
.sort()
.map(path => ({
name: `package/${relative(target.directory, path).split(sep).join('/')}`,
data: readFileSync(path),
}))
}
async function runPublint(path: string): Promise<PublintResult> {
function addPath(path: string, paths: Set<string>): void {
const stat = statSync(path)
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
} else if (stat.isFile()) {
paths.add(path)
}
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], {
cwd: root,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
const result = await publint({
pkgDir: 'package',
pack: { files: publicationFiles(target) },
})
return { path, status: 'passed', stdout, stderr }
const manifest = result.pkg as Record<string, unknown>
return result.messages.some(message => message.type === 'error')
? { path: target.path, status: 'failed', messages: result.messages, manifest }
: { path: target.path, status: 'passed', messages: result.messages, manifest }
} catch (error: unknown) {
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
return {
path,
path: target.path,
status: 'failed',
stdout: outputText(failed.stdout),
stderr: outputText(failed.stderr),
message: failed.message ?? 'publint failed',
messages: [],
manifest: target.manifest as Record<string, unknown>,
failure: error instanceof Error ? error.message : String(error),
}
}
}
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
async function runAll(targets: PackageTarget[], concurrency: number): Promise<PublintResult[]> {
let next = 0
const results: Array<PublintResult | undefined> = []
await Promise.all(Array.from({ length: concurrency }, async () => {
for (;;) {
const index = next
next += 1
const path = paths[index]
if (path === undefined) return
results[index] = await runPublint(path)
const target = targets[index]
if (target === undefined) return
results[index] = await runPublint(target)
}
}))
return paths.map((path, index) => {
return targets.map((target, index) => {
const result = results[index]
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
if (result === undefined) throw new Error(`publint-all: missing result for ${target.path}.`)
return result
})
}
function printResult(result: PublintResult): void {
console.log(`Running publint for ${result.path}...`)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status === 'failed') console.error(result.message)
if ('failure' in result) console.error(result.failure)
for (const message of result.messages) {
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
}
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
}
function parseOptions(args: string[]): Map<string, string> {
const parsed = new Map<string, string>()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
const packages = workspacePackages()

View File

@@ -1,130 +0,0 @@
/**
* Shared source of truth for the RFC index: the tree walker (structure rules) and the README
* table renderer. `gen-rfc-index.ts` writes the generated regions;
* `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh.
* Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive
* from path, H1, and filename date and sort deterministically. Import is pure.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { globSync } from 'node:fs'
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** Title-case a class/lifecycle folder name for a README heading. */
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
/** One RFC file, as discovered by the walker. */
export interface Rfc {
lifecycle: string
cls: string
base: string
/** Path relative to docs/rfc — the README link target. */
rel: string
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
title: string
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
date: string
}
/**
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
* plus one error string per violation (unknown lifecycle or class folder, bad
* depth, bad filename, missing/malformed H1). Callers treat a non-empty error
* list as fatal — the index is only generated from a structurally valid tree.
*/
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
const rfcs: Rfc[] = []
const errors: string[] = []
// The lifecycle set is closed too: any directory under docs/rfc/ that is not
// a known lifecycle would otherwise hold RFCs invisible to the walk below.
for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
}
}
for (const lifecycle of LIFECYCLES) {
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
if (!h1?.[1]) {
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
continue
}
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
}
}
return { rfcs, errors }
}
/**
* Render one lifecycle's section body: a `### {Class}` heading plus a
* `| Title | First proposed |` table for every non-empty class, in CLASSES
* order, rows sorted by date then filename.
*/
function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
const sections: string[] = []
for (const cls of CLASSES) {
const rows = rfcs
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
if (rows.length === 0) continue
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
}
return sections.join('\n\n')
}
/**
* Render the complete `docs/rfc/INDEX.md` content: a generated-file banner
* followed by one `## {Lifecycle}` section per lifecycle in canonical order.
* The whole file is generated state — there is no curated region to preserve.
*/
export function renderIndex(rfcs: Rfc[]): string {
const parts = [
'# RFC index',
'',
'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).',
]
for (const lifecycle of LIFECYCLES) {
parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle))
}
return `${parts.join('\n')}\n`
}
/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */
export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//

View File

@@ -5,9 +5,8 @@
* independent commands can overlap and which commands wait for built artifacts.
*/
import { spawn } from 'node:child_process'
import { readdir, rm } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { join, resolve } from 'node:path'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
type Mode =
@@ -17,19 +16,26 @@ type Mode =
| 'ci-coverage'
| 'ci-snapshot'
| 'ci-artifacts'
| 'ci-windows-blocking'
| 'ci-windows-complete'
| 'ci-windows-observational'
| 'node-compat'
| 'pre-push'
| 'check-all'
| 'doc-sync'
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
interface Gate {
id: string
label: string
displayCommand: string
command: string
args: string[]
needs?: string[]
env?: Record<string, string | undefined>
input?: string
verify?: (result: GateResult) => Promise<void>
allowFailure?: boolean
}
interface GateResult {
@@ -38,27 +44,46 @@ interface GateResult {
durationMs: number
stdout: string
stderr: string
output: GateOutputChunk[]
exitCode: number | null
error?: string
}
interface GateOutputChunk {
stream: 'stdout' | 'stderr'
text: string
}
interface RunningGate {
gate: Gate
promise: Promise<GateResult>
}
interface ConcurrencyDefault {
workers: number
source: string
}
const root = resolve(import.meta.dirname, '..')
const mode = parseMode(process.argv[2])
const gates = gatesForMode(mode)
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
const concurrencyDefault = defaultConcurrency(mode, gates.length)
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers)
const verbose = process.env.DSH_GATE_VERBOSE === '1'
const startedAt = performance.now()
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
? concurrencyDefault.source
: '$DSH_GATE_CONCURRENCY'
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
const results = await runGates(gates, maxConcurrency)
printSummary(results, performance.now() - startedAt)
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) {
process.exit(1)
}
function parseMode(raw: string | undefined): Mode {
switch (raw) {
@@ -68,18 +93,33 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-coverage':
case 'ci-snapshot':
case 'ci-artifacts':
case 'ci-windows-blocking':
case 'ci-windows-complete':
case 'ci-windows-observational':
case 'node-compat':
case 'pre-push':
case 'check-all':
case 'doc-sync':
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
)
}
}
function defaultConcurrency(total: number): number {
return Math.min(total, Math.max(4, availableParallelism()))
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
const available = availableParallelism()
// Local modes cap workers: several doc gates each build a full ts.Program,
// so an uncapped default on a large host trades wall clock for memory blowups.
const localCap = selectedMode === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync'
const modeLimit = localCap ? Math.min(4, available) : available
return {
workers: Math.min(total, modeLimit),
source: localCap
? `${available} available CPU(s), ${selectedMode} cap 4`
: `${available} available CPU(s)`,
}
}
function concurrencyFromEnv(name: string, fallback: number): number {
@@ -96,6 +136,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
return {
id,
label: options.label ?? script,
displayCommand: `pnpm run ${script}`,
...pnpmInvocation(['run', script]),
...options,
}
@@ -105,6 +146,7 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
return {
id,
label: options.label ?? `pnpm exec ${args.join(' ')}`,
displayCommand: `pnpm exec ${args.join(' ')}`,
...pnpmInvocation(['exec', ...args]),
...options,
}
@@ -135,36 +177,39 @@ function gatesForMode(selected: Mode): Gate[] {
pnpmScript('duplication', 'duplication'),
]
case 'ci-coverage':
return [
coverageGate(),
]
return [coverageGate()]
case 'ci-snapshot':
return [
pnpmScript('snapshot', 'test:snapshot'),
]
return [pnpmScript('build', 'build'), snapshotGate()]
case 'ci-artifacts':
return ciArtifactGates()
case 'ci-windows-blocking':
return ciWindowsBlockingGates()
case 'ci-windows-complete':
return ciWindowsCompleteGates()
case 'ci-windows-observational':
return ciWindowsObservationalGates()
case 'node-compat':
return [
pnpmScript('typecheck', 'typecheck'),
pnpmExec('source-worker-smoke', [
'vitest',
'run',
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
], { label: 'source worker smoke' }),
]
case 'pre-push':
return nodeCompatGates()
case 'pre-push': return []
case 'check-all':
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
pnpmScript('test', 'test'),
pnpmScript('duplication', 'duplication'),
pnpmScript('snapshot', 'test:snapshot'),
snapshotGate(),
pnpmScript('build', 'build'),
pnpmScript('build:web', 'build:web'),
...hygieneLeafGates({ artifactNeeds: ['build'] }),
...docSyncLeafGates(),
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
]
case 'doc-sync':
return docSyncLeafGates()
}
}
@@ -172,43 +217,67 @@ function ciPrimaryGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('typecheck', 'typecheck'),
lintGate(),
pnpmScript('duplication', 'duplication'),
coverageGate(),
pnpmScript('snapshot', 'test:snapshot'),
demoSmokeGate({ needs: ['lint'] }),
...nodeCompatSmokeGates(),
snapshotGate(),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
pnpmScript('build', 'build', { needs: ['typecheck'] }),
pnpmScript('build', 'build'),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
function nodeCompatGates(): Gate[] {
return [
...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')],
...nodeCompatSmokeGates(),
]
}
function nodeCompatSmokeGates(): Gate[] {
return [
pnpmExec('source-worker-smoke', [
'vitest',
'run',
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
], { label: 'source worker smoke' }),
pnpmExec('jsonl-zstd-smoke', [
'vitest',
'run',
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
], { label: 'JSONL Zstandard smoke' }),
]
}
function ciStaticGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
...staticDemoSmokeGates(),
...docSyncLeafGates(),
pnpmScript('build', 'build'),
...docSyncLeafGates({
docTypecheckNeeds: ['build'],
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
docsBuildScript: 'docs:build:mpa',
}),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
pnpmScript('knip', 'knip'),
]
}
function staticDemoSmokeGates(): Gate[] {
// Native Windows session persistence is outside the gates-only support scope.
return process.platform === 'win32' ? [] : [demoSmokeGate()]
}
function ciArtifactGates(): Gate[] {
return [
pnpmScript('build', 'build'),
@@ -217,15 +286,59 @@ function ciArtifactGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
function lintGate(): Gate {
function ciWindowsBlockingGates(): Gate[] {
return [
pnpmScript('windows-build', 'build', { label: 'build' }),
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
]
}
function ciWindowsCompleteGates(): Gate[] {
const observational = ciWindowsObservationalGates()
// The required production site replaces the observational MPA build; both
// VitePress modes write the same output directory and cannot overlap.
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
.map(gate => ({ ...gate, allowFailure: true }))
return [
pnpmScript('build', 'build'),
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
...observational,
]
}
function ciWindowsObservationalGates(): Gate[] {
return [
...ciStaticGates(),
lintGate(),
pnpmScript('duplication', 'duplication'),
{
...coverageGate(),
env: { DSH_EXAMPLE_MODE: 'lib' },
needs: ['build'],
},
snapshotGate(),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
function lintGate(eslintTargets: readonly string[] = ['.']): Gate {
const concurrencyArgs = eslintConcurrencyArgs()
if (process.env.DSH_ESLINT_CACHE === '1') {
return pnpmExec('lint', [
'eslint',
'.',
...eslintTargets,
...concurrencyArgs,
'--cache',
'--cache-location',
'.cache/eslint/',
@@ -236,11 +349,28 @@ function lintGate(): Gate {
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
if (concurrencyArgs.length > 0) {
return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], {
label: 'lint',
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
return pnpmScript('lint', 'lint', {
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
})
}
function eslintConcurrencyArgs(): string[] {
const raw = process.env.DSH_ESLINT_CONCURRENCY
if (raw === undefined || raw === '') return []
if (raw === 'auto') return ['--concurrency=auto']
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`)
}
return [`--concurrency=${raw}`]
}
function coverageGate(): Gate {
return pnpmExec('coverage', [
'vitest',
@@ -252,6 +382,23 @@ function coverageGate(): Gate {
})
}
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
// plugins via real exports) — CI and check-all already build, so they exercise what ships rather
// than the tsx/source path dev uses. It therefore waits on `build`.
function snapshotGate(): Gate {
return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' },
needs: ['build'],
})
}
function builtPackageInvariantsGate(needs?: string[]): Gate {
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
label: 'built package invariants',
...needs === undefined ? {} : { needs },
})
}
function positiveIntArg(envName: string, flag: string): string[] {
const raw = process.env[envName]
if (raw === undefined || raw === '') return []
@@ -262,12 +409,21 @@ function positiveIntArg(envName: string, flag: string): string[] {
return [`${flag}=${raw}`]
}
function flagEnabled(envName: string): boolean {
const raw = process.env[envName]
if (raw === undefined || raw === '') return false
if (raw !== '1') throw new Error(`run-gates: ${envName} must be 1 when set, got ${JSON.stringify(raw)}.`)
return true
}
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
return [
pnpmScript('knip', 'knip'),
pnpmScript('publint', 'publint', artifactOptions),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
builtPackageInvariantsGate(options.artifactNeeds),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,
@@ -275,10 +431,18 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
]
}
function docSyncLeafGates(): Gate[] {
function docSyncLeafGates(options: {
docTypecheckNeeds?: string[]
docTypecheckEnv?: Record<string, string | undefined>
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
} = {}): Gate[] {
const docTypecheckOptions: Partial<Gate> = {}
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
return [
pnpmScript('doc-typecheck', 'doc-typecheck'),
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
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' }),
@@ -291,60 +455,32 @@ function docSyncLeafGates(): Gate[] {
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
pnpmScript('mermaid', 'verify-mermaid'),
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
label: 'documentation projection',
}),
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
]
}
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
return {
id: 'demo-smoke',
label: 'demo smoke',
...pnpmInvocation(['run', 'demo:echo']),
input: 'echo ci smoke\n',
...dependencyOptions,
verify: async (result) => {
const output = result.stdout + result.stderr
const sessionsRoot = join(root, '.sessions')
try {
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
throw new Error('demo smoke did not show the echo tool call.')
}
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
throw new Error('demo smoke did not show the echo tool result.')
}
const buckets = await readdir(sessionsRoot, { withFileTypes: true })
let found = false
for (const bucket of buckets) {
if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue
const entries = await readdir(join(sessionsRoot, bucket.name))
if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
found = true
break
}
}
if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.')
} finally {
await rm(sessionsRoot, { recursive: true, force: true })
}
},
}
}
function builtBinSmokeGate(): Gate {
return pnpmExec('built-bin-smoke', [
'vitest',
'run',
'--config',
'vitest.e2e.config.ts',
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts',
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
// The worker-entry packages' built bundles: the only automated proof
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
// (the e2e lane runs unbuilt, so these files self-skip there).
@@ -353,6 +489,7 @@ function builtBinSmokeGate(): Gate {
], {
label: 'built-bin smoke',
needs: ['build'],
env: { DSH_EXAMPLE_MODE: 'lib' },
})
}
@@ -382,6 +519,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult
durationMs: 0,
stdout: '',
stderr: '',
output: [],
exitCode: null,
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
}
@@ -416,8 +554,10 @@ async function runGate(gate: Gate): Promise<GateResult> {
const started = performance.now()
let stdout = ''
let stderr = ''
const output: GateOutputChunk[] = []
let spawnError: string | undefined
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
const exitCode = await new Promise<number | null>((resolveExit) => {
const child = spawn(gate.command, gate.args, {
cwd: root,
env: { ...process.env, ...gate.env },
@@ -425,19 +565,28 @@ async function runGate(gate: Gate): Promise<GateResult> {
})
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.on('data', (chunk: string) => { stderr += chunk })
child.on('error', reject)
child.stdout.on('data', (chunk: string) => {
stdout += chunk
output.push({ stream: 'stdout', text: chunk })
})
child.stderr.on('data', (chunk: string) => {
stderr += chunk
output.push({ stream: 'stderr', text: chunk })
})
child.on('error', (error) => {
spawnError = `failed to start command: ${error.message}`
resolveExit(null)
})
child.on('close', resolveExit)
if (gate.input !== undefined) child.stdin.end(gate.input)
else child.stdin.end()
})
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
let error: string | undefined
let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed'
let error = spawnError
if (status === 'passed' && gate.verify !== undefined) {
try {
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode })
} catch (verifyError: unknown) {
status = 'failed'
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
@@ -450,6 +599,7 @@ async function runGate(gate: Gate): Promise<GateResult> {
durationMs: performance.now() - started,
stdout,
stderr,
output,
exitCode,
}
if (error !== undefined) result.error = error
@@ -458,9 +608,16 @@ async function runGate(gate: Gate): Promise<GateResult> {
function printResult(result: GateResult): void {
const seconds = (result.durationMs / 1000).toFixed(2)
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status === 'passed' && !verbose) {
console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`)
return
}
const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)`
const writeHeading = result.status === 'passed' ? console.log : console.error
writeHeading(`\n== ${heading} ==`)
if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`)
printOutput(result.output)
if (result.error !== undefined) console.error(result.error)
}
@@ -470,4 +627,23 @@ function printSummary(results: GateResult[], durationMs: number): void {
const skipped = results.filter(result => result.status === 'skipped').length
const seconds = (durationMs / 1000).toFixed(2)
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped')
if (unsuccessful.length === 0) return
console.error('run-gates: unsuccessful gates:')
for (const result of unsuccessful) {
const duration = (result.durationMs / 1000).toFixed(2)
const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`)
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
console.error(` ${result.gate.displayCommand}`)
}
}
function printOutput(output: GateOutputChunk[]): void {
for (const chunk of output) {
if (chunk.stream === 'stdout') process.stdout.write(chunk.text)
else process.stderr.write(chunk.text)
}
}

View File

@@ -57,12 +57,14 @@ CUSTOM_CORDIS = """\
- id: agent-core
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
workspaceContext: false
tools:
mode: both
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT
compression: 'none'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -379,6 +381,7 @@ def smoke_sdk_default(base_url: str) -> None:
root = Path(temporary).resolve()
sessions = root / "sessions"
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -389,7 +392,7 @@ def smoke_sdk_default(base_url: str) -> None:
result = harness.run("reply with the smoke text", session_id="default-smoke")
assert result.status == "ok", result
assert result.final_response == EXPECTED_TEXT, result.final_response
assert_session_log(sessions, root, EXPECTED_TEXT)
assert_zstd_session_log(sessions)
def smoke_sdk_custom(base_url: str, executable: Path) -> None:
@@ -401,6 +404,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -432,6 +436,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
cordis = root / "cordis.yml"
cordis.write_text(CUSTOM_CORDIS)
with DeepSeekHarness(
provider="deepseek",
model="smoke-model",
cwd=str(root),
session_root=str(sessions),
@@ -481,7 +486,7 @@ def smoke_direct(base_url: str, executable: Path) -> None:
}
peer = RuntimePeer([str(executable)], root, environment)
try:
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
peer.read_until(lambda message: message.get("id") == "initialize")
peer.send({
"jsonrpc": "2.0",
@@ -581,6 +586,14 @@ def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
def assert_zstd_session_log(sessions: Path) -> None:
logs = list(sessions.rglob("*.jsonl.zstd"))
if len(logs) != 1:
raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}")
if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")):
raise AssertionError(f"session log has no Zstandard magic: {logs[0]}")
def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
"""Parse every persisted JSONL session into a map keyed by header id."""
logs: dict[str, list[dict[str, object]]] = {}
@@ -624,7 +637,7 @@ def build_snapshot_files(
child_ids: list[str],
cwd: Path,
) -> dict[str, str]:
"""Render the SDK result and three persisted logs into stable goldens."""
"""Render the SDK result and three persisted logs into stable expected outputs."""
replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
for index, child_id in enumerate(child_ids, start=1):
replacements.append((child_id, f"{{{{child-{index}}}}}"))
@@ -703,7 +716,7 @@ def normalize_snapshot_value(
def scrub_snapshot_header(value: dict[object, object]) -> None:
"""Tokenize request-header bulk while retaining delta tool names."""
"""Tokenize full request-header bulk while retaining tool names."""
data = value.get("data")
if not isinstance(data, dict):
return
@@ -721,29 +734,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
]
if isinstance(header.get("messagePrefix"), list):
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
return
if value.get("type") != "request/header-delta":
return
system = data.get("system")
if isinstance(system, dict) and isinstance(system.get("insert"), list):
system["insert"] = ["{{system}}" for _ in system["insert"]]
tools = data.get("tools")
if isinstance(tools, dict):
for key in ("added", "changed"):
if isinstance(tools.get(key), list):
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
if isinstance(data.get("messagePrefix"), list):
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
def scrub_snapshot_tool_schema(value: object) -> object:
"""Keep a changed tool's name while tokenizing its schema bulk."""
if not isinstance(value, dict):
return value
return {
key: item if key == "name" else "{{tools}}"
for key, item in value.items()
}
def render_jsonl(records: list[object]) -> str:

View File

@@ -253,7 +253,7 @@
"workflow"
]
},
"reason": "fallback"
"reason": "change"
}
},
{
@@ -915,22 +915,29 @@
}
},
{
"type": "request/header-delta",
"type": "request/header",
"seq": 56,
"time": 0,
"data": {
"system": {
"keepStart": 62,
"keepEnd": 34,
"insert": []
"header": {
"config": {
"model": "smoke-model"
},
"system": "{{system}}",
"tools": [
"bash",
"bash_kill",
"bash_output",
"cordis_inspect",
"cordis_mount",
"cordis_unmount",
"run_code",
"skill",
"subagent",
"workflow"
]
},
"tools": {
"added": [],
"removed": [
"snapshot_double"
],
"changed": []
}
"reason": "change"
}
},
{
@@ -1396,7 +1403,7 @@
"workflow"
]
},
"reason": "fallback"
"reason": "change"
}
}
}
@@ -2358,22 +2365,29 @@
"payload": {
"sessionId": "{{parent}}",
"event": {
"type": "request/header-delta",
"type": "request/header",
"seq": 56,
"time": 0,
"data": {
"system": {
"keepStart": 62,
"keepEnd": 34,
"insert": []
"header": {
"config": {
"model": "smoke-model"
},
"system": "{{system}}",
"tools": [
"bash",
"bash_kill",
"bash_output",
"cordis_inspect",
"cordis_mount",
"cordis_unmount",
"run_code",
"skill",
"subagent",
"workflow"
]
},
"tools": {
"added": [],
"removed": [
"snapshot_double"
],
"changed": []
}
"reason": "change"
}
}
}

View File

@@ -13,7 +13,7 @@
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}}
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}}
@@ -55,7 +55,7 @@
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}}
{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, Service } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { packageInvariantOwners } from './package-invariants.ts'
import {
testInvariantCompanionPaths,
testInvariantCompanions,
usesManualInvariantTree,
} from './test-invariants.ts'
declare module 'cordis' {
interface Context {
testInvariantProbe: TestInvariantProbe
}
}
class TestInvariantProbe extends Service {
constructor(ctx: Context) {
super(ctx, 'testInvariantProbe')
}
}
describe('global test invariant host', () => {
it('uses one exhaustive topology to reserve every package name with enabled checks', async () => {
const ctx = new Context()
await ctx.plugin(TestInvariantProbe)
const owners = packageInvariantOwners(process.cwd())
expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length)
const unreserved: string[] = []
for (const owner of owners) {
try {
const dispose = ctx.invariants.register(owner.packageName, () => {})
unreserved.push(owner.packageName)
dispose()
} catch (error) {
expect(error).toHaveProperty(
'message',
`invariants: package "${owner.packageName}" is already registered`,
)
}
}
expect(unreserved).toEqual([])
})
it('mounts the owning package companion while leaving non-package roots service-only', () => {
expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts'))
.toEqual(['../packages/core/tools/src/invariant.ts'])
expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([])
expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts'))
.toEqual(Object.keys(testInvariantCompanions).sort())
})
it('loads and executes every source companion through the real Loader shape', async () => {
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
const registrations = new Map<string, string>()
const loader = Object.create(Loader.prototype) as Loader
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
expect(typeof installer).toBe('function')
return () => {}
})
const fakeContext = { invariants: { register } } as unknown as Context
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
const path = rawPath.replace(/^\.\.\//, '')
expect(companion.default, path).toBeUndefined()
const unwrapped = loader.unwrapExports(companion) as typeof companion
expect(unwrapped, path).toBe(companion)
expect(typeof unwrapped.name, path).toBe('string')
expect(unwrapped.inject, path).toContain('invariants')
expect(typeof unwrapped.apply, path).toBe('function')
await unwrapped.apply(fakeContext)
const call = register.mock.calls.at(-1)
if (call === undefined) throw new Error(`${path}: companion did not register`)
registrations.set(path, call[0])
}
expect(registrations).toEqual(owners)
})
it('recognizes focused invariant suites without a package inventory', () => {
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
})
})

150
scripts/test-invariants.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Vitest-wide invariant host. Ordinary Cordis roots receive the invariant
* service with global enablement plus the current test package's companion.
* One topology test mounts every companion; focused invariant tests own their
* service topology explicitly.
*/
import { expect } from 'vitest'
import { RegistryService } from 'cordis'
import type { Context, Plugin } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
declare global {
interface ImportMeta {
/** Eager Vite module-glob expansion used by the Vitest setup file. */
glob<TModule>(pattern: string, options: { eager: true }): Record<string, TModule>
}
}
/** Loader-safe shape shared by every package invariant companion. */
export interface TestInvariantCompanion {
readonly name: string
readonly inject: readonly string[]
readonly default?: unknown
apply(ctx: Context): Promise<() => void>
}
/** Every package companion, discovered eagerly so coverage observes each registration. */
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
'/packages/support/invariants/tests/service.spec.ts',
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
] as const
interface InvariantHost {
readonly fibers: readonly PluginFiber[]
readonly byCallback: ReadonlyMap<unknown, PluginFiber>
readonly ready: Promise<void>
}
type PluginFiber = ReturnType<RegistryService['plugin']>
const hosts = new WeakMap<Context, InvariantHost>()
// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly.
const originalPlugin = RegistryService.prototype.plugin
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
const testPath = expect.getState().testPath ?? ''
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
const root = this.ctx.root
const host = hosts.get(root) ?? startInvariantHost(root)
const callback = this.resolve(plugin)
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
if (existing !== undefined) {
return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing
}
const fiber = originalPlugin.call(this, plugin, config, getOuterStack)
// A root-level await is the test's composition boundary. Nested plugin
// fibers must not await their own companion parent through the global host.
if (this.ctx !== root) return fiber
return joinInvariantStartup(fiber, host.ready)
}
/**
* Detect focused suites that construct service selection or companion lifecycle explicitly.
* @param testPath - absolute or repo-relative Vitest file path.
* @returns whether the global invariant host must leave the root untouched.
*/
export function usesManualInvariantTree(testPath: string): boolean {
const normalized = testPath.replaceAll('\\', '/')
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
}
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
/**
* Select the package companions that an ordinary test root must register.
* Package tests receive their owner's checks; the dedicated topology test
* receives every owner so coverage and exhaustive runtime registration remain
* independently enforced.
* @param testPath - absolute or repo-relative normalized Vitest file path.
* @returns sorted `import.meta.glob` keys for companions to mount.
*/
export function testInvariantCompanionPaths(testPath: string): string[] {
const normalized = testPath.replaceAll('\\', '/')
const allPaths = Object.keys(testInvariantCompanions).sort()
if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths
const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//)
if (owner === null) return []
const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts`
if (testInvariantCompanions[companionPath] === undefined) {
throw new Error(`test invariants: package test has no companion at ${companionPath}`)
}
return [companionPath]
}
function startInvariantHost(root: Context): InvariantHost {
const fibers: PluginFiber[] = []
const byCallback = new Map<unknown, PluginFiber>()
const mount = (plugin: Plugin, config?: unknown): void => {
const fiber = originalPlugin.call(root.registry, plugin, config)
const callback = root.registry.resolve(plugin)
if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin')
fibers.push(fiber)
byCallback.set(callback, fiber)
}
mount(InvariantService, { enabled: true })
const testPath = expect.getState().testPath ?? ''
const companionPaths = testInvariantCompanionPaths(testPath)
for (const path of companionPaths) {
const companion = testInvariantCompanions[path]
if (companion === undefined) {
throw new Error(`test invariants: selected companion vanished at ${path}`)
}
if (!companion.inject.includes('invariants')) {
throw new Error(`test invariants: ${path} must inject the invariant service`)
}
mount(companion)
}
const [serviceFiber, ...companionFibers] = fibers
if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted')
// A companion is initially PENDING on the invariant service, and Cordis
// Fiber.await() only joins work already in flight. Wait for the service to
// activate its dependants before joining their startup and failures.
const ready = serviceFiber.await()
.then(() => Promise.all(companionFibers.map(fiber => fiber.await())))
.then(() => undefined)
const host = { fibers, byCallback, ready }
hosts.set(root, host)
return host
}
function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
const readiness = fiber.await().then(async (loaded) => {
await invariantReady
return loaded
})
const joined = Object.create(fiber) as PluginFiber
joined.then = readiness.then.bind(readiness)
return joined
}

View File

@@ -1,6 +1,14 @@
{
"requiredSince": "2026-07-14",
"required": [
".agents/notes/README.md",
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
"README.md",
"docs/architecture.md",
"docs/cookbook/adding-a-package.md",
@@ -37,15 +45,26 @@
"docs/postmortem/0001-acp-default-export-drops-inject.md",
"docs/postmortem/0002-js-expression-disabled-filesystem-tools.md",
"docs/postmortem/README.md",
"docs/rfc/README.md",
"docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
"docs/testing.md",
"docs/user/develop/basic/config.md",
"docs/user/develop/basic/index.md",
"docs/user/develop/basic/tool.md",
"docs/user/develop/framework/events.md",
"docs/user/develop/framework/index.md",
"docs/user/develop/framework/service.md",
"docs/user/develop/practice/index.md",
"docs/user/develop/practice/llm-adapter.md",
"docs/user/guide/config.md",
"docs/user/guide/index.md",
"docs/user/guide/quickstart.md",
"docs/user/index.md",
"python/README.md",
"python/sdk-runtime/README.md",
"python/sdk/README.md"
],
"excluded": [
".agents/notes/AGENTS.md",
".agents/notes/implemented/AGENTS.md",
"docs/AGENTS.md",
"docs/agent-lifecycle.md",
"docs/capability-seams.md",
@@ -58,7 +77,6 @@
"docs/i18n/translation-prompt.md",
"docs/module-graph.md",
"docs/persistence-catalog.md",
"docs/rfc/INDEX.md",
"docs/tool-catalog.md",
"docs/tool-execution-pipeline.md",
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"

View File

@@ -50,13 +50,13 @@ describe('date-based pairing frontier', () => {
const cutoff = '2026-07-14'
it('enforces the cutoff day and every later day, but not the preceding day', () => {
expect(requiresPairByDate('docs/rfc/2026-07-13-before.md', cutoff)).toBe(false)
expect(requiresPairByDate('docs/rfc/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
expect(requiresPairByDate('docs/rfc/2026-07-15-after.md', cutoff)).toBe(true)
expect(requiresPairByDate('.agents/notes/2026-07-13-before.md', cutoff)).toBe(false)
expect(requiresPairByDate('.agents/notes/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
expect(requiresPairByDate('.agents/notes/2026-07-15-after.md', cutoff)).toBe(true)
})
it('matches only a date at the start of the basename', () => {
expect(datedDocumentDate('docs/rfc/2026-07-14-proposal.md')).toBe('2026-07-14')
expect(datedDocumentDate('.agents/notes/2026-07-14-proposal.md')).toBe('2026-07-14')
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
/**
* Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules
* are shared with `agent-note-tree.ts`; the closed classification contract lives
* in `.agents/notes/README.md`.
*/
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { walkAgentNoteTree } from './agent-note-tree.ts'
const { notes, errors } = walkAgentNoteTree()
// Keep the former homes unavailable so new notes cannot silently escape this tree.
for (const legacyRoot of ['docs/rfc', 'docs/rfcs']) {
if (existsSync(resolve(import.meta.dirname, '..', legacyRoot))) {
errors.push(`legacy-path: ${legacyRoot}/ is forbidden — put Agent Notes under .agents/notes/`)
}
}
if (errors.length === 0) {
console.log(`verify-agent-note-classification: ${notes.length} Agent Note(s) checked, structure consistent.`)
process.exit(0)
}
console.error('verify-agent-note-classification: violations found:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)

View File

@@ -1,22 +1,22 @@
/**
* Enforce RFC headers, lifecycle-specific sections, alternatives, and retired
* Enforce Agent Note headers, lifecycle-specific sections, alternatives, and retired
* marker rules. Classification and filenames belong to the sibling tree gate;
* translation structure belongs to the pairing gate. Exact format and
* grandfathering rules live in `docs/rfc/README.md`.
* grandfathering rules live in `.agents/notes/README.md`.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { rfcRoot, walkRfcTree } from './rfc-index.ts'
import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts'
/** The date the format contract landed; the grandfather comment is valid only before it. */
const FORMAT_ADOPTED = '2026-07-05'
/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */
const GRANDFATHER = '<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->'
/** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */
const GRANDFATHER = '<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->'
/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format'
const LEGACY_MARKERS = ['XXX: legacy ADR/RFC body format', 'XXX: legacy ADR/Agent Note body format']
/** Status-line grammar per lifecycle folder. */
const STATUS: Record<string, RegExp> = {
@@ -35,13 +35,13 @@ const REQUIRED: Record<string, string[]> = {
/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
const { rfcs, errors } = walkRfcTree()
const { notes, errors } = walkAgentNoteTree()
for (const rfc of rfcs) {
for (const note of notes) {
const fail = (msg: string): void => {
errors.push(`format: ${rfc.rel}${msg}`)
errors.push(`format: ${note.rel}${msg}`)
}
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
const lines = readFileSync(resolve(agentNoteRoot, note.rel), 'utf8').split('\n')
// Format tokens inside fenced examples are not document structure.
let inFence = false
const prose = lines.filter((l) => {
@@ -52,11 +52,11 @@ for (const rfc of rfcs) {
return !inFence
})
if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`')
if (!/^# Agent Note: \S/.test(lines[0] ?? '')) fail('line 1 must be `# Agent Note: <title>`')
if (lines[1] !== '') fail('line 2 must be blank')
const status = STATUS[rfc.lifecycle]
const status = STATUS[note.lifecycle]
if (status !== undefined && !status.test(lines[2] ?? '')) {
fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
fail(`line 3 must match the ${note.lifecycle} status grammar (${String(status)})`)
}
if (lines[3] !== '') fail('line 4 must be blank')
const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2])
@@ -66,29 +66,29 @@ for (const rfc of rfcs) {
const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`)
for (const required of REQUIRED[rfc.lifecycle] ?? []) {
for (const required of REQUIRED[note.lifecycle] ?? []) {
if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
}
if (rfc.lifecycle === 'implemented') {
if (note.lifecycle === 'implemented') {
for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) {
fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`)
fail(`\`${h2}\` is a proposal-era heading; an implemented Agent Note states what is (fold it into Decision/Consequences/Testing)`)
}
}
const hasSection = h2s.includes('## Alternatives considered')
const hasGrandfather = prose.includes(GRANDFATHER)
if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format Agent Note whose alternatives are not reconstructible carries the grandfather comment instead — see .agents/notes/README.md § The file format)')
if (hasGrandfather && note.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for Agent Notes dated before ${FORMAT_ADOPTED}`)
if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
if (prose.some(line => LEGACY_MARKERS.some(marker => line.includes(marker)))) fail('carries the retired legacy-format debt marker')
}
if (errors.length === 0) {
console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`)
console.log(`verify-agent-note-format: ${notes.length} Agent Note(s) checked, all conform to .agents/notes/README.md § The file format.`)
process.exit(0)
}
console.error('verify-rfc-format: violations found:')
console.error('verify-agent-note-format: violations found:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)

View File

@@ -0,0 +1,106 @@
/** Verify every compiled companion through its staged package self-reference under plain Node. */
import {
copyFileSync,
cpSync,
existsSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
const loaderUrl = options.get('--loader-url')
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
const failures = []
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
const { default: Loader } = await import(loaderUrl)
const loader = Object.create(Loader.prototype)
for (const manifestPath of manifests) {
const packageDir = dirname(resolve(packagesRoot, manifestPath))
const manifest = JSON.parse(readFileSync(resolve(packagesRoot, manifestPath), 'utf8'))
const packageName = manifest.name
if (typeof packageName !== 'string' || packageName.length === 0) {
failures.push(`${manifestPath}: missing package name`)
continue
}
const invariantExport = manifest.exports?.['./invariant']
if (typeof invariantExport !== 'object'
|| invariantExport.default !== './lib/invariant.js'
|| !manifest.files?.includes('lib/invariant.js')) {
failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`)
continue
}
// Keep the staged view below its owning package so Node reaches the real
// pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's
// relative workspace links on Windows. Copy the manifest-declared lib view
// so a companion that imports an undeclared runtime chunk fails here.
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-'))
try {
copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json'))
copyDeclaredLibFiles(packageDir, stagedPackageDir, manifest.files)
const probePath = resolve(stagedPackageDir, 'probe.mjs')
writeFileSync(
probePath,
`import * as companion from ${JSON.stringify(`${packageName}/invariant`)}\nexport default companion\n`,
)
const { default: companion } = await import(pathToFileURL(probePath).href)
if ('default' in companion) throw new Error('companion has a default export')
const unwrapped = loader.unwrapExports(companion)
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace')
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing')
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
throw new Error('companion does not inject invariants')
}
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing')
} catch (error) {
failures.push(`${packageName}: ${error instanceof Error ? error.message : String(error)}`)
} finally {
rmSync(stagedPackageDir, { recursive: true, force: true })
}
}
if (failures.length > 0) {
console.error('verify-built-package-invariants: compiled companion failures:')
for (const failure of failures) console.error(` ${failure}`)
process.exit(1)
}
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
function parseOptions(args) {
const allowed = new Set(['--packages-root', '--loader-url'])
const parsed = new Map()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
for (const pattern of files) {
if (!pattern.startsWith('lib/')) continue
for (const relativePath of globSync(pattern, { cwd: packageDir })) {
const source = resolve(packageDir, relativePath)
if (!existsSync(source)) continue
const target = resolve(stagedPackageDir, relativePath)
mkdirSync(dirname(target), { recursive: true })
cpSync(source, target, { recursive: true })
}
}
}

View File

@@ -0,0 +1,88 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const verifier = fileURLToPath(new URL('./verify-built-package-invariants.mjs', import.meta.url))
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(options: {
invariantSource?: string
invariantExport?: string
runtimeChunk?: string
} = {}): { root: string; loaderUrl: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-built-package-invariants-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
mkdirSync(join(packageDir, 'lib'), { recursive: true })
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
name: '@deepseek-ai/dsh-probe',
type: 'module',
files: ['lib/invariant.js'],
exports: {
'./invariant': {
default: options.invariantExport ?? './lib/invariant.js',
},
},
}, null, 2)}\n`)
writeFileSync(
join(packageDir, 'lib/invariant.js'),
options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
)
if (options.runtimeChunk !== undefined) {
writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk)
}
const loaderPath = join(root, 'loader.mjs')
writeFileSync(loaderPath, 'export default class Loader { unwrapExports(value) { return value } }\n')
return { root, loaderUrl: pathToFileURL(loaderPath).href }
}
function verify(root: string, loaderUrl: string) {
return spawnSync(process.execPath, [
verifier,
'--packages-root', root,
'--loader-url', loaderUrl,
], {
encoding: 'utf8',
timeout: 5_000,
})
}
describe('built package invariant verifier', () => {
it('loads the staged compiled self-reference through plain Node and Loader normalization', () => {
const { root, loaderUrl } = fixture()
const result = verify(root, loaderUrl)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks')
})
it('rejects a default export and a broken invariant export map', () => {
const withDefault = fixture({
invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
})
const defaultResult = verify(withDefault.root, withDefault.loaderUrl)
expect(defaultResult.status).toBe(1)
expect(defaultResult.stderr).toContain('companion has a default export')
const brokenExport = fixture({ invariantExport: './lib/missing.js' })
const exportResult = verify(brokenExport.root, brokenExport.loaderUrl)
expect(exportResult.status).toBe(1)
expect(exportResult.stderr).toContain('@deepseek-ai/dsh-probe')
})
it('rejects an invariant bundle that needs an unstaged runtime chunk', () => {
const { root, loaderUrl } = fixture({
invariantSource: "export * from './chunk.js'\n",
runtimeChunk: "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
})
const result = verify(root, loaderUrl)
expect(result.status).toBe(1)
expect(result.stderr).toContain('chunk.js')
})
})

View File

@@ -0,0 +1,102 @@
/**
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
* verify-module-graph covers package-level edges; this gate covers the
* directory level the future package split will land on: domain directories
* may import `contract/` and never each other, and only the assembly point
* (`apply.ts` / `index.ts`) may import across domains.
*
* Layer model (lower may not import higher):
* 0 contract/ shared contract surface (types + slot declarations)
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
* 2 apply.ts, index.ts assembly point and re-export shell
*
* Not yet wired into the gate sequence (loose-gate window); run directly:
* pnpm exec tsx scripts/verify-client-domain-graph.ts
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
/** Directory names treated as the shared contract layer (importable by all). */
const CONTRACT_DIRS = new Set(['contract'])
/** Top-level client files allowed to import across domains (assembly layer). */
const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
interface Violation { file: string; imported: string; reason: string }
/** Recursively list .ts/.tsx files under dir (relative paths). */
function listSources(dir: string, prefix = ''): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const rel = prefix ? `${prefix}/${name}` : name
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
}
return out
}
/** First path segment of a client-relative file, or '' for top-level files. */
function domainOf(rel: string): string {
const ix = rel.indexOf('/')
return ix === -1 ? '' : rel.slice(0, ix)
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
for (const rel of files) {
const fromDomain = domainOf(rel)
const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
if (isAssembly) continue
const source = readFileSync(join(clientDir, rel), 'utf8')
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
violations.push({
file: `${pkgName}/src/client/${rel}`,
imported: spec,
reason: fromDomain === ''
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
})
}
}
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')

View File

@@ -1,18 +1,32 @@
/**
* Reject JavaScript expressions in Cordis Loader entry metadata.
* Validate Cordis Loader entry metadata and example package resolution.
*
* The Loader interpolates only a plugin entry's `config`; expression objects in
* fields such as `disabled` remain truthy data and silently change composition.
* Example configs run from built packages, so every named package must resolve
* from the examples workspace and every local package must be in the root
* TypeScript project graph.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { dirname, relative, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import ts from 'typescript'
interface JsExpr {
__jsExpr: string
}
interface PackageManifest {
name?: string
dependencies?: Record<string, string>
}
interface PluginReference {
file: string
name: string
}
const root = resolve(import.meta.dirname, '..')
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
@@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
}).sort()
const errors: string[] = []
const examplePluginReferences: PluginReference[] = []
for (const file of files) {
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
@@ -42,8 +57,10 @@ for (const file of files) {
}
}
errors.push(...validateExampleResolution())
if (errors.length > 0) {
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
@@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
errors.push(`${file}${path}: entry must be an object`)
return
}
recordExamplePlugin(value, file)
validateMetadata(value, file, path)
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
for (let index = 0; index < value.config.length; index++) {
@@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
const patch = config.patches[index]
const patchPath = `${path}.config.patches[${index}]`
if (!isRecord(patch)) continue
recordExamplePlugin(patch, file)
validateMetadata(patch, file, patchPath)
if (!isUnknownArray(patch.insert)) continue
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
@@ -76,6 +95,97 @@ function validateEntry(value: unknown, file: string, path: string): void {
}
}
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
if (file.startsWith('examples/') && typeof entry.name === 'string') {
examplePluginReferences.push({ file, name: entry.name })
}
}
function validateExampleResolution(): string[] {
const violations: string[] = []
const exampleManifest = readManifest('examples/package.json')
const dependencies = exampleManifest.dependencies ?? {}
const localPackages = localPackageDirectories()
const rootReferences = rootProjectReferences()
const requiredPackages = new Map<string, Set<string>>()
for (const reference of examplePluginReferences) {
const packageName = packageNameFromSpecifier(reference.name)
if (packageName === undefined) continue
const locations = requiredPackages.get(packageName) ?? new Set<string>()
locations.add(reference.file)
requiredPackages.set(packageName, locations)
}
for (const [packageName, locations] of requiredPackages) {
if (!(packageName in dependencies)) {
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
}
}
const localExamplePackages = new Set([
...Object.keys(dependencies),
...requiredPackages.keys(),
])
for (const packageName of localExamplePackages) {
const packageDirectory = localPackages.get(packageName)
if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
}
return violations
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
}
function localPackageDirectories(): Map<string, string> {
const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
const packages = new Map<string, string>()
for (const manifestPath of manifests) {
const manifest = readManifest(manifestPath)
if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
}
return packages
}
function rootProjectReferences(): Set<string> {
// Typecheck runs two sibling aggregates (root = host program,
// tsconfig.client.json = client program; the two sides merge cordis Context
// under the same keys, so one program cannot see both). Seed both and follow
// any nested aggregate references to collect the covered leaf project set.
const collected = new Set<string>()
const queue = [resolve(root, 'tsconfig.json'), resolve(root, 'tsconfig.client.json')]
const seen = new Set<string>()
for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
if (seen.has(file)) continue
seen.add(file)
const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
if (config.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
}
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
for (const reference of references) {
if (typeof reference.path !== 'string') continue
const target = resolve(dirname(file), reference.path)
if (target.endsWith('.json')) queue.push(target)
else collected.add(target)
}
}
return collected
}
function packageNameFromSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
const segments = specifier.split('/')
if (specifier.startsWith('@')) {
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
}
return segments[0] || undefined
}
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
for (const field of metadataFields) {
if (!(field in entry)) continue

View File

@@ -1,7 +1,8 @@
/**
* Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The
* textual scan requires the extension, checks matching string literals too,
* and excludes built declarations and vendored source.
* Verify root-relative documentation paths in repo-authored TypeScript. The
* textual scan covers `docs/*.md` and `.agents/notes/*.md`, requires the
* extension, checks matching string literals too, and excludes built
* declarations and vendored source.
*/
import { existsSync } from 'node:fs'
@@ -18,9 +19,9 @@ const isExcluded = (p: string): boolean =>
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
/** Root-relative Markdown path token, excluding trailing prose. */
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
const DOC_REF = /(?:\bdocs|\.agents\/notes)\/[A-Za-z0-9._/-]+\.md/g
/** Find every broken `docs/….md` reference in one TypeScript file. */
/** Find every broken root-relative documentation reference in one TypeScript file. */
function findViolations(absPath: string): Violation[] {
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
}
@@ -30,11 +31,11 @@ const all = files.flatMap(file => findViolations(file.abs))
const checked = files.length
if (all.length === 0) {
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
console.log(`verify-doc-refs: ${checked} file(s) checked, all documentation references resolve.`)
process.exit(0)
}
console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
console.error('verify-doc-refs: broken documentation references found in source comments (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}

View File

@@ -7,8 +7,8 @@
* re-exports keep their docs at the declaring contract. Unknown forms fail closed.
*/
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import { existsSync, globSync, readFileSync } from 'node:fs'
import { relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
@@ -389,7 +389,13 @@ function checkDecl(
* @param w - the walk state violations append to.
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
*/
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
function checkScope(
statements: readonly ts.Statement[],
prefix: string,
w: Walk,
ambient: boolean,
allowedNames?: ReadonlySet<string>,
): void {
const byName = new Map<string, ts.Statement[]>()
const overloadSigs = new Set<string>()
const add = (name: string, stmt: ts.Statement): void => {
@@ -455,7 +461,20 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
continue
}
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) {
if (allowedNames === undefined) {
request(stmt, null)
} else if (ts.isVariableStatement(stmt)) {
for (const declaration of stmt.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && allowedNames.has(declaration.name.text)) {
request(stmt, declaration.name.text)
}
}
} else {
const name = declarationName(stmt) ?? 'default'
if (allowedNames.has(name)) request(stmt, null)
}
}
}
for (const stmt of statements) {
const only = requested.get(stmt)
@@ -463,6 +482,67 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
}
}
function exportedTargets(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (!value || typeof value !== 'object') return []
return Object.values(value).flatMap(exportedTargets)
}
function sourceEntry(target: string): string | undefined {
if (target.startsWith('./lib/types/') && target.endsWith('.d.ts')) {
return `src/${target.slice('./lib/types/'.length, -'.d.ts'.length)}.ts`
}
if (target.startsWith('./lib/') && target.endsWith('.js')) {
return `src/${target.slice('./lib/'.length, -'.js'.length)}.ts`
}
return undefined
}
function declarationName(declaration: ts.Node): string | undefined {
const name = (declaration as ts.NamedDeclaration).name
if (name && ts.isIdentifier(name)) return name.text
return undefined
}
/** Resolve the declarations reachable through packages that do not export src/*. */
function restrictedPublicNames(
scanRoot: string,
rels: readonly string[],
program: ts.Program,
checker: ts.TypeChecker,
): { restrictedPackages: Set<string>; namesByFile: Map<string, Set<string>> } {
const restrictedPackages = new Set<string>()
const namesByFile = new Map<string, Set<string>>()
const packages = new Set(rels.map(rel => rel.split('/').slice(0, 3).join('/')))
for (const packageDir of packages) {
const manifestPath = resolve(scanRoot, packageDir, 'package.json')
if (!existsSync(manifestPath)) continue
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { exports?: Record<string, unknown> }
if (!manifest.exports || manifest.exports['./src/*'] !== undefined) continue
restrictedPackages.add(packageDir)
const entries = new Set(Object.values(manifest.exports).flatMap(exportedTargets).flatMap((target) => {
const entry = sourceEntry(target)
return entry ? [`${packageDir}/${entry}`] : []
}))
for (const entry of entries) {
const source = program.getSourceFile(resolve(scanRoot, entry))
const moduleSymbol = source && checker.getSymbolAtLocation(source)
if (!source || !moduleSymbol) continue
for (const exported of checker.getExportsOfModule(moduleSymbol)) {
const target = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported
for (const declaration of target.declarations ?? []) {
const name = declarationName(declaration)
const file = declaration.getSourceFile().fileName
const rel = relative(scanRoot, file).split(sep).join('/')
if (!name || !rel.startsWith(`${packageDir}/src/`)) continue
namesByFile.set(rel, new Set([...(namesByFile.get(rel) ?? []), name]))
}
}
}
}
return { restrictedPackages, namesByFile }
}
/**
* Compiler options for the walk's program.
*
@@ -495,15 +575,26 @@ function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
*/
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
const violations: string[] = []
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot })
.map(path => path.split(sep).join('/'))
.sort()
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
const checker = program.getTypeChecker()
const { restrictedPackages, namesByFile } = restrictedPublicNames(scanRoot, rels, program, checker)
for (const rel of rels) {
const sf = program.getSourceFile(resolve(scanRoot, rel))
if (!sf) continue // program root files always resolve; guard for narrowing
// A script-style declaration file (no imports/exports) is one big ambient
// scope; a module-style .d.ts still honors explicit export modifiers.
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
const packageDir = rel.split('/').slice(0, 3).join('/')
const allowedNames = restrictedPackages.has(packageDir) ? namesByFile.get(rel) ?? new Set<string>() : undefined
checkScope(
sf.statements,
'',
{ rel, sf, text: sf.text, checker, violations },
sf.isDeclarationFile && !ts.isExternalModule(sf),
allowedNames,
)
}
return violations
}

View File

@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
const PATTERNS = [
'README.md',
'README.zh.md',
'.agents/notes/**/*.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',

View File

@@ -2,7 +2,8 @@
* Reject Markdown prose paragraphs spanning multiple physical lines. The GFM
* AST distinguishes paragraphs—including those in lists and blockquotes—from
* multiline structural nodes. The checker never rewrites; symlinked instruction
* files are deduped. The owning convention is in `docs/AGENTS.md`.
* files are deduped. VitePress frontmatter and custom-container delimiters are
* masked before parsing. The owning convention is in `docs/AGENTS.md`.
*/
import { readFileSync } from 'node:fs'
@@ -13,15 +14,16 @@ import { uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
/** Files to check: doc-typecheck's scope, prompt goldens, and the AGENTS.md pair. */
/** Files to check: doc-typecheck's scope, system-prompt expected outputs, and the AGENTS.md pair. */
const PATTERNS = [
'README.md',
'README.zh.md',
'.agents/notes/**/*.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
'examples/**/system-prompt.golden.md',
'packages/**/system-prompt.golden.md',
'examples/**/system-prompt.expected.md',
'packages/**/system-prompt.expected.md',
'AGENTS.md',
'packages/AGENTS.md',
]
@@ -34,11 +36,23 @@ interface Violation {
text: string
}
function maskVitePressStructure(source: string): string {
const lines = source.split('\n')
if (lines[0] === '---') {
const closing = lines.indexOf('---', 1)
if (closing !== -1) {
for (let index = 0; index <= closing; index++) lines[index] = ''
}
}
return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n')
}
/** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
const source = readFileSync(absPath, 'utf8')
const tree = parseMarkdown(source)
const parsedSource = maskVitePressStructure(source)
const tree = parseMarkdown(parsedSource)
const out: Violation[] = []
visitMarkdown(tree, (node: Nodes): boolean | void => {

View File

@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
const PATTERNS = [
'README.md',
'README.zh.md',
'.agents/notes/**/*.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',
@@ -78,7 +79,10 @@ Object.defineProperty(globalThis, 'window', { value: window })
Object.defineProperty(globalThis, 'document', { value: window.document })
Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
const mermaid = (await import('mermaid')).default
mermaid.initialize({ startOnLoad: false })
// maxEdges: mermaid's default 500-edge render guard; the module graph grows
// with every package edge and crossed it legitimately. Raise the guard here
// (a secure config settable only via initialize) rather than trimming edges.
mermaid.initialize({ startOnLoad: false, maxEdges: 1000 })
for (const block of blocks) {
try {
await mermaid.parse(block.source, { suppressErrors: false })

View File

@@ -0,0 +1,21 @@
/** Verify package-owned invariant source and publication contracts. */
import { resolve } from 'node:path'
import {
collectPackageInvariantViolations,
formatPackageInvariantViolation,
packageInvariantOwners,
} from './package-invariants.ts'
const root = resolve(import.meta.dirname, '..')
const violations = collectPackageInvariantViolations(root)
if (violations.length > 0) {
console.error('verify-package-invariants: violations found:')
for (const violation of violations) {
console.error(` ${formatPackageInvariantViolation(root, violation)}`)
}
process.exit(1)
}
console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`)

View File

@@ -14,6 +14,7 @@ const root = resolve(import.meta.dirname, '..')
/** Markdown + repo-authored TypeScript that may cite package paths. */
const PATTERNS = [
'README.md',
'.agents/notes/**/*.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',

View File

@@ -2,7 +2,7 @@
* Doc-sync gate for the canonical package-README limitations section. It scans
* package manifests, rejects missing or variant sections, and requires one
* top-level bullet; audited packages in {@link NO_LIMITATIONS} must omit it.
* See the [limitations RFC](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md).
* See the [limitations Agent Note](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md).
*/
import { existsSync, globSync, readFileSync } from 'node:fs'

View File

@@ -1,8 +1,8 @@
/**
* Doc-sync gate for package README Model Experience sections. It validates
* audited package classifications, context-surface fields, package-owned text
* blocks, generated-catalog links, and final-section order. See the
* [Model Experience RFC](../docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md).
* audited package classifications, model/token/KV-cache fields, package-owned
* text blocks, generated-catalog links, and final-section order. See the
* [Model Experience Agent Note](../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md).
*/
import { existsSync, globSync, readFileSync } from 'node:fs'
@@ -12,8 +12,10 @@ import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from
const root = resolve(import.meta.dirname, '..')
const HEADING = '## Model Experience'
const LIMITATIONS_HEADING = '## Known Limitations and Deferred Work'
const MODEL_VIEW_LABEL = '**What the model sees**'
const TOKEN_EFFECT_LABEL = '**Token effect**'
const MODEL_VIEW_HEADING = '#### What the model sees'
const TOKEN_EFFECT_HEADING = '#### Token effect'
const KV_CACHE_EFFECT_HEADING = '#### KV Cache effect'
const FIELD_HEADINGS = [MODEL_VIEW_HEADING, TOKEN_EFFECT_HEADING, KV_CACHE_EFFECT_HEADING] as const
type SentenceKind = 'none' | 'indirect'
@@ -30,40 +32,69 @@ interface SentenceContract {
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
}
/**
* Packages whose Model Experience is simple enough for one gated sentence.
* Every other package must carry canonical context-surface blocks. A package
* moves on or off this list with the change to its context behavior.
* Packages whose Model Experience is simple enough for one gated sentence plus
* a KV-cache field. Every other package must carry canonical context-surface
* blocks. A package moves on or off this list with its context behavior.
*/
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'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/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or 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/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
'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/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/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/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
@@ -81,35 +112,41 @@ interface ContextSurface {
heading: Line
modelView: Line
tokenEffect: Line
kvCacheEffect: Line
title: string
modelViewVerbatimBlocks: number
verbatimBlocks: number
}
/** Validate H4-plus-markdown literals nested after one context surface's fields. */
function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error?: string } {
interface ParsedField {
value: Line
verbatimBlocks: number
}
/** Validate H5-plus-markdown literals nested under one Model Experience field. */
function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>): { blocks: number; error?: string } {
let cursor = 0
while (raw[cursor]?.trim().length === 0) cursor += 1
if (cursor === raw.length) return { blocks: 0 }
let blocks = 0
const fragments = new Set<string>()
while (true) {
while (raw[cursor]?.trim().length === 0) cursor += 1
if (cursor === raw.length) break
if (!/^#### \S/.test(raw[cursor] ?? '')) {
return { blocks, error: 'content after Token effect must be a titled H4 verbatim block' }
if (!/^##### \S/.test(raw[cursor] ?? '')) {
return { blocks, error: 'content after a field paragraph must be a titled H5 verbatim block' }
}
const title = (raw[cursor] as string).slice('#### '.length)
const title = (raw[cursor] as string).slice('##### '.length)
const fragment = headingFragment(title)
if (fragment.length === 0) return { blocks, error: 'verbatim H4 title must be non-empty' }
if (fragment.length === 0) return { blocks, error: 'verbatim H5 title must be non-empty' }
if (fragments.has(fragment)) {
return { blocks, error: `verbatim H4 title ${JSON.stringify(title)} is duplicated within its context surface` }
return { blocks, error: `verbatim H5 title ${JSON.stringify(title)} is duplicated within its context surface` }
}
fragments.add(fragment)
cursor += 1
while (raw[cursor]?.trim().length === 0) cursor += 1
if (raw[cursor] !== '```markdown') {
return { blocks, error: 'each nested verbatim H4 requires an exact ```markdown fence' }
return { blocks, error: 'each nested verbatim H5 requires an exact ```markdown fence' }
}
cursor += 1
const contentStart = cursor
@@ -122,7 +159,7 @@ function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error
return { blocks }
}
/** GitHub-style fragment for the simple ASCII H4 titles allowed by this contract. */
/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */
function headingFragment(title: string): string {
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
}
@@ -155,6 +192,7 @@ let indirectCount = 0
let verbatimBlockCount = 0
let systemPromptSurfaceCount = 0
let toolSchemaSurfaceCount = 0
let kvCacheEffectCount = 0
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
if (!scannedPackages.has(pkg)) {
@@ -248,13 +286,31 @@ for (const packageJson of packageJsons) {
if (sentenceContract !== undefined) {
const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/
const rawContent = rawSection.filter(line => line.trim().length > 0)
if (content.length !== 1 || rawContent.length !== 1 || !pattern.test(content[0]?.raw ?? '')) {
const sentence = content[0]
const kvCacheHeading = content[1]
const kvCacheEffect = content[2]
if (content.length !== 3 || rawContent.length !== 3 || !pattern.test(sentence?.raw ?? '')) {
const prefix = sentenceContract.kind === 'none' ? 'None, as ' : 'Indirectly, through '
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period` })
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period, followed by ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
continue
}
if (kvCacheHeading?.raw !== KV_CACHE_EFFECT_HEADING
|| kvCacheEffect === undefined
|| /^#{1,6} /.test(kvCacheEffect.raw)
|| kvCacheEffect.raw.trim().length === 0) {
failures.push({ path: readme, message: `line ${kvCacheHeading?.index ?? sentence?.index ?? modelHeading.index}: short Model Experience form requires exact ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
continue
}
if (sentence === undefined
|| sentence.index !== modelHeading.index + 2
|| kvCacheHeading.index !== sentence.index + 2
|| kvCacheEffect.index !== kvCacheHeading.index + 2) {
failures.push({ path: readme, message: 'short Model Experience sentence, KV-cache H4, and paragraph require one blank line between each element' })
continue
}
if (sentenceContract.kind === 'none') explainedNoneCount += 1
else indirectCount += 1
kvCacheEffectCount += 1
continue
}
@@ -280,8 +336,6 @@ for (const packageJson of packageJsons) {
const end = surfaceStarts[surfaceIndex + 1]?.index ?? content.length
const entries = content.slice(start.index, end)
const heading = entries[0] as Line
const modelView = entries[1]
const tokenEffect = entries[2]
const title = heading.raw.slice('### '.length)
const fragment = headingFragment(title)
if (fragment.length === 0) {
@@ -294,56 +348,100 @@ for (const packageJson of packageJsons) {
surfaceError = true
break
}
if (modelView === undefined || !modelView.raw.startsWith(`${MODEL_VIEW_LABEL}: `) || modelView.raw.slice(`${MODEL_VIEW_LABEL}: `.length).trim().length === 0) {
failures.push({ path: readme, message: `line ${modelView?.index ?? heading.index}: context surface requires non-empty ${MODEL_VIEW_LABEL}: text` })
surfaceError = true
break
}
if (tokenEffect === undefined || !tokenEffect.raw.startsWith(`${TOKEN_EFFECT_LABEL}: `) || tokenEffect.raw.slice(`${TOKEN_EFFECT_LABEL}: `.length).trim().length === 0) {
failures.push({ path: readme, message: `line ${tokenEffect?.index ?? heading.index}: context surface requires non-empty ${TOKEN_EFFECT_LABEL}: text` })
const fieldStarts = entries
.map((line, index) => ({ line, index }))
.filter(entry => /^#### \S/.test(entry.line.raw))
if (fieldStarts.length !== FIELD_HEADINGS.length || fieldStarts[0]?.index !== 1) {
failures.push({ path: readme, message: `line ${heading.index}: context surface requires exactly three ordered H4 fields: ${FIELD_HEADINGS.join(', ')}` })
surfaceError = true
break
}
if ((surfaceIndex === 0 && heading.index !== modelHeading.index + 2)
|| rawLines[heading.index - 2]?.trim().length !== 0
|| modelView.index !== heading.index + 2
|| tokenEffect.index !== modelView.index + 2) {
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and fields require one blank line between each element` })
|| fieldStarts[0].line.index !== heading.index + 2) {
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and first field require one blank line between them` })
surfaceError = true
break
}
const unexpected = entries.slice(3).find(line => !/^#### \S/.test(line.raw))
if (unexpected !== undefined) {
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${TOKEN_EFFECT_LABEL} must be a titled H4 plus \`markdown\` fence inside this context surface` })
surfaceError = true
break
const parsedFields: ParsedField[] = []
const verbatimFragments = new Set<string>()
for (let fieldIndex = 0; fieldIndex < FIELD_HEADINGS.length; fieldIndex += 1) {
const fieldStart = fieldStarts[fieldIndex] as { line: Line; index: number }
const expectedHeading = FIELD_HEADINGS[fieldIndex] as string
if (fieldStart.line.raw !== expectedHeading) {
failures.push({ path: readme, message: `line ${fieldStart.line.index}: expected exact field heading ${JSON.stringify(expectedHeading)}, found ${JSON.stringify(fieldStart.line.raw)}` })
surfaceError = true
break
}
const fieldEnd = fieldStarts[fieldIndex + 1]?.index ?? entries.length
const fieldEntries = entries.slice(fieldStart.index, fieldEnd)
const value = fieldEntries[1]
if (value === undefined || /^#{1,6} /.test(value.raw) || value.raw.trim().length === 0) {
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} requires one non-empty paragraph` })
surfaceError = true
break
}
if (value.index !== fieldStart.line.index + 2) {
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} and its paragraph require one blank line between them` })
surfaceError = true
break
}
const unexpected = fieldEntries.slice(2).find(line => !/^##### \S/.test(line.raw))
if (unexpected !== undefined) {
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${expectedHeading} paragraph must be a titled H5 plus \`markdown\` fence owned by that field` })
surfaceError = true
break
}
const nextHeadingLine = fieldStarts[fieldIndex + 1]?.line.index
?? surfaceStarts[surfaceIndex + 1]?.line.index
?? nextH2Line
if (rawLines[nextHeadingLine - 2]?.trim().length !== 0) {
failures.push({ path: readme, message: `line ${nextHeadingLine}: Model Experience headings require a preceding blank line` })
surfaceError = true
break
}
const verbatim = validateNestedVerbatim(rawLines.slice(value.index, nextHeadingLine - 1), verbatimFragments)
if (verbatim.error !== undefined) {
failures.push({ path: readme, message: `line ${value.index}: ${verbatim.error}` })
surfaceError = true
break
}
if (fieldEntries.length - 2 !== verbatim.blocks) {
failures.push({ path: readme, message: `line ${value.index}: every nested H5 must own exactly one \`markdown\` fence` })
surfaceError = true
break
}
parsedFields.push({ value, verbatimBlocks: verbatim.blocks })
}
const nextHeadingLine = surfaceStarts[surfaceIndex + 1]?.line.index ?? nextH2Line
const verbatim = validateNestedVerbatim(rawLines.slice(tokenEffect.index, nextHeadingLine - 1))
if (verbatim.error !== undefined) {
failures.push({ path: readme, message: `line ${tokenEffect.index}: ${verbatim.error}` })
surfaceError = true
break
}
if (entries.length - 3 !== verbatim.blocks) {
failures.push({ path: readme, message: `line ${tokenEffect.index}: every nested H4 must own exactly one \`markdown\` fence` })
surfaceError = true
break
}
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw)) {
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H4 in its owning H3` })
if (surfaceError) break
const modelViewField = parsedFields[0] as ParsedField
const tokenEffectField = parsedFields[1] as ParsedField
const kvCacheEffectField = parsedFields[2] as ParsedField
const modelView = modelViewField.value
const tokenEffect = tokenEffectField.value
const kvCacheEffect = kvCacheEffectField.value
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw) || /\]\(#[^)]+\)/.test(kvCacheEffect.raw)) {
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H5 in its owning H4 field` })
surfaceError = true
break
}
surfaceFragments.add(fragment)
surfaces.push({ heading, modelView, tokenEffect, title, verbatimBlocks: verbatim.blocks })
surfaces.push({
heading,
modelView,
tokenEffect,
kvCacheEffect,
title,
modelViewVerbatimBlocks: modelViewField.verbatimBlocks,
verbatimBlocks: parsedFields.reduce((total, field) => total + field.verbatimBlocks, 0),
})
}
if (surfaceError) continue
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
&& surface.verbatimBlocks === 0)
&& surface.modelViewVerbatimBlocks === 0)
if (promptWithoutVerbatim !== undefined) {
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H4 plus verbatim \`markdown\` block` })
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H5 plus verbatim \`markdown\` block under ${MODEL_VIEW_HEADING}` })
continue
}
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
@@ -375,11 +473,12 @@ for (const packageJson of packageJsons) {
contextSurfaceCount += surfaces.length
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).length
kvCacheEffectCount += surfaces.length
structuredCount += 1
}
if (failures.length === 0) {
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${kvCacheEffectCount} KV-cache fields, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
process.exit(0)
}

View File

@@ -1,39 +0,0 @@
/**
* Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the
* generated index and reject index rows in the curated README. Structural rules
* and rendering are shared with `rfc-index.ts`; the closed classification
* contract lives in `docs/rfc/README.md`.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
if (errors.length === 0) {
let index: string | undefined
try {
index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8')
} catch {
// A missing INDEX.md is reported below as staleness, exactly like a drifted one.
}
if (renderIndex(rfcs) !== index) {
errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result')
}
const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8')
for (const line of readme.split('\n')) {
if (INDEX_ROW.test(line)) {
errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`)
}
}
}
if (errors.length === 0) {
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
process.exit(0)
}
console.error('verify-rfc-classification: violations found:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)

View File

@@ -24,8 +24,18 @@ const root = resolve(import.meta.dirname, '..')
const listMode = process.argv.includes('--list')
const writeMode = process.argv.includes('--write')
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */
const SCOPE_PATTERNS = [
'README.md',
'README.zh.md',
'README.i18n.yaml',
'.agents/notes/**/*.md',
'.agents/notes/**/*.i18n.yaml',
'docs/**/*.md',
'docs/**/*.i18n.yaml',
'python/**/*.md',
'python/**/*.i18n.yaml',
]
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
@@ -121,8 +131,8 @@ for (const req of manifest.required) {
}
}
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
// bilingual: a new RFC lands with its pair or not at all. Deterministic from
// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
// the filename alone — no git history, so it holds on shallow CI checkouts.
for (const source of sources) {
if (isExcluded(source)) continue

View File

@@ -1,7 +1,10 @@
/**
* Verify every `ts type-equiv` block against the source symbol named by the
* manifest. Blocks and entries have a one-to-one relationship; comparison
* ignores comments and whitespace but preserves declaration structure.
* Verify every `ts type-equiv` and `ts public-api` block against the source
* symbol named by the manifest. Ordinary entries preserve the complete
* declaration; `public-api` entries preserve a class's body-stripped public
* declaration. Blocks and entries have a one-to-one relationship; comparison
* ignores whitespace and non-JSDoc comments but preserves declaration
* structure and every original JSDoc comment.
*/
import { globSync, readFileSync, existsSync } from 'node:fs'
@@ -11,35 +14,35 @@ import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
/** One manifest entry: a documented type-equiv block and its source symbol. */
/** One manifest entry: a source-equivalence block and its source symbol. */
interface ManifestEntry {
/** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
/** Doc file (repo-relative) containing the source-equivalence block. */
doc: string
/** The declared symbol the block must match (e.g. `SessionEvent`). */
symbol: string
/** Source file (repo-relative) that exports the symbol. */
source: string
/** Complete declaration (default), or a body-stripped public class API. */
projection?: 'public-api'
}
/** One extracted ` ```ts type-equiv ` block. */
/** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */
interface EquivBlock {
doc: string
/** 1-based line of the opening fence (for diagnostics). */
line: number
/** Symbol name parsed from the block's declaration. */
symbol: string
/** Complete declaration (default), or a body-stripped public class API. */
projection?: 'public-api'
/** Block body (the pasted declaration). */
code: string
}
/**
* Remove comments and normalize whitespace so prose-only edits do not drift
* structural copies. This is intentionally not a general tokenizer: repo type
* declarations do not contain comment delimiters inside string literals.
*/
function normalize(code: string): string {
/** Normalize declaration structure independently of comments and whitespace. */
function normalizeStructure(code: string): string {
return code
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
@@ -47,23 +50,38 @@ function normalize(code: string): string {
.trim()
}
/**
* Extract normalized JSDoc comments in source order. Type declarations in this
* repository do not contain comment delimiters inside string literals.
*/
function normalizeJSDoc(code: string): string[] {
return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)]
.map(match => match[0].replace(/\s+/g, ' ').trim())
}
/** Strip source-only export modifiers. */
function stripExport(code: string): string {
return code.replace(/^export\s+(default\s+)?/, '')
}
/** Parse the declared symbol name from a type-equiv block body. */
/** Parse the declared symbol name from a source-equivalence block body. */
function blockSymbol(code: string): string | null {
const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code)
return m?.[1] ?? null
const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
for (const stmt of sf.statements) {
const named =
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
if (named && stmt.name) return stmt.name.text
}
return null
}
/** Extract every ` ```ts type-equiv ` block from one Markdown file. */
/** Extract every source-equivalence block from one Markdown file. */
function extractEquivBlocks(docRel: string): EquivBlock[] {
const text = readFileSync(resolve(root, docRel), 'utf8')
const lines = text.split('\n')
const blocks: EquivBlock[] = []
let open: { line: number; body: string[] } | null = null
let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
@@ -78,21 +96,33 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
if (!symbol) {
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
}
blocks.push({ doc: docRel, line: open.line, symbol, code })
blocks.push({
doc: docRel,
line: open.line,
symbol,
code,
...(open.projection === undefined ? {} : { projection: open.projection }),
})
open = null
continue
}
if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] }
const info = (fence[2] ?? '').trim()
if (info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
}
if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
}
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
return blocks
}
/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
/**
* The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
* null when the symbol is not declared there. Uses the TS parser so it spans
* interfaces, type aliases (including mapped/generic ones), classes, and enums
* uniformly, and excludes the leading JSDoc (getStart skips leading trivia)
* while keeping inline member comments. */
* uniformly while including declaration and member JSDoc.
*/
function sourceDeclaration(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
@@ -102,19 +132,89 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null {
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
if (named && stmt.name?.text === symbol) {
return stripExport(stmt.getText(sf))
const declarationStart = stmt.getStart(sf)
const jsDoc = ts.getJSDocCommentsAndTags(stmt)
.filter(ts.isJSDoc)
.map(doc => text.slice(doc.pos, doc.end))
.join('\n')
const declaration = stripExport(text.slice(declarationStart, stmt.getEnd()))
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
}
}
return null
}
/** Leading source JSDoc attached to one declaration or member. */
function sourceJSDoc(text: string, node: ts.Node): string {
return ts.getJSDocCommentsAndTags(node)
.filter(ts.isJSDoc)
.map(doc => text.slice(doc.pos, doc.end))
.join('\n')
}
/** Whether a class member is part of its public declaration. */
function isPublicMember(member: ts.ClassElement): boolean {
if (ts.isClassStaticBlockDeclaration(member)) return false
const name = ts.getNameOfDeclaration(member)
if (name && ts.isPrivateIdentifier(name)) return false
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
return !(modifiers?.some(modifier =>
modifier.kind === ts.SyntaxKind.PrivateKeyword
|| modifier.kind === ts.SyntaxKind.ProtectedKeyword,
) ?? false)
}
/** Remove an implementation body while retaining the source signature. */
function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string {
const start = member.getStart(sf)
let end = member.end
if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member)
|| ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
if (member.body) end = member.body.getStart(sf)
}
if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf)
const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd()
return `${signature};`
}
/**
* Render a class as an ambient declaration containing only its public fields,
* constructor, accessors, and methods. Implementation bodies and private or
* protected members are deliberately absent; original class/member JSDoc is
* retained so the projection is the source-owned public contract.
*/
function sourcePublicApi(sourceRel: string, symbol: string): string | null {
const abs = resolve(root, sourceRel)
const text = readFileSync(abs, 'utf8')
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
for (const stmt of sf.statements) {
if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
const classDoc = sourceJSDoc(text, stmt)
const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : ''
const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ')
const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ')
const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {`
const members = stmt.members
.filter(isPublicMember)
.map((member) => {
const jsDoc = sourceJSDoc(text, member)
const declaration = bodylessMember(text, sf, member)
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
})
const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n')
return classDoc === '' ? declaration : `${classDoc}\n${declaration}`
}
return null
}
const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
const entries = manifest.entries
// Key a block/entry by doc + symbol (a symbol may be documented in more than one
// doc, but at most once per doc).
const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}`
// Key a block/entry by doc + symbol + projection. A symbol may be documented in
// more than one doc, and a doc may carry both complete and projected forms.
const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string =>
`${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}`
// Collect every type-equiv block across ALL docs in scope — not only the docs
// the manifest names — so a block in an unmanifested doc is found and reported
@@ -133,7 +233,7 @@ for (const d of [...new Set(entries.map(e => e.doc))]) {
else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
}
// Duplicate-block guard: the same symbol twice in one doc is ambiguous.
// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous.
const blockByKey = new Map<string, EquivBlock>()
for (const b of blocks) {
const k = keyOf(b)
@@ -173,16 +273,25 @@ let verified = 0
for (const e of entries) {
const b = blockByKey.get(keyOf(e))
if (!b) continue // already reported as an orphan entry
const decl = sourceDeclaration(e.source, e.symbol)
const decl = e.projection === 'public-api'
? sourcePublicApi(e.source, e.symbol)
: sourceDeclaration(e.source, e.symbol)
if (decl === null) {
errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
continue
}
if (normalize(decl) !== normalize(stripExport(b.code))) {
const doc = stripExport(b.code)
const sourceStructure = normalizeStructure(decl)
const docStructure = normalizeStructure(doc)
const sourceJSDoc = normalizeJSDoc(decl)
const docJSDoc = normalizeJSDoc(doc)
if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) {
errors.push(
`DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
+ ` source: ${normalize(decl)}\n`
+ ` doc: ${normalize(stripExport(b.code))}`,
+ ` source structure: ${sourceStructure}\n`
+ ` doc structure: ${docStructure}\n`
+ ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n`
+ ` doc JSDoc: ${JSON.stringify(docJSDoc)}`,
)
continue
}
@@ -190,7 +299,7 @@ for (const e of entries) {
}
if (errors.length === 0) {
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`)
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
process.exit(0)
}