From cf328fa8c819745f6b99193a4bf8ea313496b7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:41:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(spec):=20=E5=8F=82=E8=80=83=E9=A1=B5?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E5=8F=AA=E8=AE=A4=E3=80=8C=E4=B8=8D=E5=B1=9E?= =?UTF-8?q?=E4=BA=8E=E4=BB=BB=E4=BD=95=E7=AC=A6=E5=8F=B7=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=BA=A7=20doc=20block=20(#5059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getFileDescription()` 取整个文件里第一个 `/** */` 块原样做参考页开篇。 这不是一条关于「描述」的规则,而是一条关于「顺序」的规则:哪个声明碰巧排 在文件最前面,它的注释就被搬上公开文档页。#3746 陷阱 1 早就写明这一点, 而它已经落到 main 上两次,`check:docs` 全程绿 —— 那道门比对的是生成物与 源码是否一致,而生成物确实忠实复制了那个错误的块。 改为按 TSDoc 自己的规则选块(`lib/file-description.ts`):一个 doc block 属于它紧邻其后的那个声明 —— 这正是编辑器 hover 该符号时显示的文本。所以 只有同时满足三条的块才是模块描述:顶层(不在声明体内缩进)、位于首个声明 之前(import / re-export 不算声明)、且其后不紧跟声明。取不到就不输出描述 —— 宁可缺,不要错。 规则本身即门禁:issue 正文提议的「首句模式」检查只能覆盖 history 常量那 一个子类,且只能在发布之后发现;实测的 6 张受害页里有 4 张附近根本没有 history 常量。选块规则从结构上取不到符号注释,整个类别就不可能再发生。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- packages/spec/scripts/build-docs.ts | 36 +- .../spec/scripts/file-description.test.ts | 309 ++++++++++++++++++ packages/spec/scripts/lib/file-description.ts | 191 +++++++++++ 3 files changed, 508 insertions(+), 28 deletions(-) create mode 100644 packages/spec/scripts/file-description.test.ts create mode 100644 packages/spec/scripts/lib/file-description.ts diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 9fa6dd8770..8e0c3ca4cb 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -34,6 +34,7 @@ import { type CategorySurface, } from './lib/docs-import-surface'; import { escapeMdxDescription } from './lib/escape-mdx'; +import { renderFileDescription } from './lib/file-description'; import { anchorFor, formatType, type TypeContext } from './lib/format-type'; import { createSink } from './lib/generated-output'; import { @@ -282,33 +283,12 @@ function sourcePathToDocsRoute(target: string): string | null { return `/docs/references/${category}/${zodFile}`; } -// Extract file-level JSDoc description from source -function getFileDescription(content: string): string { - const match = content.match(/\/\*\*([\s\S]*?)\*\//); - if (match) { - return match[1] - .split('\n') - .map(line => line.replace(/^\s*\*\s?/, '').trim()) - .filter(line => line) - // A bare `@see ` tag renders as noise — turn it into prose. - .map(line => line.replace(/^@see\s+/, 'See also: ')) - .join('\n\n') - .replace(/\{@link\s+([^|]+?)\s*\|\s*([^}]+?)\s*\}/g, (_m, target: string, text: string) => - `[${text.trim()}](${sourcePathToDocsRoute(target.trim()) ?? target.trim()})`) - .replace(/\{@link\s+([^}]+?)\s*\}/g, (_m, target: string) => { - const route = sourcePathToDocsRoute(target.trim()); - return route ? `[${target.trim()}](${route})` : `\`${target.trim()}\``; - }) - // Same for a bare source path left in prose by `See also:` above. - .replace(/(? { - const route = sourcePathToDocsRoute(p); - return route ? `[${p}](${route})` : `\`${p}\``; - }) - .replace(/file:\/\//g, '') // Remove file:// protocol - .replace(/\{/g, '\\{').replace(/\}/g, '\\}') // Escape { } for MDX - } - return ''; -} +// The module description a page opens with — WHICH doc block, and how it +// renders, both live in `lib/file-description.ts` (#5059). It used to be the +// first doc block anywhere in the file, which is a rule about ordering rather +// than about descriptions: six public pages opened with an internal comment +// because a helper happened to sit at the top of the file, and `check:docs` +// could not see it (the artifact reproduced the wrong block faithfully). function generateMarkdown(schemaName: string, schema: any, category: string, zodFile: string) { const defs = schema.definitions || schema.$defs || {}; @@ -409,7 +389,7 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, const sourcePath = sourceRel ? path.join(REPO_ROOT, sourceRel) : undefined; let fileDesc = ''; if (sourcePath && fs.existsSync(sourcePath)) { - fileDesc = getFileDescription(fs.readFileSync(sourcePath, 'utf-8')); + fileDesc = renderFileDescription(fs.readFileSync(sourcePath, 'utf-8'), { sourcePathToDocsRoute }); } let md = `---\n`; diff --git a/packages/spec/scripts/file-description.test.ts b/packages/spec/scripts/file-description.test.ts new file mode 100644 index 0000000000..52e60e65bc --- /dev/null +++ b/packages/spec/scripts/file-description.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for WHICH doc block the reference-docs generator publishes as a page's + * opening paragraph — #5059. + * + * `getFileDescription()` took the first doc block anywhere in the file, + * verbatim. That is a rule about ordering, not about descriptions, so whichever + * declaration happened to sit at the top of a `.zod.ts` donated its comment to a + * public page. It landed on `main` twice with `check:docs` green both times — + * that gate compares the generated page against the source, and the page + * faithfully reproduced the wrong block, so there was no drift to report. + * + * The measurement on the issue (main `4615a18`) found the victim surface is six + * pages, not the two the issue body named, and that four of the six have no + * `#4001` history constant anywhere near them — they are ordinary internal enum + * and shared-type comments that merely sit first. That is why this is fixed as a + * SELECTION rule rather than as the first-sentence pattern gate the issue body + * floated (`#\d{3,}` / `Shared history` / `Until #`): the pattern only + * recognises the history-constant subclass, and only after publication. A rule + * that cannot pick a symbol's comment makes the whole class impossible, so the + * rule IS the gate and this file is its enforcement. + * + * MEASURED (reverse verification), the ordinary direction: restoring the old + * one-line selection (`content.match(/\/\*\*([\s\S]*?)\*\//)?.[1] ?? ''`) in + * place of `findModuleDocBlock` turns the six victim cases and every + * `documents a symbol` case red — each reporting the internal comment it used + * to publish — while the `keeps a real module header` cases stay green, because + * for those two selections agree. That asymmetry is the point: the defect was + * invisible precisely on the inputs everyone had thought to check. + * + * The corpus gate at the end is the part that cannot rot: it re-derives the + * verdict from the real `packages/spec/src` tree, so a future file that puts a + * helper above its schemas cannot quietly re-acquire a wrong page description. + */ + +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import { describe, expect, it } from 'vitest'; + +import { findModuleDocBlock, renderFileDescription } from './lib/file-description'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const SRC_DIR = path.resolve(HERE, '../src'); + +/** First prose line of a selected block, the way a page renders it. */ +const opening = (block: string | null) => + block === null + ? null + : block.split('\n').map(l => l.replace(/^\s*\*\s?/, '').trim()).filter(Boolean)[0] ?? ''; + +describe('findModuleDocBlock — a block documents a symbol, or it documents the module', () => { + it('rejects a block attached to a private helper const (#4001 history constants)', () => { + // `data/mapping.zod.ts` and `system/translation.zod.ts`, reduced. The page + // opened with "Shared history for this file (#4001)." for two releases. + const source = [ + "// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.", + '', + "import { z } from 'zod';", + '', + '/**', + ' * Shared history for this file (#4001).', + ' */', + "const MAPPING_HISTORY = 'Until #4001 closed this shape these were dropped silently';", + '', + '/** Import mapping. */', + 'export const MappingSchema = z.object({});', + '', + ].join('\n'); + expect(findModuleDocBlock(source)).toBeNull(); + }); + + it('rejects a block attached to an exported schema (`Transport Protocol Enum`)', () => { + const source = [ + "import { z } from 'zod';", + '', + '/**', + ' * Transport Protocol Enum', + ' * Defines the communication protocol for realtime data synchronization', + ' */', + "export const TransportProtocol = z.enum(['websocket', 'sse', 'polling']);", + '', + ].join('\n'); + expect(findModuleDocBlock(source)).toBeNull(); + }); + + it('rejects a block nested inside a declaration body (a property doc)', () => { + // `api/contract.zod.ts`: the page opened with `ApiErrorSchema.code`'s doc. + const source = [ + "import { z } from 'zod';", + '', + 'export const ApiErrorSchema = z.object({', + ' /**', + ' * Machine-readable semantic code (ADR-0112).', + ' */', + ' code: z.string(),', + '});', + '', + ].join('\n'); + expect(findModuleDocBlock(source)).toBeNull(); + }); + + it('rejects a block that arrives after the first declaration, however good it is', () => { + // `api/protocol.zod.ts` carries a real module header — 12 lines BELOW the + // first schema. 宁可缺,不要错: a header is a header by position, and a + // generator that hunts for prose anywhere in the file is the defect. + const source = [ + "import { z } from 'zod';", + '', + 'export const FirstSchema = z.object({});', + '', + '/**', + ' * ObjectStack Protocol - Zod Schema Definitions', + ' */', + '', + '// banner', + '', + ].join('\n'); + expect(findModuleDocBlock(source)).toBeNull(); + }); + + it('keeps a module header written above the imports', () => { + const source = [ + "// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.", + '', + '/**', + ' * @module ui/sharing', + ' *', + ' * Sharing & Embedding Protocol', + ' */', + '', + "import { z } from 'zod';", + '', + '/** Sharing config. */', + 'export const SharingConfigSchema = z.object({});', + '', + ].join('\n'); + expect(opening(findModuleDocBlock(source))).toBe('@module ui/sharing'); + }); + + it('keeps a module header written below the imports and a re-export', () => { + // `api/websocket.zod.ts`. Re-exports declare no symbol of their own, so + // they do not close the header zone — the banner after the block does the + // separating, and the schema below has its own JSDoc. + const source = [ + "import { z } from 'zod';", + "import { PresenceStatus } from './realtime-shared.zod';", + '', + "export { PresenceStatus } from './realtime-shared.zod';", + '', + '/**', + ' * WebSocket Event Protocol', + ' */', + '', + '// ==========================================', + '// Message Types', + '// ==========================================', + '', + '/** WebSocket Message Type Enum */', + "export const WebSocketMessageType = z.enum(['ping']);", + '', + ].join('\n'); + expect(opening(findModuleDocBlock(source))).toBe('WebSocket Event Protocol'); + }); + + it('keeps a module header separated from the first schema by a section banner', () => { + // `system/settings-manifest.zod.ts`, `api/analytics.zod.ts`. A banner between + // the block and the declaration is where the preamble ends — nobody writes + // one between a JSDoc and the symbol it documents. + const source = [ + "import { z } from 'zod';", + '', + '/**', + ' * Settings Manifest Protocol', + ' */', + '', + '// ---------------------------------------------------------------------', + '// Specifier types', + '// ---------------------------------------------------------------------', + '', + 'export const SettingsManifestSchema = z.object({});', + '', + ].join('\n'); + expect(opening(findModuleDocBlock(source))).toBe('Settings Manifest Protocol'); + }); + + it('keeps a header the lazify codemod separated from the imports', () => { + // `scripts/lazify-schemas.ts` inserts its import after the leading run of + // comments and imports — and that run swallows a doc block, so a header can + // end up with imports on both sides. It is still a header. + const source = [ + "import { z } from 'zod';", + '', + '/**', + ' * Analytics API Protocol', + ' */', + '', + "import { lazySchema } from '../shared/lazy-schema';", + 'export const AnalyticsEndpoint = z.enum([]);', + '', + ].join('\n'); + expect(opening(findModuleDocBlock(source))).toBe('Analytics API Protocol'); + }); + + it('returns null rather than guessing when a file has no doc block at all', () => { + expect(findModuleDocBlock("import { z } from 'zod';\nexport const A = z.string();\n")).toBeNull(); + }); +}); + +describe('renderFileDescription', () => { + const ctx = { sourcePathToDocsRoute: (t: string) => (t.includes('sync') ? '/docs/references/automation/sync' : null) }; + + it('renders nothing when the module has no description', () => { + const source = "import { z } from 'zod';\n\n/** Sort direction. */\nexport const S = z.string();\n"; + expect(renderFileDescription(source, ctx)).toBe(''); + }); + + it('still resolves `@link` targets and escapes braces in a real header', () => { + // Rendering is unchanged by #5059 — only the block SELECTION moved. These + // two assertions exist so the extraction is provably behaviour-preserving. + // + // The `{@link }` (untitled) form is deliberately NOT asserted here: + // the untitled branch emits `[path](route)` and the bare-source-path + // rewriter two lines below then matches the path INSIDE the link text and + // wraps it again, so the published output is a link nested in a link. That + // is a pre-existing defect of the rendering chain, live on `main` in + // `automation/etl.mdx:54` and `integration/connector.mdx:102`; filed + // separately rather than pinned here, because pinning it would ratify it. + const source = [ + '/**', + ' * Header referencing {@link ../automation/sync.zod.ts | the sync protocol}', + ' * and a literal \\{ brace \\}.', + ' */', + '', + "import { z } from 'zod';", + '', + ].join('\n'); + const out = renderFileDescription(source, ctx); + expect(out).toContain('[the sync protocol](/docs/references/automation/sync)'); + expect(out).toContain('\\{'); + }); +}); + +/** + * The corpus half: re-derive the verdict from the real sources, so the six + * pages the issue measured cannot silently re-acquire a wrong opening, and so a + * NEW file that puts a helper above its schemas is caught here rather than on + * the published site. + */ +describe('corpus — no reference source donates a symbol comment to its page', () => { + const zodFiles: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.name.endsWith('.zod.ts')) zodFiles.push(p); + } + }; + walk(SRC_DIR); + + it('finds sources to check', () => { + expect(zodFiles.length).toBeGreaterThan(150); + }); + + it('never selects a block that is immediately followed by a declaration', () => { + const offenders: string[] = []; + for (const file of zodFiles) { + const source = fs.readFileSync(file, 'utf-8'); + const block = findModuleDocBlock(source); + if (block === null) continue; + const marker = `/**${block}*/`; + const at = source.indexOf(marker); + if (at < 0) { offenders.push(`${path.relative(SRC_DIR, file)}: selected block not found verbatim`); continue; } + const after = source.slice(at + marker.length).replace(/^\n/, ''); + const next = after.split('\n').find(l => l.trim() !== '') ?? ''; + if (/^[A-Za-z_$@]/.test(next) && !/^(?:import\b|export\s*(?:\*|\{|type\s*\{))/.test(next)) { + offenders.push(`${path.relative(SRC_DIR, file)} → ${next.slice(0, 60)}`); + } + } + expect(offenders).toEqual([]); + }); + + it('drops the six openings the issue measured, and keeps the module headers beside them', () => { + const openingOf = (rel: string) => + opening(findModuleDocBlock(fs.readFileSync(path.join(SRC_DIR, rel), 'utf-8'))); + + // The six victims — five caught by the issue's strict criterion plus + // `data/mapping`, whose history constant precedes the first export and so + // slipped past it. All six now render no description at all. + for (const rel of [ + 'data/mapping.zod.ts', + 'system/translation.zod.ts', + 'api/contract.zod.ts', + 'api/protocol.zod.ts', + 'api/realtime.zod.ts', + 'kernel/plugin.zod.ts', + ]) { + expect(openingOf(rel), rel).toBeNull(); + } + + // …while files that carry a genuine module header keep it. Without this + // half the rule could "fix" the six by describing nothing at all. + expect(openingOf('system/migration.zod.ts')).toBe('Migration protocol — the two kinds of migration, kept apart on purpose.'); + expect(openingOf('api/websocket.zod.ts')).toBe('WebSocket Event Protocol'); + expect(openingOf('ui/sharing.zod.ts')).toBe('@module ui/sharing'); + }); +}); diff --git a/packages/spec/scripts/lib/file-description.ts b/packages/spec/scripts/lib/file-description.ts new file mode 100644 index 0000000000..6b97e092f9 --- /dev/null +++ b/packages/spec/scripts/lib/file-description.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which doc block of a `*.zod.ts` module is the MODULE's description — the + * prose a reference page opens with — and how that block renders to MDX. + * + * Extracted from `build-docs.ts` (#5059) for the same reason `format-type.ts` + * (#4912) and `escape-mdx.ts` (#5452) were: the generator is a top-level script + * with side effects, so the only way to assert on its block SELECTION used to be + * to run the whole thing and read the emitted `.mdx`. That is how six public + * reference pages came to open with an internal comment — twice on `main`, with + * `check:docs` green throughout, because that gate compares the artifact to the + * source and the artifact faithfully reproduced the wrong block. + * + * ## The rule + * + * The old selection was "the first doc block anywhere in the file, verbatim". + * That is not a rule about module descriptions at all — it is a rule about + * ORDERING, so whichever declaration happens to sit at the top donates its + * comment to a public page. Moving a helper up a file silently rewrote a + * published document, and nothing in the pipeline could see it: a page opened + * with `Shared history for this file (#4001)` (an internal note on a private + * const) or `Transport Protocol Enum` (one enum's doc, on a page documenting + * fourteen schemas). + * + * A block describes the MODULE only when all three hold: + * + * 1. **Top-level** — the delimiter starts at column 0. A comment indented + * inside an object literal documents a property, not a module: `api/contract` + * opened with the doc of `ApiErrorSchema.code`. + * 2. **In the header zone** — no declaration precedes it. Imports and + * re-exports do not close the zone (they introduce no symbol of their own, + * and this repo writes module headers on either side of them); the first + * `const`/`export const`/… does. + * 3. **Documenting nothing** — the block is not immediately followed by a + * declaration. + * + * (3) is the load-bearing one, and it is simply TSDoc's own rule read back: a + * doc block belongs to the declaration it immediately precedes, which is why + * every editor shows that text when you hover the symbol. So a block glued to + * `export const TransportProtocol` is that enum's documentation — publishing it + * as the Realtime page's opening paragraph was the generator inventing a second + * meaning for text that already had one. "Immediately" means blank lines only: + * nobody separates a JSDoc from its symbol with a `// ═══` banner, so a banner + * (or another doc block, or an import) between the two marks the end of the + * preamble rather than an attachment. + * + * When no block qualifies, the module has no description and the page prints + * none. 宁可缺,不要错 — a missing paragraph is a gap the reader can see, while + * a confidently rendered internal note is a page that lies about its subject. + * A module that wants an opening paragraph writes one block that documents no + * symbol; 178 of the 200 reference sources already have exactly that. + * + * This IS the gate. The issue floated a first-sentence pattern check + * (`#\d{3,}` / `Shared history`), but that only recognises one subclass of + * wrong block after it has been published — and the measurement on #5059 found + * four victims with no history constant anywhere near them. A selection rule + * that cannot pick a symbol's comment in the first place makes the whole class + * impossible. Its enforcement is `file-description.test.ts`, which pins the + * selection on the real shapes instead of on the emitted `.mdx`. + */ + +/** + * Context a description needs to turn a source path referenced from JSDoc into + * a link that resolves on the docs site. + * + * Injected rather than imported so this module stays free of the generator's + * module-level category maps — the same seam `TypeContext.schemaHref` uses. + */ +export interface FileDescriptionContext { + /** + * A `*.zod.ts` path as written in JSDoc -> the docs route rendering it, or + * `null` when no page renders it (the reference is then printed as code, + * never as a link that 404s). + */ + sourcePathToDocsRoute: (target: string) => string | null; +} + +/** + * Lines that may sit around the module's doc block without closing the header + * zone. They introduce no symbol, so a block next to them is still a candidate. + */ +const MODULE_PLUMBING = /^(?:import\b|export\s*(?:\*|\{|type\s*\{))/; + +/** + * Does this top-level line start something a doc block above it would be + * documenting? + * + * Deliberately coarse in the safe direction: anything at column 0 opening with + * an identifier character that is not module plumbing counts. The closing + * punctuation of a multi-line statement (`} from './x';`, `]);`) does not open + * with one, and neither does a comment — so the scan walks over those, while an + * unrecognised top-level statement closes the zone instead of being skipped. + */ +function startsDeclaration(line: string): boolean { + return /^[A-Za-z_$@]/.test(line) && !MODULE_PLUMBING.test(line); +} + +/** Index of the line closing the block comment opened at `start`. */ +function endOfBlockComment(lines: readonly string[], start: number): number { + let end = start; + // A one-line block closes on its own line, hence the check starts at `start`. + while (end < lines.length && !lines[end].includes('*/')) end++; + return end; +} + +/** + * First non-blank line after `from`, or `null` at end of file. + * + * ONLY blank lines are skipped, and that is the whole of the attachment rule: a + * doc block documents the declaration it immediately precedes. Anything else + * between them — a section banner, another doc block, an import — means the + * block was written about the module, not about that declaration. Nobody + * separates a JSDoc from the symbol it documents with a `// ═══` banner; that + * banner is where the preamble ends. + */ +function nextNonBlankLine(lines: readonly string[], from: number): number | null { + let i = from; + while (i < lines.length && lines[i].trim() === '') i++; + return i < lines.length ? i : null; +} + +/** + * The module's own doc block, INNER text only (delimiters stripped, `*` line + * prefixes intact) — or `null` when the module does not have one. + * + * See the module comment for the three conditions. This is the whole of the + * fix: expressed as what the generator will select, not as a detector bolted on + * beside it, because a rule that makes the wrong page impossible needs no + * detector. + * + * It is line-oriented rather than AST-based on purpose — `build-docs.ts` reads + * `.zod.ts` sources as text and has no TypeScript program to ask, and every + * shape it cannot resolve resolves to `null`, i.e. to no description. + */ +export function findModuleDocBlock(source: string): string | null { + const lines = source.split('\n'); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (line.startsWith('/**')) { + const end = endOfBlockComment(lines, i); + if (end >= lines.length) return null; // unterminated — nothing to trust + const next = nextNonBlankLine(lines, end + 1); + if (next !== null && startsDeclaration(lines[next])) return null; // documents a symbol + const raw = lines.slice(i, end + 1).join('\n'); + return raw.slice(raw.indexOf('/**') + 3, raw.lastIndexOf('*/')); + } + + if (line.startsWith('/*')) { i = endOfBlockComment(lines, i) + 1; continue; } + if (line.trim() === '' || line.trim().startsWith('//') || !/^\S/.test(line)) { i++; continue; } + if (MODULE_PLUMBING.test(line)) { i++; continue; } + if (startsDeclaration(line)) return null; // header zone closed before any block + + i++; // closing punctuation of a multi-line import / re-export + } + return null; +} + +/** + * The module's doc block, rendered as the MDX fragment a reference page opens + * with. Empty string when the module has no description — callers print nothing + * rather than a placeholder. + */ +export function renderFileDescription(source: string, ctx: FileDescriptionContext): string { + const block = findModuleDocBlock(source); + if (block === null) return ''; + const { sourcePathToDocsRoute } = ctx; + return block + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '').trim()) + .filter(line => line) + // A bare `@see ` tag renders as noise — turn it into prose. + .map(line => line.replace(/^@see\s+/, 'See also: ')) + .join('\n\n') + .replace(/\{@link\s+([^|]+?)\s*\|\s*([^}]+?)\s*\}/g, (_m, target: string, text: string) => + `[${text.trim()}](${sourcePathToDocsRoute(target.trim()) ?? target.trim()})`) + .replace(/\{@link\s+([^}]+?)\s*\}/g, (_m, target: string) => { + const route = sourcePathToDocsRoute(target.trim()); + return route ? `[${target.trim()}](${route})` : `\`${target.trim()}\``; + }) + // Same for a bare source path left in prose by `See also:` above. + .replace(/(? { + const route = sourcePathToDocsRoute(p); + return route ? `[${p}](${route})` : `\`${p}\``; + }) + .replace(/file:\/\//g, '') // Remove file:// protocol + .replace(/\{/g, '\\{').replace(/\}/g, '\\}'); // Escape { } for MDX +} From efc9831ca0d7ac981826a90b7cf2e979af02d394 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:46:55 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(references):=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90=20=E2=80=94=E2=80=94=2020=20=E5=BC=A0?= =?UTF-8?q?=E5=8F=82=E8=80=83=E9=A1=B5=E7=A7=BB=E9=99=A4=E5=B1=9E=E4=BA=8E?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=AC=A6=E5=8F=B7=E7=9A=84=E5=BC=80=E7=AF=87?= =?UTF-8?q?=20(#5059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯生成物提交,`gen:schema && gen:docs` 的输出,284 行删除 / 0 行新增。 每一页移除的都是某个符号的 JSDoc 被当作模块描述发布的那一段;没有任何一页 丢失真正的模块级文件头(178 张带 Source 行的页面描述一字未动)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .../docs/references/ai/solution-blueprint.mdx | 22 --------- content/docs/references/ai/tool.mdx | 8 ---- content/docs/references/api/contract.mdx | 8 ---- .../docs/references/api/error-code-ledger.mdx | 46 ------------------- content/docs/references/api/protocol.mdx | 8 ---- content/docs/references/api/realtime.mdx | 4 -- content/docs/references/api/router.mdx | 4 -- .../docs/references/automation/approval.mdx | 8 ---- .../references/cloud/template-manifest.mdx | 8 ---- content/docs/references/data/driver-mysql.mdx | 20 -------- .../docs/references/data/driver-postgres.mdx | 16 ------- .../docs/references/data/driver-sqlite.mdx | 24 ---------- content/docs/references/data/mapping.mdx | 12 ----- content/docs/references/kernel/manifest.mdx | 20 -------- content/docs/references/kernel/plugin.mdx | 4 -- content/docs/references/shared/enums.mdx | 2 - content/docs/references/system/doc.mdx | 34 -------------- .../docs/references/system/notification.mdx | 18 -------- .../docs/references/system/translation.mdx | 14 ------ content/docs/references/ui/responsive.mdx | 4 -- 20 files changed, 284 deletions(-) diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index ca2d31d23a..23207da57c 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -5,28 +5,6 @@ description: Solution Blueprint protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) - -The structured-output target an AI agent emits for a *high-level* goal - -("build me a project-management system") instead of transcribing a field - -list. It is a **simplified proposal shape** — deliberately lighter than the - -full `ObjectSchema` / `ViewSchema` / `DashboardSchema`. - -The `apply_blueprint` tool expands each entry into a proper metadata body - -and stages it as a draft (so the per-type Zod schema still validates the - -real artifact at write time). - -The blueprint is **never persisted on its own**: the agent presents it for - -conversational confirmation/edit (cheap), and only on human approval does it - -batch-draft. This is the safety valve for low-specificity input. - **Source:** `packages/spec/src/ai/solution-blueprint.zod.ts` diff --git a/content/docs/references/ai/tool.mdx b/content/docs/references/ai/tool.mdx index c7a0266b76..ee03a7668f 100644 --- a/content/docs/references/ai/tool.mdx +++ b/content/docs/references/ai/tool.mdx @@ -5,14 +5,6 @@ description: Tool protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Retired `ToolSchema` keys — the rejection carries the upgrade prescription, - -because the parse error is the one channel every consumer bumping - -`@objectstack/spec` is guaranteed to hit (pattern of `object.zod.ts`'s - -`UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove). - **Source:** `packages/spec/src/ai/tool.zod.ts` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 18995193ca..590b5d07b5 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -5,14 +5,6 @@ description: Contract protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Machine-readable semantic code (ADR-0112): a `StandardErrorCode` member or - -a code registered in `ERROR_CODE_LEDGER`. A closed set on purpose — an - -unregistered code fails parse, so the envelope conformance suites catch - -invented codes instead of letting a new dialect grow (#3841). - **Source:** `packages/spec/src/api/contract.zod.ts` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index e49629d5cb..acba08a468 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -5,52 +5,6 @@ description: Error Code Ledger protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Error-Code Ledger (ADR-0112 D3). - -The top-level `error.code` vocabulary is two-tier: - -1. **Standard catalog** — `StandardErrorCode` (`errors.zod.ts`): a small, - -closed set with platform-wide HTTP semantics. It does NOT grow when a - -service invents a code. - -2. **Registered extension codes** — THIS ledger: every service-specific code - -a route may put in `error.code`, registered under its owning package. - -`ErrorCode` (exported below) is the union, and is what `ApiErrorSchema.code` - -validates against. An unregistered code fails schema parse — which fails the - -envelope conformance suites — which fails CI. That friction is the point - -(ADR-0112: "no silent fourth state" for error codes, per ADR-0049/0078). - -## Registering a new code - -Add it to your package's entry (create the entry if your package has none), - -SCREAMING_SNAKE (`^[A-Z][A-Z0-9_]*$` — lint-enforced by - -`error-code-ledger.test.ts`), with a trailing `//` comment when the name - -alone doesn't carry the meaning. Prefer a domain prefix for anything not - -self-evidently global (`ATTACHMENT_*`, `REPORT_*`, `SETTINGS_*`). If the - -condition is generic (not found / permission / validation / rate limit), - -use the standard catalog instead of registering a synonym. - -A code emitted by several packages is listed once per emitting package — - -the union dedupes; the per-package rows are provenance, not identity. - -Field-level codes (`FieldErrorSchema.code`, the `fields[]` array) are a - -SEPARATE vocabulary and do not belong here — see #3977 (ADR-0112 D6). - **Source:** `packages/spec/src/api/error-code-ledger.zod.ts` diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bf42c05743..e982da9544 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -5,14 +5,6 @@ description: Protocol protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Response for `GET /api/v1/automation/actions` (ADR-0018). - -Returns the live action/node registry — the platform's built-in actions plus - -any plugin-contributed ones — backing the designer palette and flow - -validation. Each entry is a canonical `ActionDescriptorSchema`. - **Source:** `packages/spec/src/api/protocol.zod.ts` diff --git a/content/docs/references/api/realtime.mdx b/content/docs/references/api/realtime.mdx index 9e147b4c9d..66ac1e4f17 100644 --- a/content/docs/references/api/realtime.mdx +++ b/content/docs/references/api/realtime.mdx @@ -5,10 +5,6 @@ description: Realtime protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Transport Protocol Enum - -Defines the communication protocol for realtime data synchronization - **Source:** `packages/spec/src/api/realtime.zod.ts` diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index b3ab8dc7b8..e055585a8a 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -5,10 +5,6 @@ description: Router protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Route Category Enum - -Classifies routes for middleware application and security policies. - **Source:** `packages/spec/src/api/router.zod.ts` diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 1633e04e26..25dbd6bc27 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -5,14 +5,6 @@ description: Approval protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Approval Step Approver Type - -Declaration order is author-facing: designers derive their picker from this - -enum, and it leads with the portable indirect bindings (`manager`, - -`position`, `department`, `team`) — a literal `user` id comes last. - **Source:** `packages/spec/src/automation/approval.zod.ts` diff --git a/content/docs/references/cloud/template-manifest.mdx b/content/docs/references/cloud/template-manifest.mdx index 534471e3d4..5dc2c1d546 100644 --- a/content/docs/references/cloud/template-manifest.mdx +++ b/content/docs/references/cloud/template-manifest.mdx @@ -5,14 +5,6 @@ description: Template Manifest protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -`objectstack.manifest.json` — on-disk descriptor for a template / package - -source tree. Strict projection of `CreatePackageRequestSchema` (server- - -managed fields excluded) plus scaffold-time extras (name slug, - -specVersion, skills, preview, scaffold, readmePath). - **Source:** `packages/spec/src/cloud/template-manifest.zod.ts` diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx index 547bfb2a3d..ee2fb95c38 100644 --- a/content/docs/references/data/driver-mysql.mdx +++ b/content/docs/references/data/driver-mysql.mdx @@ -5,26 +5,6 @@ description: Driver Mysql protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -MySQL / MariaDB driver configuration — the `config` slot of a `datasource` - -whose `driver` resolves to `mysql` (`mysql2`). - -The driver id was offered by the connection form and buildable by the shared - -factory long before #4410, but had no config shape at all in `packages/spec` - -— postgres, mongo and memory each had one and mysql did not, so its `config` - -was the one slot with neither a gate nor a documented shape. - -Every key here is read by `createDefaultDatasourceDriverFactory` - -(→ `SqlDriver`, knex `mysql2`). Postgres-only knobs are deliberately absent: - -`mysql2` has no `application_name` and no `statement_timeout`, so declaring - -them would advertise settings the client drops. - **Source:** `packages/spec/src/data/driver/mysql.zod.ts` diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index b26d4fa439..5a258b5961 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -5,22 +5,6 @@ description: Driver Postgres protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -PostgreSQL driver configuration — the `config` slot of a `datasource` whose - -`driver` resolves to `postgres` (`pg` / `postgresql`). - -ENFORCED as of #4410: `DatasourceSchema` parses `config` against this schema, - -so a misspelled connection key fails at authoring time instead of leaving the - -datasource on the client's localhost defaults. Every key here is read by - -`createDefaultDatasourceDriverFactory` (→ `SqlDriver`, knex `pg`). - -Pool sizing is NOT here: it lives in the driver-agnostic `datasource.pool` - -block, which the factory now honours for every SQL driver. - **Source:** `packages/spec/src/data/driver/postgres.zod.ts` diff --git a/content/docs/references/data/driver-sqlite.mdx b/content/docs/references/data/driver-sqlite.mdx index 4b6c2eeeb1..564044e63e 100644 --- a/content/docs/references/data/driver-sqlite.mdx +++ b/content/docs/references/data/driver-sqlite.mdx @@ -5,30 +5,6 @@ description: Driver Sqlite protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -SQLite driver configuration — the `config` slot of a `datasource` whose - -`driver` resolves to `sqlite` (native `better-sqlite3`, with the dev-only - -step-down to wasm then in-memory, #2229) or to `sqlite-wasm` (pure-JS). - -The one key that matters is `filename`, and it is exactly the key the silent - -strip used to hide: an author who wrote `path:` got no error, the connection - -fell back to `:memory:`, and their data vanished on restart with every signal - -saying the datasource was configured. - -`file` and `database` once also worked, purely because the factory read them - -as undeclared `??` fallbacks. That tolerance has graduated into the declared - -ADR-0087 conversion `datasource-config-driver-key-aliases` (#4456): stored - -rows are rewritten to `filename` at load, the factory reads one spelling, - -and authoring rejects both with the rename hint below. - **Source:** `packages/spec/src/data/driver/sqlite.zod.ts` diff --git a/content/docs/references/data/mapping.mdx b/content/docs/references/data/mapping.mdx index 451b1188ef..57142beb06 100644 --- a/content/docs/references/data/mapping.mdx +++ b/content/docs/references/data/mapping.mdx @@ -5,18 +5,6 @@ description: Mapping protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Shared history for this file (#4001). - -An import mapping is instructions for moving somebody's data. A dropped key - -does not fail the import — it runs, to completion, with a "success" the - -author reads as "the data arrived the way I described it". The rows land - -untransformed, or unmatched, or duplicated, and the diagnosis starts from a - -green run. - **Source:** `packages/spec/src/data/mapping.zod.ts` diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index da975bcc01..280a8fe2de 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -5,26 +5,6 @@ description: Manifest protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Structured permission grants requested by a plugin (ADR-0025 §3.2). - -Each list scopes one capability surface the plugin may touch. The - -install-time consent flow (ADR §3.5 step 2) turns this declaration into - -the persisted `granted_permissions` set enforced at load by the - -PluginPermissionEnforcer. - -@example - -```jsonc - -\{ "services": ["object", "http"], "hooks": ["record.beforeInsert"], - -"network": ["api.acme.com"], "fs": [] \} - -``` - **Source:** `packages/spec/src/kernel/manifest.zod.ts` diff --git a/content/docs/references/kernel/plugin.mdx b/content/docs/references/kernel/plugin.mdx index d9de5aae6c..72298e37fb 100644 --- a/content/docs/references/kernel/plugin.mdx +++ b/content/docs/references/kernel/plugin.mdx @@ -5,10 +5,6 @@ description: Plugin protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Shared Plugin Types - -These are the specialized plugin types common between Manifest (Package) and Plugin (Runtime). - **Source:** `packages/spec/src/kernel/plugin.zod.ts` diff --git a/content/docs/references/shared/enums.mdx b/content/docs/references/shared/enums.mdx index d4d5d63b40..d05c51336f 100644 --- a/content/docs/references/shared/enums.mdx +++ b/content/docs/references/shared/enums.mdx @@ -5,8 +5,6 @@ description: Enums protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Sort direction used across query, data-engine, analytics - **Source:** `packages/spec/src/shared/enums.zod.ts` diff --git a/content/docs/references/system/doc.mdx b/content/docs/references/system/doc.mdx index d66c12f937..18c1cec781 100644 --- a/content/docs/references/system/doc.mdx +++ b/content/docs/references/system/doc.mdx @@ -5,40 +5,6 @@ description: Doc protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Package Documentation Metadata Protocol (ADR-0046) - -One `doc` item per Markdown file under the package's flat `src/docs/` - -directory (no subdirectories — flatness is the contract that keeps - -cross-references stable). The CLI compiles each file into this shape at - -build time; TS-first stacks may also declare items inline via - -`defineStack(\{ docs: [...] \})`. - -Identity model: `name` = filename stem (lowercase snake_case). A namespace - -prefix (`crm_lead_guide`) is a *recommended convention*, no longer required: - -per ADR-0048, single-doc resolution is package-scoped (`getItem('doc', name, - -packageId)` via `?package=` on the detail route), so two packages may ship a - -doc with the same bare name and each resolves within its own package — just - -like `page`/`dashboard`/`report`. The prefix stays useful for readable, - -globally-unique filenames but is not load-bearing for uniqueness. - -Docs are inert data: the kernel registers them without parsing - -`content`, and they participate in no runtime behavior. Renderers - -resolve relative links between docs (`[guide](./crm_lead_guide.md)`) - -by stripping `./` and `.md` to obtain the target doc name. - **Source:** `packages/spec/src/system/doc.zod.ts` diff --git a/content/docs/references/system/notification.mdx b/content/docs/references/system/notification.mdx index 9c3cc4b877..b35ea46b4d 100644 --- a/content/docs/references/system/notification.mdx +++ b/content/docs/references/system/notification.mdx @@ -5,24 +5,6 @@ description: Notification protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Notification Channel Enum - -Supported notification delivery channels. - -⚠️ PARTIALLY ENFORCED — the delivery channels actually registered by - -`service-messaging` are `inbox`, `email`, and `sms` (#3197). `push`, - -`slack`, `teams`, and `webhook` have no delivery implementation, and the - -dispatcher dead-letters any message addressed to an unregistered channel. - -Note also the naming drift: this enum says `in-app` while the implemented - -channel registers as `inbox` (which this enum does not contain) — - -reconcile before wiring this enum into the runtime. - **Source:** `packages/spec/src/system/notification.zod.ts` diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index f4b879ac25..d43139e3e2 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -5,20 +5,6 @@ description: Translation protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Shared history sentence for every shape in this file (#4001). - -Translation data has the most literal version of the silent-strip failure in - -the whole spec: a misspelled group or key is dropped, the bundle saves or - -loads without complaint, and the string it was meant to translate renders in - -the source language. There is no error, no log line, and no difference - -between "not translated yet" and "translated into a key nothing reads" — so - -the bug looks like missing coverage forever. - **Source:** `packages/spec/src/system/translation.zod.ts` diff --git a/content/docs/references/ui/responsive.mdx b/content/docs/references/ui/responsive.mdx index 8a7ba2253f..a558480299 100644 --- a/content/docs/references/ui/responsive.mdx +++ b/content/docs/references/ui/responsive.mdx @@ -5,10 +5,6 @@ description: Responsive protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Breakpoint Name Enum - -Standard Tailwind-style breakpoint names (xs–2xl). - **Source:** `packages/spec/src/ui/responsive.zod.ts` From 33409bcb640d0e9ee41ec6eaeb573e6567035018 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:57:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E9=A1=B5=E6=A8=A1=E5=9D=97=E6=8F=8F=E8=BF=B0=E9=80=89=E5=9D=97?= =?UTF-8?q?=E8=A7=84=E5=88=99=20(#5059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .../docs-gen-module-description-selection.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .changeset/docs-gen-module-description-selection.md diff --git a/.changeset/docs-gen-module-description-selection.md b/.changeset/docs-gen-module-description-selection.md new file mode 100644 index 0000000000..e861dd7f8d --- /dev/null +++ b/.changeset/docs-gen-module-description-selection.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): a reference page's opening paragraph is the module's own doc block, never a symbol's (#5059) + +`getFileDescription()` took the **first** doc block anywhere in a `*.zod.ts` +file, verbatim, and published it as the page's opening paragraph. That is not a +rule about descriptions — it is a rule about *ordering*: whichever declaration +happened to sit at the top of the file donated its comment to a public +document. Adding a helper above the first schema silently rewrote a published +page, and no gate could see it. `check:docs` compares the generated page against +the source and the page reproduced the wrong block faithfully, so there was no +drift to report; the trap was written down when the generator was built and +still landed on `main` twice. + +The measured victim surface was **six pages**. The Translation protocol +reference opened with `Shared history sentence for every shape in this file +(#4001).` — this repo's internal tightening-campaign narrative — and the Mapping +page with its sibling. Four more had no history constant anywhere near them: +`api/contract` published the doc of `ApiErrorSchema.code` (a comment *nested +inside* an object literal), `api/realtime` published `Transport Protocol Enum` +on a page documenting fourteen schemas, and `api/protocol` / `kernel/plugin` the +same shape. Any future "move a helper to the top of the file" makes another. + +The selection now follows **TSDoc's own rule, read back**: a doc block belongs +to the declaration it immediately precedes — which is exactly the text an editor +shows when you hover that symbol. So a module description must be a block that + +- starts at column 0 (a block indented inside a declaration body documents a + property, never a module), +- appears before the first declaration (imports and re-exports introduce no + symbol of their own and do not close the header zone), and +- is not immediately followed by a declaration. + +When no block qualifies the page prints no description at all — 宁可缺,不要错. +A confidently rendered internal note is a page that lies about its subject, +which is worse for a reader (and for an AI author working from these pages) than +a page that opens with its `Source:` pointer. + +**Twenty reference pages lost an opening paragraph**, each of which was a +symbol's JSDoc rather than the module's: the six above plus `ai/solution-blueprint`, +`ai/tool`, `api/error-code-ledger`, `api/router`, `automation/approval`, +`cloud/template-manifest`, `data/driver-mysql`, `data/driver-postgres`, +`data/driver-sqlite`, `kernel/manifest`, `shared/enums`, `system/doc`, +`system/notification`, `ui/responsive`. **No page lost a real module header** — +the other 178 pages with a `Source:` line keep their description byte for byte. +A module that wants its opening paragraph back writes one block that documents +no symbol; 178 sources already do. + +The rule is also the gate. The issue proposed failing on first sentences +matching `#\d{3,}` / `Shared history`, but that recognises only the +history-constant subclass, and only after publication — it would have caught two +of the six. A selection rule that cannot pick a symbol's comment makes the whole +class impossible instead. `scripts/lib/file-description.ts` (extracted from +`build-docs.ts`, following `format-type.ts` #4912 and `escape-mdx.ts` #5452) and +its pin suite `scripts/file-description.test.ts` carry it, corpus check included.