diff --git a/package.json b/package.json index 50319023cf..e171048edf 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,11 @@ "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 10068eb792..3af95e2d0e 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -8,7 +8,11 @@ * build is required first). A block that is a deliberate sketch rather than * compilable code opts out with an explicit ` ```ts ignore-check ` info string * — the opt-out is visible in the source, and this script reports the ratio so - * the escape hatch can't quietly become the norm. + * the escape hatch can't quietly become the norm. A third info string, + * ` ```ts type-equiv `, marks a verbatim paste of a source type definition that + * `scripts/verify-type-equiv.ts` drift-checks against the source symbol; it is + * skipped here and EXCLUDED from the opt-out ratio (a separately-checked + * category, not an unchecked sketch). * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -20,23 +24,35 @@ import { glob } from 'node:fs/promises' const root = resolve(import.meta.dirname, '..') +/** + * How a fenced block participates in this gate: + * - `check` (` ```ts `) — compiled. + * - `ignore` (` ```ts ignore-check `) — a deliberate sketch; skipped, and + * counted in the opt-out ratio so the escape hatch can't quietly take over. + * - `type-equiv` (` ```ts type-equiv `) — a verbatim paste of a source type + * definition, drift-checked by `scripts/verify-type-equiv.ts` against the + * source symbol. Skipped HERE (it is not standalone-compilable — no imports) + * and EXCLUDED from the opt-out ratio: it is a separate fully-checked + * category, not an unchecked sketch. + */ +type BlockKind = 'check' | 'ignore' | 'type-equiv' + /** One extracted code block. */ interface Block { file: string /** 1-based line of the opening fence. */ line: number - /** `true` when the fence is ` ```ts ignore-check ` (skip compilation). */ - ignored: boolean + kind: BlockKind code: string } -/** Extract every ```ts / ```ts ignore-check block from one Markdown file. */ +/** Extract every ```ts / ```ts ignore-check / ```ts type-equiv block 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; ignored: boolean; body: string[] } | null = null + let open: { line: number; kind: BlockKind; body: string[] } | null = null lines.forEach((raw, i) => { const fence = /^```(\s*)(\S.*)?$/.exec(raw) @@ -46,15 +62,18 @@ function extractBlocks(absPath: string): Block[] { } if (open) { // closing fence - blocks.push({ file, line: open.line, ignored: open.ignored, code: open.body.join('\n') }) + blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') }) open = null return } // opening fence — only care about ts blocks const info = (fence[2] ?? '').trim() - if (info === 'ts' || info === 'ts ignore-check') { - open = { line: i + 1, ignored: info === 'ts ignore-check', body: [] } - } + const kind: BlockKind | null = + info === 'ts' ? 'check' + : info === 'ts ignore-check' ? 'ignore' + : info === 'ts type-equiv' ? 'type-equiv' + : null + if (kind) open = { line: i + 1, kind, body: [] } }) return blocks } @@ -106,8 +125,13 @@ for (const pattern of markdownGlobs) { files.sort() const all = files.flatMap(extractBlocks) -const checked = all.filter(b => !b.ignored) -const ignored = all.filter(b => b.ignored) +const checked = all.filter(b => b.kind === 'check') +const ignored = all.filter(b => b.kind === 'ignore') +// `type-equiv` blocks are verified by verify-type-equiv.ts, not here: neither +// compiled nor counted toward the opt-out ratio (they are a separate +// fully-checked category, not an unchecked sketch). The ratio's denominator is +// therefore the compile-eligible blocks only. +const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { console.log('doc-typecheck: no ts code blocks to check.') @@ -139,11 +163,11 @@ try { process.exit(1) } - const ratio = ignored.length / all.length - console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out).`) + const ratio = ignored.length / ratioDenominator + console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${all.length - ratioDenominator} type-equiv (checked by verify-type-equiv).`) // Guard against the escape hatch becoming the norm. - if (all.length >= 4 && ratio > 0.5) { - console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${all.length}). Make them compile or delete them.`) + 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 { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json new file mode 100644 index 0000000000..5ea2916cd4 --- /dev/null +++ b/scripts/type-equiv.manifest.json @@ -0,0 +1,41 @@ +{ + "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "entries": [ + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/agent/src/types.ts" }, + + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/session/src/types.ts" }, + + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/session/src/types.ts" }, + + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/tools/src/index.ts" }, + + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/src/types.ts" } + ] +} diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts new file mode 100644 index 0000000000..5de48b97f9 --- /dev/null +++ b/scripts/verify-type-equiv.ts @@ -0,0 +1,208 @@ +/** + * Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a + * VERBATIM copy of the source type definition it documents. + * + * The core-data-structures docs paste real type definitions so a reader sees + * the exact shape. A paste drifts the moment source changes — this script is + * the drift guard. For each block it extracts the documented symbol's + * declaration from source via the TypeScript compiler API, whitespace- + * normalizes both the source text and the block, and asserts they are equal. + * + * Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`), + * NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script + * enforces a 1:1 correspondence — every type-equiv block in the docs has + * exactly one manifest entry (keyed by doc + declared symbol), and every + * manifest entry resolves to exactly one block. An orphan on either side fails, + * so a block can never be silently unchecked and an entry can never rot. + * + * doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it + * (it is not standalone-compilable and is not counted in the opt-out ratio); + * the two scripts share the fence, this one owns the verification. + * + * Run: `tsx scripts/verify-type-equiv.ts`. + */ + +import { readFileSync, existsSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') + +/** One manifest entry: a documented type-equiv block and its source symbol. */ +interface ManifestEntry { + /** Doc file (repo-relative) containing the ` ```ts type-equiv ` 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 +} + +/** One extracted ` ```ts type-equiv ` 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 + /** Block body (the pasted declaration). */ + code: string +} + +/** Collapse a declaration to its structural form for comparison: drop comments + * (block + line), then collapse all whitespace runs to single spaces. This lets + * a doc block show a CLEAN definition (without source's verbose inline JSDoc) + * while still guaranteeing the field shapes match — drift in a field name or + * type fails; a reworded inline comment does not. Adequate for our own type + * source (no string literal contains `//` or `/* *​/`); not a general tokenizer. */ +function normalize(code: string): string { + return code + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1') + .replace(/\s+/g, ' ') + .trim() +} + +/** Strip a leading `export ` / `export default ` modifier — the doc block shows + * the bare declaration, the source carries the export modifier. */ +function stripExport(code: string): string { + return code.replace(/^export\s+(default\s+)?/, '') +} + +/** Parse the declared symbol name from a type-equiv block body. */ +function blockSymbol(code: string): string | null { + const m = /(?:export\s+(?:default\s+)?)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) + return m?.[1] ?? null +} + +/** Extract every ` ```ts type-equiv ` 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 + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? '' + const fence = /^```(\s*)(\S.*)?$/.exec(raw) + if (!fence) { + if (open) open.body.push(raw) + continue + } + if (open) { + const code = open.body.join('\n') + const symbol = blockSymbol(code) + 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 }) + open = null + continue + } + if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] } + } + 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 + * 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. */ +function sourceDeclaration(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) { + const named = + ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) + || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt) + if (named && stmt.name?.text === symbol) { + return stripExport(stmt.getText(sf)) + } + } + 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}` + +// Collect every type-equiv block across the docs the manifest references. +const docFiles = [...new Set(entries.map(e => e.doc))] +const missingDocs = docFiles.filter(d => !existsSync(resolve(root, d))) +const blocks: EquivBlock[] = docFiles.filter(d => existsSync(resolve(root, d))).flatMap(extractEquivBlocks) + +const errors: string[] = [] +for (const d of missingDocs) errors.push(`manifest references ${d}, which does not exist`) + +// Duplicate-block guard: the same symbol twice in one doc is ambiguous. +const blockByKey = new Map() +for (const b of blocks) { + const k = keyOf(b) + const prior = blockByKey.get(k) + if (prior) { + errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`) + continue + } + blockByKey.set(k, b) +} + +// Duplicate-entry guard in the manifest. +const entryByKey = new Map() +for (const e of entries) { + const k = keyOf(e) + if (entryByKey.has(k)) { + errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`) + continue + } + entryByKey.set(k, e) +} + +// 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block). +for (const b of blocks) { + if (!entryByKey.has(keyOf(b))) { + errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`) + } +} +for (const e of entries) { + if (!blockByKey.has(keyOf(e))) { + errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`) + } +} + +// Verbatim check: each matched block must equal its source declaration. +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) + 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))) { + 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))}`, + ) + continue + } + verified++ +} + +if (errors.length === 0) { + console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`) + process.exit(0) +} + +console.error('verify-type-equiv: type-equiv verification failed:') +for (const e of errors) console.error(` ${e}`) +console.error(`\n(checked ${blocks.length} block(s) across ${docFiles.map(d => relative(root, resolve(root, d))).length} doc(s); manifest at scripts/type-equiv.manifest.json)`) +process.exit(1)