From f6d9574582ecda117ac1b88900ac44e83168fc60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:50:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20=E5=90=8C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E8=A3=B8=E6=BA=90=E7=A0=81=E8=B7=AF=E5=BE=84=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20fromCategory=20=E8=A7=A3=E6=9E=90,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BB=A5=E7=BA=AF=E6=96=87=E6=9C=AC=E8=90=BD=E5=9C=B0=20(#6484?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file-description.ts` 的 bare-path 改写步骤与 `build-docs.ts` 的 `sourcePathToDocsRoute()` 两侧都要求路径里至少有一个目录段,于是作者写 `auth.zod.ts`(与自己同目录)时两侧都匹配不上 —— 既不成链接,也不回退成 代码段,以纯文本落在页面上。 缺的不是正则而是上下文:`build-docs.ts` 按分类遍历、自己知道正在渲染哪个 目录,却只把 `sourcePathToDocsRoute` 一个成员交给渲染方。 - `FileDescriptionContext` 增加 `fromCategory`(必填),由 `build-docs.ts` 传入;裸文件名在渲染方补全为 `<分类>/<文件>` 后再交给解析器。补全放在 调用方一侧是有意的 —— 裸名不是身份(#4696),全分类搜同名文件会撞。 三个引用位置(两种 `{@link}` 与裸散文)共用同一条补全规则。 - 改写正则的目录段变为可选:`(?:[\w-]+\/)?`。用 `?` 而非 `*`,放宽严格可加 —— 嵌套源码仍从最后两段开始匹配,形状不变。 - `sourcePathToDocsRoute()` 补上它文档里一直声明、实现却没做的那一半: 分类为真不等于页面存在。放宽后有 4 个邻居根本不存在,旧实现会各发一条 404 链接;现在按本次运行真正发出的页面清单判断。为此把「schema 归页」的 分组提前到 `PAGES_BY_CATEGORY` 统一算一次,§2 与解析器读同一份,不做第二次枚举。 测试:`file-description.test.ts` 新增 10 条单测 + 1 条语料断言,含反空过守卫 (同一路径带分类段仍成链接)与 `../` 组合方向。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o --- .changeset/docs-gen-same-dir-bare-path.md | 31 ++ packages/spec/scripts/build-docs.ts | 112 +++++-- .../spec/scripts/file-description.test.ts | 288 +++++++++++++++++- packages/spec/scripts/lib/file-description.ts | 68 ++++- 4 files changed, 457 insertions(+), 42 deletions(-) create mode 100644 .changeset/docs-gen-same-dir-bare-path.md diff --git a/.changeset/docs-gen-same-dir-bare-path.md b/.changeset/docs-gen-same-dir-bare-path.md new file mode 100644 index 0000000000..1101459d55 --- /dev/null +++ b/.changeset/docs-gen-same-dir-bare-path.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 参考页里写在同目录的裸源码路径不再以纯文本落地 (#6484) + +参考页开篇那段模块描述由 `packages/spec/scripts/lib/file-description.ts` 渲染。它把 +JSDoc 里裸写的 `*.zod.ts` 路径改写成站内链接,而这条机制的**两侧**过去都要求路径里 +至少有一个目录段:改写正则的 `[\w-]+/` 分组是必需的,`build-docs.ts` 的 +`sourcePathToDocsRoute()` 也要求那个斜杠、并把第一段读作分类名。 + +于是作者按最自然的方式引用邻居 —— 写 `auth.zod.ts` 而不是 `identity/auth.zod.ts` —— +两侧都匹配不上,既没成链接,也没回退成代码段,以**纯文本**发布在四张参考页上,共 9 处: +`api/realtime-shared`、`cloud/package`、`identity/identity`、`system/security-context`。 + +缺的从来不是正则,而是**上下文**:`build-docs.ts` 按分类遍历,自己知道正在渲染哪个目录, +却只把一个成员交给渲染方。现在 `FileDescriptionContext` 增加 `fromCategory`,由 +`build-docs.ts` 传入,裸文件名在渲染方补全成 `<分类>/<文件>` 后再去解析 —— 与 +`schemaHrefFrom(fromCategory)` 是同一道缝。补全放在调用方一侧是有意的:裸名不是身份 +(#4696),`auth.zod.ts` 在多个分类下都存在,让解析器自己去全分类搜同名文件只会答出 +目录遍历最后到达的那一个。 + +读者可见的变化是这 9 处:**5 处成为可点链接**(`api/realtime`、`api/websocket`、 +`cloud/package-version`、`cloud/environment-package`、`system/encryption`),**4 处回退成 +代码段**(`auth`、`audit`、`compliance`、`masking` —— 这四个邻居本就不存在,按 #6229 +的规矩「目标没有页面就不发链接」)。纯文本是三种结果里唯一错的那种,现在一处不剩。 + +`sourcePathToDocsRoute()` 同时补上了它文档里一直声明、实现却没做的那一半:分类是真的 +不等于页面存在。旧实现只校验分类,这在放宽之前侥幸成立(能匹配上的路径恰好都有页面); +放宽后那 4 个不存在的邻居会各产出一条 404 链接。现在按本次运行真正发出的页面清单判断, +全语料 216 条站内路由、437 个位置,无死链。 diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index a6a8f1d3e3..bd497e3949 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -280,16 +280,77 @@ function schemaHrefFrom(fromCategory: string): (name: string) => string | null { } +/** + * Every page this run publishes: `category` -> page slug -> the schemas that + * page documents. + * + * Grouped ONCE and read twice — by §2 below, which emits the pages, and by + * `sourcePathToDocsRoute`, which has to answer "is there a page for this file?" + * while §2 is still part-way through the categories. Neither of the two obvious + * shortcuts can answer it: asking the sink (`wasEmitted`) makes the reply depend + * on which category the walk reached first, and asking the disk makes a run's + * output depend on the previous run's, so a deleted page would keep resolving + * until someone regenerated twice. + */ +function groupSchemasByPage(): Map>> { + const byCategory = new Map>>(); + + for (const category of Object.keys(CATEGORIES)) { + const categorySchemaDir = path.join(SCHEMA_DIR, category); + if (!fs.existsSync(categorySchemaDir)) { + console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`); + continue; + } + + const pages = new Map>(); + for (const file of fs.readdirSync(categorySchemaDir).filter(f => f.endsWith('.json'))) { + const schemaName = file.replace('.json', ''); + const content = JSON.parse(fs.readFileSync(path.join(categorySchemaDir, file), 'utf-8')); + // Category-scoped: the page is owned by the file in THIS category that puts + // the name on its export surface — declaration or re-export. `misc` stays + // the catch-all for a published schema no `.zod.ts` here accounts for + // (`security/*` declares two in plain `.ts` files), and it is honest about + // it: `sourcePathFor` finds no file, so the page prints no "Source:" line. + const zodFile = schemaIndex.pageFor(category, schemaName) || 'misc'; + + if (!pages.has(zodFile)) pages.set(zodFile, []); + pages.get(zodFile)!.push({ name: schemaName, content }); + } + + byCategory.set(category, pages); + } + + return byCategory; +} + /** * Rewrite a source path referenced from JSDoc (`../automation/sync.zod.ts`) to * the docs route that renders it. Without this the generated page links to a * path that only exists in the repo, i.e. a 404 on the site. + * + * Always given a path WITH a category segment: `lib/file-description.ts` + * completes a same-directory spelling from its `fromCategory` before calling in, + * precisely so this stays the `/` lookup #4696 settled on and + * never has to guess which `auth.zod.ts` an author meant. */ function sourcePathToDocsRoute(target: string): string | null { const m = target.match(/(?:^|\/)([\w-]+)\/([\w.-]+)\.zod\.ts$/); if (!m) return null; const [, category, zodFile] = m; if (!CATEGORIES[category]) return null; + // A real category is not yet a page. This used to be the whole test, which + // was survivable only because every path the old regex could match happened + // to name a file with a page behind it. #6484 widened what reaches here to + // include same-directory spellings, and FOUR of the nine name a neighbour + // that does not exist at all — `identity/auth`, `system/audit`, + // `system/compliance`, `system/masking`, all four long since removed. Under + // the old test each would have become a confident link to a 404 (measured: + // deleting this line puts exactly those four dead routes into the artifact). + // + // File existence is not the test either: seven `.zod.ts` sources publish no + // page at all, their schemas being unrepresentable in JSON Schema. The test + // is whether THIS run emits the page, which is what the map knows. + if (!PAGES_BY_CATEGORY.get(category)?.has(zodFile)) return null; return `/docs/references/${category}/${zodFile}`; } @@ -420,7 +481,14 @@ 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 = renderFileDescription(fs.readFileSync(sourcePath, 'utf-8'), { sourcePathToDocsRoute }); + // `category` is what a path written relative to the module's own + // directory is relative TO — without it the renderer cannot tell which + // `auth.zod.ts` a neighbour reference means, and until #6484 it was never + // told, so those references shipped as plain prose. + fileDesc = renderFileDescription(fs.readFileSync(sourcePath, 'utf-8'), { + fromCategory: category, + sourcePathToDocsRoute, + }); } let md = `---\n`; @@ -699,6 +767,15 @@ function deadDocLinks(mdx: string): string[] { console.log('Building documentation...'); +/** + * The page inventory, built before anything is rendered. + * + * It has to exist before the first `renderFileDescription` call, because that + * is where `sourcePathToDocsRoute` is asked whether a referenced neighbour has + * a page — an answer no partially-filled sink could give. + */ +const PAGES_BY_CATEGORY = groupSchemasByPage(); + /** Categories that had schemas to regenerate from — drives the flush() guard. */ let managedCount = 0; @@ -734,34 +811,11 @@ if (fs.existsSync(DOCS_ROOT)) { // But verify we don't kill the manual files. } -Object.keys(CATEGORIES).forEach(category => { - const categorySchemaDir = path.join(SCHEMA_DIR, category); - - if (!fs.existsSync(categorySchemaDir)) { - console.log(`Warning: Schema directory ${categorySchemaDir} does not exist`); - return; - } - - const files = fs.readdirSync(categorySchemaDir).filter(f => f.endsWith('.json')); - const zodFileSchemas = new Map>(); - - files.forEach(file => { - const schemaName = file.replace('.json', ''); - const schemaPath = path.join(categorySchemaDir, file); - const content = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); - // Category-scoped: the page is owned by the file in THIS category that puts - // the name on its export surface — declaration or re-export. `misc` stays - // the catch-all for a published schema no `.zod.ts` here accounts for - // (`security/*` declares two in plain `.ts` files), and it is honest about - // it: `sourcePathFor` finds no file, so the page prints no "Source:" line. - const zodFile = schemaIndex.pageFor(category, schemaName) || 'misc'; - - if (!zodFileSchemas.has(zodFile)) { - zodFileSchemas.set(zodFile, []); - } - zodFileSchemas.get(zodFile)!.push({ name: schemaName, content }); - }); - +// The grouping is `PAGES_BY_CATEGORY`'s, not a second one computed here: the +// page a schema lands on decides both what this loop writes and what +// `sourcePathToDocsRoute` calls a live route, and two enumerations of that could +// disagree — the same discipline §2.6 already applies to the root index. +PAGES_BY_CATEGORY.forEach((zodFileSchemas, category) => { const categoryDir = path.join(DOCS_ROOT, category); // Generate file diff --git a/packages/spec/scripts/file-description.test.ts b/packages/spec/scripts/file-description.test.ts index 09a5b9c646..144a2b0f6a 100644 --- a/packages/spec/scripts/file-description.test.ts +++ b/packages/spec/scripts/file-description.test.ts @@ -210,7 +210,12 @@ describe('findModuleDocBlock — a block documents a symbol, or it documents the }); describe('renderFileDescription', () => { - const ctx = { sourcePathToDocsRoute: (t: string) => (t.includes('sync') ? '/docs/references/automation/sync' : null) }; + // `fromCategory` is the directory the rendered module lives in (#6484); these + // cases reference `automation/` and are written as if from there. + const ctx = { + fromCategory: 'automation', + 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"; @@ -249,7 +254,10 @@ describe('renderFileDescription', () => { * published pages. */ describe('renderFileDescription — #5553: line layout is content, not decoration', () => { - const ctx = { sourcePathToDocsRoute: () => null }; + // Nothing resolves here — these cases are about line layout, not routes — so + // every path they contain takes the code-span fallback whatever `fromCategory` + // says. + const ctx = { fromCategory: 'data', sourcePathToDocsRoute: () => null }; it('keeps an inline code span that wraps across two source lines', () => { // `automation/flow-function.zod.ts:13-15`, reduced — the example the issue @@ -403,6 +411,8 @@ describe('renderFileDescription — #5553: line layout is content, not decoratio */ describe('renderFileDescription — #6136: the bare-path rewriter skips formed links', () => { const ctx = { + // The verbatim inputs below come from `automation/etl.zod.ts` (#6484). + fromCategory: 'automation', sourcePathToDocsRoute: (t: string) => /integration\/connector\.zod\.ts$/.test(t) ? '/docs/references/integration/connector' : null, }; @@ -488,6 +498,11 @@ describe('renderFileDescription — #6229: a bare path keeps its `../` prefix in // `(?:^|/)` head, so a `../` prefix on the way IN already resolves to the // same page. The defect was never in route resolution — only in how much // of the path the rewriter handed it. + // + // Written as if from `api/` (#6484) — the first verbatim input below is + // `api/http-cache.zod.ts`. Every path here carries its own category, so + // none of them is completed and the value only has to be honest. + fromCategory: 'api', sourcePathToDocsRoute: (t: string) => { const m = /(?:^|\/)(system|api)\/([\w-]+)\.zod\.ts$/.exec(t); return m ? `/docs/references/${m[1]}/${m[2]}` : null; @@ -587,6 +602,11 @@ describe('renderFileDescription — #6420: a bare path in parentheses still link // Mirrors `build-docs.ts`'s `sourcePathToDocsRoute`, restricted to the two // categories these cases name, so an unroutable path is genuinely // unroutable rather than a stand-in that resolves everything. + // + // Written as if from `automation/` (#6484), which is where the first + // verbatim input below lives. Every path here is category-qualified, so + // none is completed. + fromCategory: 'automation', sourcePathToDocsRoute: (t: string) => { const m = /(?:^|\/)(integration|automation)\/([\w-]+)\.zod\.ts$/.exec(t); return m ? `/docs/references/${m[1]}/${m[2]}` : null; @@ -659,6 +679,198 @@ describe('renderFileDescription — #6420: a bare path in parentheses still link }); }); +/** + * #6484 — a path written relative to the module's OWN directory. + * + * Both halves of the mechanism used to require a directory segment: the + * rewriter's `[\w-]+/` group was mandatory, and `sourcePathToDocsRoute` read + * the segment before the slash as the category. So a module referring to a + * neighbour the way authors actually write it — `auth.zod.ts`, not + * `identity/auth.zod.ts` — matched nothing on either side and fell through as + * plain prose. Not a link, and not the code-span fallback either: nine such + * references on four published pages (`api/realtime-shared:19,21`, + * `cloud/package:17,18`, `identity/identity:13`, + * `system/security-context:14,16,17,18`), which is the one outcome of the three + * that is simply wrong. + * + * The missing input was never the regex, it was the context: `build-docs.ts` + * iterates BY CATEGORY and knows exactly which directory it is rendering, and + * handed `renderFileDescription` a context object with one member that did not + * include it. `fromCategory` is that member, and the completion happens on this + * side of the seam on purpose — a bare filename is not an identity (#4696), so + * a resolver that searched every category for one would answer with whichever + * the directory walk reached last. `auth.zod.ts` below is exactly that + * collision, pinned in both directions. + * + * MEASURED (reverse verification), the ordinary direction: reverting either + * half — the optional `(?:[\w-]+\/)?` group, or the `completeFromCategory` + * call — turns every same-directory case here red and leaves every + * category-qualified case green, which is what the vacuity guard exists to make + * meaningful. + * + * Corpus-wide the widening is exactly those nine positions and nothing else + * (`gen:docs` on the fixed generator: 231 files, 4 changed, 9 lines): five + * become links, four become code spans, none stays plain text. + */ +describe('renderFileDescription — #6484: a same-directory path resolves against its own category', () => { + /** + * Which pages exist, per category — an explicit set rather than "any file + * under a real category". + * + * That second condition is the whole reason this stand-in is not a one-line + * regex: `build-docs.ts` used to accept any file name under a real category, + * which was survivable only while every path the old rewriter could match + * happened to have a page behind it. Four of the nine references this issue + * measured name a neighbour that does not exist at all (`identity/auth`, + * `system/audit`, `system/compliance`, `system/masking`), so a stand-in that + * resolved them would let a dead link pass for a fix. + */ + const PAGES: Record = { + api: ['auth', 'realtime', 'realtime-shared', 'websocket'], + cloud: ['environment-package', 'package', 'package-version'], + identity: ['identity', 'organization'], + system: ['cache', 'encryption', 'security-context'], + }; + + // Mirrors `build-docs.ts`'s `sourcePathToDocsRoute`, both conditions: a real + // category AND a page this run publishes. + const sourcePathToDocsRoute = (t: string) => { + const m = /(?:^|\/)([\w-]+)\/([\w.-]+)\.zod\.ts$/.exec(t); + return m && PAGES[m[1]]?.includes(m[2]) ? `/docs/references/${m[1]}/${m[2]}` : null; + }; + + const describedBy = (fromCategory: string, line: string) => + renderFileDescription( + ['/**', ` * ${line}`, ' */', '', "import { z } from 'zod';", ''].join('\n'), + { fromCategory, sourcePathToDocsRoute }, + ); + + it('links a same-directory path whose page exists — the published `api/realtime-shared` line', () => { + // `packages/spec/src/api/realtime-shared.zod.ts:17` verbatim — the exact + // input behind `content/docs/references/api/realtime-shared.mdx:19`, which + // published the file name as prose. + expect(describedBy('api', '@see realtime.zod.ts for transport-layer configuration')).toBe( + 'See also: [realtime.zod.ts](/docs/references/api/realtime) for transport-layer configuration', + ); + }); + + it('links a same-directory path mid-sentence — the published `cloud/package` line', () => { + // `packages/spec/src/cloud/package.zod.ts:15` verbatim. A different + // position from the `@see` case above (inside a list item, in ordinary + // parentheses), so neither published page can regress on its own. + expect( + describedBy('cloud', '- `sys_package_version` — immutable release snapshots (see package-version.zod.ts)'), + ).toBe( + '- `sys_package_version` — immutable release snapshots (see [package-version.zod.ts](/docs/references/cloud/package-version))', + ); + }); + + it('prints a same-directory path with NO page as code, never as a dead link', () => { + // `packages/spec/src/identity/identity.zod.ts:11` verbatim. There is no + // `packages/spec/src/identity/auth.zod.ts` and no `identity/auth` page, so + // #6229's rule decides this: no page, no link. Widening the match without + // this arm would have published a confident link to a 404 — strictly worse + // than the plain text it replaces. + expect( + describedBy('identity', 'This is separate from authentication configuration (auth.zod.ts) which'), + ).toBe('This is separate from authentication configuration (`auth.zod.ts`) which'); + }); + + it('resolves the SAME bare name differently in a different category (#4696)', () => { + // The pair that rules out searching every category for a bare filename, and + // the reason the completion lives on the caller's side of the seam. `auth` + // is a real page under `api` and no page at all under `identity`: a + // resolver handed the bare name alone could only answer one of these two + // correctly, and which one would depend on directory-walk order. + expect(describedBy('api', 'Configuration lives in auth.zod.ts today.')).toBe( + 'Configuration lives in [auth.zod.ts](/docs/references/api/auth) today.', + ); + expect(describedBy('identity', 'Configuration lives in auth.zod.ts today.')).toBe( + 'Configuration lives in `auth.zod.ts` today.', + ); + }); + + it('renders both outcomes from one authored list — the `system/security-context` lines', () => { + // `packages/spec/src/system/security-context.zod.ts:16,18` verbatim, the + // two neighbouring bullets of one list: `audit` was removed and has no + // page, `encryption` survives and has one. Same shape, same line layout, + // opposite verdicts — which is what "no occurrence stays plain text" means + // in practice, as against "all nine become links". + expect( + describedBy('system', '- **Audit** (audit.zod.ts — REMOVED): the live audit path is plugin-audit’s'), + ).toBe('- **Audit** (`audit.zod.ts` — REMOVED): the live audit path is plugin-audit’s'); + expect( + describedBy('system', '- **Encryption** (encryption.zod.ts): Field-level encryption and key management'), + ).toBe( + '- **Encryption** ([encryption.zod.ts](/docs/references/system/encryption)): Field-level encryption and key management', + ); + }); + + it('completes a bare `{@link}` target the same way — one rule for every position', () => { + // The tag form and the bare-prose form resolve through the same helper. A + // relative spelling means the same file whichever one it is written in, so + // completing only the prose form would make the shape of the tag decide + // whether a neighbour resolves. + expect(describedBy('api', 'See {@link realtime.zod.ts} for the transport.')).toBe( + 'See [realtime.zod.ts](/docs/references/api/realtime) for the transport.', + ); + expect(describedBy('identity', 'See {@link auth.zod.ts} for the configuration.')).toBe( + 'See `auth.zod.ts` for the configuration.', + ); + }); + + it('does NOT complete a `../` spelling — that prefix leaves the category', () => { + // Composition with #6229, and deliberately not symmetric with it. `../` out + // of a category directory lands on `packages/spec/src/`, which publishes no + // pages, so completing `../auth.zod.ts` with `identity` would invent a + // reference the author did not write. It resolves to nothing and prints as + // code — still an improvement, since before #6484 this shape matched + // nothing at all and shipped as plain text. + expect(describedBy('identity', 'Declared in ../auth.zod.ts for now.')).toBe( + 'Declared in `../auth.zod.ts` for now.', + ); + }); + + it('still keeps a `../../` prefix inside a category-qualified link (#6229 composes)', () => { + // `packages/spec/src/api/http-cache.zod.ts:35` verbatim. The prefixed, + // qualified shape is untouched by this change — asserted, not assumed, + // because both fixes edit the same regex. + expect(describedBy('api', '@see ../../system/cache.zod.ts for application-level caching')).toBe( + 'See also: [../../system/cache.zod.ts](/docs/references/system/cache) for application-level caching', + ); + }); + + it('still links the same file written WITH its category — the vacuity guard', () => { + // Without this the cases above could all have been satisfied by a `ctx` + // that resolves nothing, written around the code-span fallback, and would + // have proved nothing at all. Pinning the qualified spelling of a file the + // bare cases also name fixes the only variable to the SHAPE of the path. + // + // Both directions of the qualified form: from its own category, and from a + // foreign one. Neither may depend on `fromCategory` — completion applies to + // bare names only, so a qualified path resolves identically wherever it is + // written. + expect(describedBy('api', 'Configuration lives in api/auth.zod.ts today.')).toBe( + 'Configuration lives in [api/auth.zod.ts](/docs/references/api/auth) today.', + ); + expect(describedBy('identity', 'Configuration lives in api/auth.zod.ts today.')).toBe( + 'Configuration lives in [api/auth.zod.ts](/docs/references/api/auth) today.', + ); + }); + + it('keeps the directory group capped at ONE segment — the widening is additive', () => { + // `(?:[\w-]+\/)?`, not `(?:[\w-]+\/)*`. A nested source has always matched + // from its LAST two segments, leaving the outer directory beside the + // construct; that shape is #6229's business, not this issue's, and a + // repeating group would silently change it. Pinned here because the choice + // is otherwise invisible: both spellings pass every other case in this + // block. + expect(describedBy('data', 'Declared in data/driver/postgres.zod.ts for now.')).toBe( + 'Declared in data/`driver/postgres.zod.ts` for now.', + ); + }); +}); + /** * 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 @@ -771,11 +983,34 @@ describe('corpus — every rendered description is well-formed markdown', () => const categories = new Set( fs.readdirSync(SRC_DIR, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name), ); - const ctx = { - sourcePathToDocsRoute: (target: string) => { - const m = target.match(/(?:^|\/)([\w-]+)\/([\w.-]+)\.zod\.ts$/); - return m && categories.has(m[1]) ? `/docs/references/${m[1]}/${m[2]}` : null; - }, + + /** + * Which pages the generator publishes, per category — read from the emitted + * tree, which is the same set `check:docs` holds to `packages/spec/src`. + * + * The category check alone is not the generator's rule any more (#6484). + * Seven `.zod.ts` sources publish no page, and four of the neighbours the + * corpus names have no source at all, so a stand-in that resolved every file + * under a real category would hand these assertions link shapes the real run + * never produces. Not circular: nothing below asserts that a route resolves — + * they assert the markdown around it is well-formed and that no path reaches + * a page still bare. + */ + const pages = new Map>(); + for (const category of categories) { + const dir = path.resolve(HERE, '../../../content/docs/references', category); + if (!fs.existsSync(dir)) continue; + pages.set( + category, + new Set(fs.readdirSync(dir).filter(f => f.endsWith('.mdx')).map(f => f.slice(0, -'.mdx'.length))), + ); + } + + const sourcePathToDocsRoute = (target: string) => { + const m = target.match(/(?:^|\/)([\w-]+)\/([\w.-]+)\.zod\.ts$/); + return m && categories.has(m[1]) && pages.get(m[1])?.has(m[2]) + ? `/docs/references/${m[1]}/${m[2]}` + : null; }; const zodFiles: string[] = []; @@ -789,7 +1024,16 @@ describe('corpus — every rendered description is well-formed markdown', () => walk(SRC_DIR); const described = zodFiles - .map(file => ({ rel: path.relative(SRC_DIR, file), out: renderFileDescription(fs.readFileSync(file, 'utf-8'), ctx) })) + .map(file => { + const rel = path.relative(SRC_DIR, file); + // Each source is rendered from ITS OWN category, exactly as + // `generateZodFileMarkdown` does — that is what makes a same-directory + // reference resolvable at all (#6484), and rendering the whole corpus + // from one fixed category would test a context the generator never + // constructs. + const ctx = { fromCategory: rel.split(path.sep)[0], sourcePathToDocsRoute }; + return { rel, out: renderFileDescription(fs.readFileSync(file, 'utf-8'), ctx) }; + }) .filter(d => d.out !== ''); /** The description with fenced code blocks removed. */ @@ -922,6 +1166,34 @@ describe('corpus — every rendered description is well-formed markdown', () => expect(offenders).toEqual([]); }); + it('never leaves a same-directory source path as plain text (#6484)', () => { + // The corpus half of the unit block above, and the issue's acceptance + // criterion stated where it can be re-derived: a `*.zod.ts` reference this + // step can match must reach the page as a link or as a code span, never as + // prose. Deliberately NOT "every one of the nine becomes a link" — four of + // them name a neighbour with no page, and #6229 says those get a code span. + // + // Scanned on the rendered fragment rather than on the emitted `.mdx` for + // the reason the rest of this file is: `check:docs` reproduces the artifact + // faithfully, so all nine published symptoms sailed through it green. + // + // A path is bare when it survives the removal of every formed link and every + // code span. Link TEXT has to go with the link — the fallback the rewriter + // emits is `[]()`, whose text is the path itself, so matching + // inside it would report every fix as a defect. + const offenders: string[] = []; + for (const { rel, out } of described) { + for (const line of withoutFences(out).split('\n')) { + const bare = line + .replace(/\[[^\]]*\]\([^)\s]*\)/g, '') // a formed link, text and destination + .replace(/`[^`]*`/g, ''); // a code span — the null-route fallback + const hit = /(?:\.\.\/)*(?:[\w-]+\/)?[\w.-]+\.zod\.ts/.exec(bare); + if (hit) offenders.push(`${rel}: ${hit[0]}`); + } + } + expect(offenders).toEqual([]); + }); + it('keeps a description for every source that had one — #6134 selection is untouched', () => { // The rendering fix must not remove a page's opening paragraph; that is // #5059's acceptance criterion and it still binds. 185 sources carry a diff --git a/packages/spec/scripts/lib/file-description.ts b/packages/spec/scripts/lib/file-description.ts index 5c36978899..f7457fe5e0 100644 --- a/packages/spec/scripts/lib/file-description.ts +++ b/packages/spec/scripts/lib/file-description.ts @@ -108,14 +108,54 @@ * module-level category maps — the same seam `TypeContext.schemaHref` uses. */ export interface FileDescriptionContext { + /** + * The `packages/spec/src/` category directory the module being rendered lives + * in (`identity`, `system`, …) — i.e. what a path written relative to the + * module's OWN directory is relative TO. + * + * Required, not optional, and that is the point of #6484. A module referring + * to a neighbour writes `auth.zod.ts`, not `identity/auth.zod.ts`, and until + * this member existed the renderer had no way to know which directory that + * name was written in: nine such references on four published pages matched + * nothing and shipped as plain prose — not a link, not even a code span. + * + * The completion happens HERE rather than inside `sourcePathToDocsRoute` + * because a bare filename is not an identity (#4696): `auth.zod.ts` exists + * under more than one category, and a resolver that searched all of them + * would answer with whichever the directory walk reached last. The writer's + * own category is the only thing that disambiguates it, and only the caller + * knows it — the same seam, and the same argument, as `schemaHrefFrom`. + */ + fromCategory: string; + /** * 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). + * + * Always called with a path that HAS a category segment: `fromCategory` + * completes the relative spellings before they get here, so this stays the + * `/.zod.ts` lookup #4696 settled on. */ sourcePathToDocsRoute: (target: string) => string | null; } +/** + * A reference written relative to the module's own directory, completed with + * the category it was written in — `auth.zod.ts` inside `identity/` is + * `identity/auth.zod.ts`. Anything that already carries a directory segment is + * returned untouched. + * + * The test is deliberately "no `/` at all" rather than "does not start with a + * category": `../auth.zod.ts` does NOT get completed, because a `../` from a + * category directory leaves that category, and `packages/spec/src/auth.zod.ts` + * is not a page. It resolves to no route and prints as code, which is the + * honest answer — completing it would invent a page the author never named. + */ +function completeFromCategory(target: string, fromCategory: string): string { + return /^[\w.-]+\.zod\.ts$/.test(target) ? `${fromCategory}/${target}` : target; +} + /** * 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. @@ -357,7 +397,13 @@ function mapProse(text: string, kinds: ProseRun['kind'][], fn: (plain: string) = /** One run of consecutive prose lines, rendered to MDX. */ function renderProse(text: string, ctx: FileDescriptionContext): string { - const { sourcePathToDocsRoute } = ctx; + // ONE resolution rule for every position a path can be referenced from — the + // two `{@link}` forms and the bare-prose form below. A relative spelling means + // the same file whichever of the three it is written in, so completing it in + // one place and not the others would make the shape of the tag decide whether + // a neighbour resolves (#6484). + const routeFor = (target: string) => + ctx.sourcePathToDocsRoute(completeFromCategory(target, ctx.fromCategory)); // A bare `@see ` tag renders as noise — turn it into prose. let out = text.replace(/^@see[ \t]+/gm, 'See also: '); @@ -365,9 +411,9 @@ function renderProse(text: string, ctx: FileDescriptionContext): string { // `{@link}` first, because this is the step that PRODUCES markdown links. out = mapProse(out, ['text'], s => s .replace(/\{@link\s+([^|]+?)\s*\|\s*([^}]+?)\s*\}/g, (_m, target: string, label: string) => - `[${label.trim()}](${sourcePathToDocsRoute(target.trim()) ?? target.trim()})`) + `[${label.trim()}](${routeFor(target.trim()) ?? target.trim()})`) .replace(/\{@link\s+([^}]+?)\s*\}/g, (_m, target: string) => { - const route = sourcePathToDocsRoute(target.trim()); + const route = routeFor(target.trim()); return route ? `[${target.trim()}](${route})` : `\`${target.trim()}\``; })); @@ -399,9 +445,21 @@ function renderProse(text: string, ctx: FileDescriptionContext): string { // (integration/connector.zod.ts) - …` rendered as neither a link nor code, // just plain text, on three published pages. So the guards go and the // tokenizer keeps the invariant they were reaching for. + // + // The directory segment is OPTIONAL (#6484). It used to be mandatory on both + // sides of the mechanism — here and in `sourcePathToDocsRoute` — so a path + // written relative to the module's own directory (`auth.zod.ts` inside + // `identity/`) matched nothing at all and fell through as plain prose. Not a + // link and not even the code-span fallback: nine such references on four + // published pages, the one outcome of the three that is simply wrong. + // + // `?` and not `*`: the group stays capped at one segment so this widening is + // strictly additive. `data/driver/postgres.zod.ts` still matches from + // `driver/`, exactly as before — a two-segment group would swallow `data/` + // too and change a shape this issue is not about. out = mapProse(out, ['text'], s => - s.replace(/((?:\.\.\/)*\b[\w-]+\/[\w.-]+\.zod\.ts)\b/g, (_m, p: string) => { - const route = sourcePathToDocsRoute(p); + s.replace(/((?:\.\.\/)*\b(?:[\w-]+\/)?[\w.-]+\.zod\.ts)\b/g, (_m, p: string) => { + const route = routeFor(p); return route ? `[${p}](${route})` : `\`${p}\``; })); From ffc4bfb84198d0be5ad4da97f5fe3c636f75dae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:09:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(docs):=20regenerate=20references=20?= =?UTF-8?q?=E2=80=94=E2=80=94=209=20=E5=A4=84=E5=90=8C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E8=A3=B8=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=86=8D=E6=98=AF=E7=BA=AF?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20(#6484)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm --filter @objectstack/spec gen:docs` 的纯输出,未手改一个字节。 231 个产物里 4 个文件、9 行变化,与 issue 点名的 9 处完全重合,零附带: - 5 处成为站内链接:api/realtime、api/websocket、cloud/package-version、 cloud/environment-package、system/encryption - 4 处回退成代码段:auth、audit、compliance、masking —— 这四个邻居本就不存在, 按 #6229「目标没有页面就不发链接」 全语料 216 条站内路由 / 437 个位置逐条核过,无死链。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o --- content/docs/references/api/realtime-shared.mdx | 4 ++-- content/docs/references/cloud/package.mdx | 4 ++-- content/docs/references/identity/identity.mdx | 2 +- content/docs/references/system/security-context.mdx | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/content/docs/references/api/realtime-shared.mdx b/content/docs/references/api/realtime-shared.mdx index c62e523242..a0f51ab6f1 100644 --- a/content/docs/references/api/realtime-shared.mdx +++ b/content/docs/references/api/realtime-shared.mdx @@ -16,9 +16,9 @@ realtime protocol (SSE/Polling/WebSocket) and the WebSocket collaboration protoc - `realtime.zod.ts` — Transport-layer protocol (Channel, Subscription, Transport selection) - `websocket.zod.ts` — Collaboration protocol (Cursor, OT editing, Advanced presence) -See also: realtime.zod.ts for transport-layer configuration +See also: [realtime.zod.ts](/docs/references/api/realtime) for transport-layer configuration -See also: websocket.zod.ts for collaborative editing protocol +See also: [websocket.zod.ts](/docs/references/api/websocket) for collaborative editing protocol **Source:** `packages/spec/src/api/realtime-shared.zod.ts` diff --git a/content/docs/references/cloud/package.mdx b/content/docs/references/cloud/package.mdx index 0d850429b6..bf86267bf9 100644 --- a/content/docs/references/cloud/package.mdx +++ b/content/docs/references/cloud/package.mdx @@ -14,8 +14,8 @@ flows, translations, agents — into a named, versioned artifact. Architecture: - `sys_package` — identity (one row per logical package) -- `sys_package_version` — immutable release snapshots (see package-version.zod.ts) -- `sys_package_installation` — env ↔ version pairing (see environment-package.zod.ts) +- `sys_package_version` — immutable release snapshots (see [package-version.zod.ts](/docs/references/cloud/package-version)) +- `sys_package_installation` — env ↔ version pairing (see [environment-package.zod.ts](/docs/references/cloud/environment-package)) See `docs/adr/0003-package-as-first-class-citizen.md` for the full rationale. diff --git a/content/docs/references/identity/identity.mdx b/content/docs/references/identity/identity.mdx index 7340c50ee9..cbd77aead3 100644 --- a/content/docs/references/identity/identity.mdx +++ b/content/docs/references/identity/identity.mdx @@ -10,7 +10,7 @@ Identity & User Model Specification Defines the standard user, account, and session data models for ObjectStack. These schemas represent "who is logged in" and their associated data. -This is separate from authentication configuration (auth.zod.ts) which +This is separate from authentication configuration (`auth.zod.ts`) which defines "how to login". diff --git a/content/docs/references/system/security-context.mdx b/content/docs/references/system/security-context.mdx index 6465bcdd61..4972990414 100644 --- a/content/docs/references/system/security-context.mdx +++ b/content/docs/references/system/security-context.mdx @@ -11,11 +11,11 @@ Provides a central governance layer that correlates and unifies the four independent security subsystems it was designed against. Three of the four have since been REMOVED per ADR-0056 D8 (declared-but-never-enforced; see system/index.ts notes) — only encryption survives, marked experimental: -- **Audit** (audit.zod.ts — REMOVED): the live audit path is plugin-audit's +- **Audit** (`audit.zod.ts` — REMOVED): the live audit path is plugin-audit's always-on capture + object/field `trackHistory` + lifecycle `audit` retention -- **Encryption** (encryption.zod.ts): Field-level encryption and key management -- **Compliance** (compliance.zod.ts — REMOVED): GDPR/HIPAA/SOX/PCI-DSS configs -- **Masking** (masking.zod.ts — REMOVED): PII data masking and tokenization +- **Encryption** ([encryption.zod.ts](/docs/references/system/encryption)): Field-level encryption and key management +- **Compliance** (`compliance.zod.ts` — REMOVED): GDPR/HIPAA/SOX/PCI-DSS configs +- **Masking** (`masking.zod.ts` — REMOVED): PII data masking and tokenization This schema enforces cross-cutting security policies, ensuring compliance frameworks drive encryption requirements, masking rules respect role-based