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.
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`
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
+}