From df3c95adc906978833642c68f77175f90aedfd5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:00:36 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20=E6=AD=A3=E6=96=87=E9=87=8C?= =?UTF-8?q?=E8=A3=B8=E9=9C=B2=E7=9A=84=E6=BA=90=E7=A0=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?,`../`=20=E5=89=8D=E7=BC=80=E5=9B=9E=E5=88=B0=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E9=87=8C=E9=9D=A2=20(#6229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 bare-path 改写步骤的 `\b` 从前缀组**之前**挪到**之后**,并把前缀组由 `?` 放宽成 `*`。 单词边界要求两侧有一个单词字符,而 `../` 三个字符全是非单词字符,所以原来的 `\b` 永远 无法在 `.` 处成立:匹配只能从第一个路径段开始,前缀被丢在它本该进入的链接外面 —— `See also: ../../[system/cache.zod.ts](route)`,发布在 api/http-cache 与 system/cache 两页。 实测修正了最初记录的成因:前缀组不是「只支持一级」,而是**对任何现实输入都不成立** (`../x/y.zod.ts` 与 `../../x/y.zod.ts` 一样丢前缀;唯一能让它生效的是 `x../y/z.zod.ts`)。 因此两个半边都必需且不可互相替代:只放宽 `?`→`*` 是空操作,只挪 `\b` 仍会留下外层一个 `../`。 两条都用回退-跑 pin 逐条量过。 `build-docs.ts` 的 sourcePathToDocsRoute 一行未改 —— 它本就以 `$` 收尾、`(?:^|/)` 起头, 带前缀的路径一直能解析到同一页;缺陷只在交给它多长的一段路径。 重生成差异恰好是上述两页各一行。新增 7 条单元 pin(两级/一级/多级/无前缀/无路由回退/ 不得从词中间起匹配)与 1 条语料级 pin,后者与 #6136 的嵌套链接 pin 并列,直接断言 `../` 不得留在链接或代码段外面。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .../bare-path-parent-prefix-inside-link.md | 51 ++++++++ content/docs/references/api/http-cache.mdx | 2 +- content/docs/references/system/cache.mdx | 2 +- .../spec/scripts/file-description.test.ts | 121 +++++++++++++++++- packages/spec/scripts/lib/file-description.ts | 13 +- 5 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 .changeset/bare-path-parent-prefix-inside-link.md diff --git a/.changeset/bare-path-parent-prefix-inside-link.md b/.changeset/bare-path-parent-prefix-inside-link.md new file mode 100644 index 0000000000..aa7268f3c7 --- /dev/null +++ b/.changeset/bare-path-parent-prefix-inside-link.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 正文里裸露的源码路径,`../` 前缀回到链接里面 (#6229) + +`file-description.ts` 把「正文中裸露的 `*.zod.ts` 路径」改写成链接的那一步,正则以 +`\b((?:\.\./)?…)` 开头。**单词边界要求两侧有一个单词字符,而 `../` 三个字符全是非单词 +字符**,所以那个 `\b` 永远无法在 `.` 处成立:匹配只能从第一个路径段开始,前缀被丢在它 +本该进入的链接**外面**,发布成: + +``` +See also: ../../[system/cache.zod.ts](/docs/references/system/cache) for application-level caching +``` + +`content/docs/references/api/http-cache.mdx` 与 `content/docs/references/system/cache.mdx` +两页,读者看到的是一串裸文本紧挨着一个链接。 + +**实测把最初记录的成因修正了一处。** 原记录说前缀组「只允许一级,而语料用的是两级」。逐个 +输入量过之后:该组对**任何现实输入都不成立**,不是「只支持一级」—— + +| 输入 | 修复前匹配到的 | | +|---|---|---| +| `../../system/cache.zod.ts` | `system/cache.zod.ts` | 前缀漏在外面 | +| `../system/cache.zod.ts` | `system/cache.zod.ts` | **同样漏在外面**,并非原记录所说「本来就正常」 | +| `system/cache.zod.ts` | `system/cache.zod.ts` | 正常 | +| `x../system/cache.zod.ts` | `../system/cache.zod.ts` | 唯一能让该组生效的拼法:点号前有单词字符,没人这么写 | + +于是两个半边**都**是必需的,而且互相不可替代:把 `?` 放宽成 `*` 而不动 `\b`,是在一个永远 +到不了的组上改重复次数 —— **完全的空操作**;只把 `\b` 挪到组后面而保留 `?`,两级前缀里仍有 +外层一个 `../` 留在链接外。两条都用「回退一半、跑 pin」逐条量过,方向与预测一致。 + +修正后的正则把 `\b` 放在前缀组**之后**、紧贴第一个路径段: + +``` +/(? **Source:** `packages/spec/src/api/http-cache.zod.ts` diff --git a/content/docs/references/system/cache.mdx b/content/docs/references/system/cache.mdx index b24ad44f74..5639ae575f 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -26,7 +26,7 @@ Supports Memory, Redis, Memcached, and CDN. - **Use case**: Reduce API response time for repeated metadata requests - **Scope**: HTTP layer, client-server communication -See also: ../../[api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching +See also: [../../api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching **Source:** `packages/spec/src/system/cache.zod.ts` diff --git a/packages/spec/scripts/file-description.test.ts b/packages/spec/scripts/file-description.test.ts index e1ce35b777..c56fe3a2e1 100644 --- a/packages/spec/scripts/file-description.test.ts +++ b/packages/spec/scripts/file-description.test.ts @@ -442,13 +442,11 @@ describe('renderFileDescription — #6136: the bare-path rewriter skips formed l // skipping paths, and a rewriter that stopped doing its job would pass the // two cases above for the wrong reason. // - // Spelled WITHOUT a `../` prefix on purpose. A bare path that carries one - // is mis-linked by a defect this PR does not touch — the rewriter's leading - // `\b` cannot match at the `.` of `../`, so the prefix is left outside the - // link (`../../[system/cache.zod.ts](route)`, live on `api/http-cache` and - // `system/cache`). That is a different input shape from #6136 (no `{@link}` - // is involved) and it is filed separately; asserting the broken spelling - // here would ratify it, so this case steers around it the way #5059's did. + // Spelled WITHOUT a `../` prefix on purpose: when this was written the + // prefixed spelling was mis-linked by a defect #6136 did not touch, and + // asserting the broken output here would have ratified it, so the case + // steered around it the way #5059's did. #6229 has since fixed it — the + // prefixed spellings are pinned in their own block below, correctly. const source = [ '/**', ' * The connector lives in integration/connector.zod.ts today.', @@ -463,6 +461,98 @@ describe('renderFileDescription — #6136: the bare-path rewriter skips formed l }); }); +/** + * #6229 — a `../` prefix belongs INSIDE the link, not beside it. + * + * The rewriter opened with `\b((?:\.\./)?…)`. A word boundary needs a word + * character on one side and every character of `../` is a non-word one, so the + * `\b` could never match at the `.`: the match began at the first path segment + * and the prefix was stranded next to the link it belongs to, published as + * `See also: ../../[system/cache.zod.ts](route)`. + * + * Measured rather than assumed: the prefix group was DEAD for every realistic + * input, not capped at one level as first recorded. `../x/y.zod.ts` lost its + * prefix exactly like `../../x/y.zod.ts` did, and the only spelling that ever + * reached the group was `x../y/z.zod.ts` — a word character before the dots, + * which nobody writes. So the two halves of the fix are not independent: `?` + * to `*` alone is a no-op on a group that is never reached, and moving the + * `\b` alone still strands the outer level of a `../../`. Both cases below + * therefore pin a spelling that a one-sided fix leaves red. + * + * No `{@link}` appears anywhere here — this link is produced entirely by the + * bare-path step, which is why the shape survived #6136. + */ +describe('renderFileDescription — #6229: a bare path keeps its `../` prefix inside the link', () => { + const ctx = { + // Mirrors `build-docs.ts`'s `sourcePathToDocsRoute`: `$`-anchored with a + // `(?:^|/)` 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. + sourcePathToDocsRoute: (t: string) => { + const m = /(?:^|\/)(system|api)\/([\w-]+)\.zod\.ts$/.exec(t); + return m ? `/docs/references/${m[1]}/${m[2]}` : null; + }, + }; + + const describedBy = (line: string) => + renderFileDescription(['/**', ` * ${line}`, ' */', '', "import { z } from 'zod';", ''].join('\n'), ctx); + + it('keeps a two-level `../../` prefix inside the link', () => { + // `packages/spec/src/api/http-cache.zod.ts:35` verbatim — the exact input + // behind `content/docs/references/api/http-cache.mdx`, which published + // `See also: ../../[system/cache.zod.ts](/docs/references/system/cache) …`. + expect(describedBy('@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('keeps the `../../` prefix inside the link on the second published page', () => { + // `packages/spec/src/system/cache.zod.ts:28` verbatim — the other half of + // the pair, so neither page can regress on its own. + expect(describedBy('@see ../../api/http-cache.zod.ts for HTTP-level caching')).toBe( + 'See also: [../../api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching', + ); + }); + + it('keeps a single-level `../` prefix inside the link', () => { + // NOT a case that already worked before #6229 — see the block comment. It + // is pinned because it is the spelling the rest of the corpus uses inside + // `{@link}` tags, so a bare one is a matter of time. + expect(describedBy('The application cache lives in ../system/cache.zod.ts today.')).toBe( + 'The application cache lives in [../system/cache.zod.ts](/docs/references/system/cache) today.', + ); + }); + + it('keeps an arbitrarily deep prefix inside the link', () => { + // `*`, not a second `?`: the depth is whatever the author wrote. + expect(describedBy('Declared in ../../../system/cache.zod.ts for the record.')).toBe( + 'Declared in [../../../system/cache.zod.ts](/docs/references/system/cache) for the record.', + ); + }); + + it('still links an unprefixed path — the fix must not narrow the common case', () => { + expect(describedBy('The application cache lives in system/cache.zod.ts today.')).toBe( + 'The application cache lives in [system/cache.zod.ts](/docs/references/system/cache) today.', + ); + }); + + it('prints an unroutable prefixed path as code, prefix included', () => { + // The null-route fallback has to carry the prefix too, or the page would + // show `../../` beside a code span the way it used to show it beside a link. + expect(describedBy('Declared in ../../nowhere/absent.zod.ts for now.')).toBe( + 'Declared in `../../nowhere/absent.zod.ts` for now.', + ); + }); + + it('still refuses to start mid-word', () => { + // The `\b` moved, it did not go away: `xsystem/…` is one token, so the + // rewriter must not carve a link out of its tail. + expect(describedBy('Declared in xsystem/cache.zod.ts for now.')).toBe( + 'Declared in `xsystem/cache.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 @@ -652,6 +742,23 @@ describe('corpus — every rendered description is well-formed markdown', () => expect(offenders).toEqual([]); }); + it('never strands a `../` prefix outside the link it belongs to (#6229)', () => { + // The corpus half of the unit block above, and the assertion behind the + // issue's own acceptance grep (`\.\./\[` over `content/docs/references/`, + // which this re-derives from source instead of from the artifact). The + // published shape was `See also: ../../[system/cache.zod.ts](route)` on + // `api/http-cache` and `system/cache`: the rewriter began matching at the + // first path SEGMENT, so the prefix stayed behind as bare text beside the + // construct that names it. The code-span fallback lost it the same way + // (`../../` + a backtick), so both closers are checked. + const offenders: string[] = []; + for (const { rel, out } of described) { + const stranded = out.match(/(?:\.\.\/)+[[`]/); + if (stranded) offenders.push(`${rel}: ${stranded[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 4dc919f31d..c3604e8981 100644 --- a/packages/spec/scripts/lib/file-description.ts +++ b/packages/spec/scripts/lib/file-description.ts @@ -375,8 +375,19 @@ function renderProse(text: string, ctx: FileDescriptionContext): string { // the whole of #6136's fix: the untitled `{@link}` branch above emits // `[]()`, whose link text is the path itself, so a rewriter run // over the raw string matched it a second time and nested a link in a link. + // + // The `\b` sits AFTER the `../` prefix rather than before it (#6229). A word + // boundary needs a word character on one side, and every character of `../` + // is a non-word one, so a leading `\b` could not match at the `.` — the match + // started at the first path segment instead and the prefix was left OUTSIDE + // the link it belongs to (`../../[system/cache.zod.ts](route)`, published on + // `api/http-cache` and `system/cache`). The prefix group was therefore dead + // for every realistic input, not merely capped at one level: widening it to + // `*` without moving the `\b` changes nothing, since a group that is never + // reached repeats zero times either way. Both halves are load-bearing — + // moving the `\b` alone would still strand the outer level of a `../../`. out = mapProse(out, ['text'], s => - s.replace(/(? { + s.replace(/(? { const route = sourcePathToDocsRoute(p); return route ? `[${p}](${route})` : `\`${p}\``; }));