1
0
Эх сурвалжийг харах

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.
Tianyi Cui 3 сар өмнө
parent
commit
07048983e0

+ 2 - 1
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",

+ 39 - 15
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 {

+ 41 - 0
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" }
+  ]
+}

+ 208 - 0
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<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)