build(doc-sync): add verify-type-equiv gate for verbatim type pastes
Introduce a `ts type-equiv` Markdown fence: a verbatim paste of a source type definition that `scripts/verify-type-equiv.ts` drift-checks against the source symbol via the TypeScript parser, with provenance in a central `scripts/type-equiv.manifest.json` kept 1:1 with the blocks. doc-typecheck recognizes the same fence, skips compiling it (not standalone-compilable), and excludes it from the opt-out ratio. Wired into the `doc-sync` chain.
This commit is contained in:
@@ -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 {
|
||||
|
||||
41
scripts/type-equiv.manifest.json
Normal file
41
scripts/type-equiv.manifest.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
208
scripts/verify-type-equiv.ts
Normal file
208
scripts/verify-type-equiv.ts
Normal file
@@ -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<string, EquivBlock>()
|
||||
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<string, ManifestEntry>()
|
||||
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)
|
||||
Reference in New Issue
Block a user