Skip to content

Commit df3c95a

Browse files
committed
fix(spec): 正文里裸露的源码路径,../ 前缀回到链接里面 (#6229)
把 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
1 parent c78be03 commit df3c95a

5 files changed

Lines changed: 179 additions & 10 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 正文里裸露的源码路径,`../` 前缀回到链接里面 (#6229)
6+
7+
`file-description.ts` 把「正文中裸露的 `*.zod.ts` 路径」改写成链接的那一步,正则以
8+
`\b((?:\.\./)?…)` 开头。**单词边界要求两侧有一个单词字符,而 `../` 三个字符全是非单词
9+
字符**,所以那个 `\b` 永远无法在 `.` 处成立:匹配只能从第一个路径段开始,前缀被丢在它
10+
本该进入的链接**外面**,发布成:
11+
12+
```
13+
See also: ../../[system/cache.zod.ts](/docs/references/system/cache) for application-level caching
14+
```
15+
16+
`content/docs/references/api/http-cache.mdx``content/docs/references/system/cache.mdx`
17+
两页,读者看到的是一串裸文本紧挨着一个链接。
18+
19+
**实测把最初记录的成因修正了一处。** 原记录说前缀组「只允许一级,而语料用的是两级」。逐个
20+
输入量过之后:该组对**任何现实输入都不成立**,不是「只支持一级」——
21+
22+
| 输入 | 修复前匹配到的 | |
23+
|---|---|---|
24+
| `../../system/cache.zod.ts` | `system/cache.zod.ts` | 前缀漏在外面 |
25+
| `../system/cache.zod.ts` | `system/cache.zod.ts` | **同样漏在外面**,并非原记录所说「本来就正常」 |
26+
| `system/cache.zod.ts` | `system/cache.zod.ts` | 正常 |
27+
| `x../system/cache.zod.ts` | `../system/cache.zod.ts` | 唯一能让该组生效的拼法:点号前有单词字符,没人这么写 |
28+
29+
于是两个半边****是必需的,而且互相不可替代:把 `?` 放宽成 `*` 而不动 `\b`,是在一个永远
30+
到不了的组上改重复次数 —— **完全的空操作**;只把 `\b` 挪到组后面而保留 `?`,两级前缀里仍有
31+
外层一个 `../` 留在链接外。两条都用「回退一半、跑 pin」逐条量过,方向与预测一致。
32+
33+
修正后的正则把 `\b` 放在前缀组**之后**、紧贴第一个路径段:
34+
35+
```
36+
/(?<!\()((?:\.\.\/)*\b[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g
37+
```
38+
39+
`build-docs.ts``sourcePathToDocsRoute` 一行未改:它本来就以 `$` 收尾、以 `(?:^|/)` 起头,
40+
带前缀的路径一直能解析到同一页 —— 缺陷从来不在路由解析,只在**交给它多长的一段路径**
41+
42+
无路由可解的路径回退成行内代码,前缀同样跟着进去(此前是 `../../` 加一个反引号,把前缀漏在
43+
代码段外面,与链接是同一个毛病)。
44+
45+
`{@link}` 那一步不受影响:它产出的链接在重新分词后是 `link` 段,本步骤看不到(#6136 的修法)。
46+
语料中唯一另一处带前缀的裸路径在行内代码段里(`data/feed.zod.ts`),同样被分词器挡在外面。
47+
48+
重生成后差异**恰好是上述两页各一行**,与预测一致;`grep -rn '\.\./\[' content/docs/references/`
49+
从 2 降到 0。除单元 pin 外另加一条语料级 pin(与 #6136 的嵌套链接 pin 并列),直接在渲染结果上
50+
断言「`../` 不得留在链接或代码段外面」,即 issue 那条验收 grep 的等价物,只是从源码重新推导而
51+
不是去读产物。

content/docs/references/api/http-cache.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Implements P0 requirement for ObjectStack kernel.
3434

3535
Industry alignment: HTTP Caching (RFC 7234), Salesforce Metadata API
3636

37-
See also: ../../[system/cache.zod.ts](/docs/references/system/cache) for application-level caching
37+
See also: [../../system/cache.zod.ts](/docs/references/system/cache) for application-level caching
3838

3939
<Callout type="info">
4040
**Source:** `packages/spec/src/api/http-cache.zod.ts`

content/docs/references/system/cache.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Supports Memory, Redis, Memcached, and CDN.
2626
- **Use case**: Reduce API response time for repeated metadata requests
2727
- **Scope**: HTTP layer, client-server communication
2828

29-
See also: ../../[api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching
29+
See also: [../../api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching
3030

3131
<Callout type="info">
3232
**Source:** `packages/spec/src/system/cache.zod.ts`

packages/spec/scripts/file-description.test.ts

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,11 @@ describe('renderFileDescription — #6136: the bare-path rewriter skips formed l
442442
// skipping paths, and a rewriter that stopped doing its job would pass the
443443
// two cases above for the wrong reason.
444444
//
445-
// Spelled WITHOUT a `../` prefix on purpose. A bare path that carries one
446-
// is mis-linked by a defect this PR does not touch — the rewriter's leading
447-
// `\b` cannot match at the `.` of `../`, so the prefix is left outside the
448-
// link (`../../[system/cache.zod.ts](route)`, live on `api/http-cache` and
449-
// `system/cache`). That is a different input shape from #6136 (no `{@link}`
450-
// is involved) and it is filed separately; asserting the broken spelling
451-
// here would ratify it, so this case steers around it the way #5059's did.
445+
// Spelled WITHOUT a `../` prefix on purpose: when this was written the
446+
// prefixed spelling was mis-linked by a defect #6136 did not touch, and
447+
// asserting the broken output here would have ratified it, so the case
448+
// steered around it the way #5059's did. #6229 has since fixed it — the
449+
// prefixed spellings are pinned in their own block below, correctly.
452450
const source = [
453451
'/**',
454452
' * The connector lives in integration/connector.zod.ts today.',
@@ -463,6 +461,98 @@ describe('renderFileDescription — #6136: the bare-path rewriter skips formed l
463461
});
464462
});
465463

464+
/**
465+
* #6229 — a `../` prefix belongs INSIDE the link, not beside it.
466+
*
467+
* The rewriter opened with `\b((?:\.\./)?…)`. A word boundary needs a word
468+
* character on one side and every character of `../` is a non-word one, so the
469+
* `\b` could never match at the `.`: the match began at the first path segment
470+
* and the prefix was stranded next to the link it belongs to, published as
471+
* `See also: ../../[system/cache.zod.ts](route)`.
472+
*
473+
* Measured rather than assumed: the prefix group was DEAD for every realistic
474+
* input, not capped at one level as first recorded. `../x/y.zod.ts` lost its
475+
* prefix exactly like `../../x/y.zod.ts` did, and the only spelling that ever
476+
* reached the group was `x../y/z.zod.ts` — a word character before the dots,
477+
* which nobody writes. So the two halves of the fix are not independent: `?`
478+
* to `*` alone is a no-op on a group that is never reached, and moving the
479+
* `\b` alone still strands the outer level of a `../../`. Both cases below
480+
* therefore pin a spelling that a one-sided fix leaves red.
481+
*
482+
* No `{@link}` appears anywhere here — this link is produced entirely by the
483+
* bare-path step, which is why the shape survived #6136.
484+
*/
485+
describe('renderFileDescription — #6229: a bare path keeps its `../` prefix inside the link', () => {
486+
const ctx = {
487+
// Mirrors `build-docs.ts`'s `sourcePathToDocsRoute`: `$`-anchored with a
488+
// `(?:^|/)` head, so a `../` prefix on the way IN already resolves to the
489+
// same page. The defect was never in route resolution — only in how much
490+
// of the path the rewriter handed it.
491+
sourcePathToDocsRoute: (t: string) => {
492+
const m = /(?:^|\/)(system|api)\/([\w-]+)\.zod\.ts$/.exec(t);
493+
return m ? `/docs/references/${m[1]}/${m[2]}` : null;
494+
},
495+
};
496+
497+
const describedBy = (line: string) =>
498+
renderFileDescription(['/**', ` * ${line}`, ' */', '', "import { z } from 'zod';", ''].join('\n'), ctx);
499+
500+
it('keeps a two-level `../../` prefix inside the link', () => {
501+
// `packages/spec/src/api/http-cache.zod.ts:35` verbatim — the exact input
502+
// behind `content/docs/references/api/http-cache.mdx`, which published
503+
// `See also: ../../[system/cache.zod.ts](/docs/references/system/cache) …`.
504+
expect(describedBy('@see ../../system/cache.zod.ts for application-level caching')).toBe(
505+
'See also: [../../system/cache.zod.ts](/docs/references/system/cache) for application-level caching',
506+
);
507+
});
508+
509+
it('keeps the `../../` prefix inside the link on the second published page', () => {
510+
// `packages/spec/src/system/cache.zod.ts:28` verbatim — the other half of
511+
// the pair, so neither page can regress on its own.
512+
expect(describedBy('@see ../../api/http-cache.zod.ts for HTTP-level caching')).toBe(
513+
'See also: [../../api/http-cache.zod.ts](/docs/references/api/http-cache) for HTTP-level caching',
514+
);
515+
});
516+
517+
it('keeps a single-level `../` prefix inside the link', () => {
518+
// NOT a case that already worked before #6229 — see the block comment. It
519+
// is pinned because it is the spelling the rest of the corpus uses inside
520+
// `{@link}` tags, so a bare one is a matter of time.
521+
expect(describedBy('The application cache lives in ../system/cache.zod.ts today.')).toBe(
522+
'The application cache lives in [../system/cache.zod.ts](/docs/references/system/cache) today.',
523+
);
524+
});
525+
526+
it('keeps an arbitrarily deep prefix inside the link', () => {
527+
// `*`, not a second `?`: the depth is whatever the author wrote.
528+
expect(describedBy('Declared in ../../../system/cache.zod.ts for the record.')).toBe(
529+
'Declared in [../../../system/cache.zod.ts](/docs/references/system/cache) for the record.',
530+
);
531+
});
532+
533+
it('still links an unprefixed path — the fix must not narrow the common case', () => {
534+
expect(describedBy('The application cache lives in system/cache.zod.ts today.')).toBe(
535+
'The application cache lives in [system/cache.zod.ts](/docs/references/system/cache) today.',
536+
);
537+
});
538+
539+
it('prints an unroutable prefixed path as code, prefix included', () => {
540+
// The null-route fallback has to carry the prefix too, or the page would
541+
// show `../../` beside a code span the way it used to show it beside a link.
542+
expect(describedBy('Declared in ../../nowhere/absent.zod.ts for now.')).toBe(
543+
'Declared in `../../nowhere/absent.zod.ts` for now.',
544+
);
545+
});
546+
547+
it('still refuses to start mid-word', () => {
548+
// The `\b` moved, it did not go away: `xsystem/…` is one token, so the
549+
// rewriter must not carve a link out of its tail.
550+
expect(describedBy('Declared in xsystem/cache.zod.ts for now.')).toBe(
551+
'Declared in `xsystem/cache.zod.ts` for now.',
552+
);
553+
});
554+
});
555+
466556
/**
467557
* The corpus half: re-derive the verdict from the real sources, so the six
468558
* 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', () =>
652742
expect(offenders).toEqual([]);
653743
});
654744

745+
it('never strands a `../` prefix outside the link it belongs to (#6229)', () => {
746+
// The corpus half of the unit block above, and the assertion behind the
747+
// issue's own acceptance grep (`\.\./\[` over `content/docs/references/`,
748+
// which this re-derives from source instead of from the artifact). The
749+
// published shape was `See also: ../../[system/cache.zod.ts](route)` on
750+
// `api/http-cache` and `system/cache`: the rewriter began matching at the
751+
// first path SEGMENT, so the prefix stayed behind as bare text beside the
752+
// construct that names it. The code-span fallback lost it the same way
753+
// (`../../` + a backtick), so both closers are checked.
754+
const offenders: string[] = [];
755+
for (const { rel, out } of described) {
756+
const stranded = out.match(/(?:\.\.\/)+[[`]/);
757+
if (stranded) offenders.push(`${rel}: ${stranded[0]}`);
758+
}
759+
expect(offenders).toEqual([]);
760+
});
761+
655762
it('keeps a description for every source that had one — #6134 selection is untouched', () => {
656763
// The rendering fix must not remove a page's opening paragraph; that is
657764
// #5059's acceptance criterion and it still binds. 185 sources carry a

packages/spec/scripts/lib/file-description.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,19 @@ function renderProse(text: string, ctx: FileDescriptionContext): string {
375375
// the whole of #6136's fix: the untitled `{@link}` branch above emits
376376
// `[<path>](<route>)`, whose link text is the path itself, so a rewriter run
377377
// over the raw string matched it a second time and nested a link in a link.
378+
//
379+
// The `\b` sits AFTER the `../` prefix rather than before it (#6229). A word
380+
// boundary needs a word character on one side, and every character of `../`
381+
// is a non-word one, so a leading `\b` could not match at the `.` — the match
382+
// started at the first path segment instead and the prefix was left OUTSIDE
383+
// the link it belongs to (`../../[system/cache.zod.ts](route)`, published on
384+
// `api/http-cache` and `system/cache`). The prefix group was therefore dead
385+
// for every realistic input, not merely capped at one level: widening it to
386+
// `*` without moving the `\b` changes nothing, since a group that is never
387+
// reached repeats zero times either way. Both halves are load-bearing —
388+
// moving the `\b` alone would still strand the outer level of a `../../`.
378389
out = mapProse(out, ['text'], s =>
379-
s.replace(/(?<!\()\b((?:\.\.\/)?[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g, (_m, p: string) => {
390+
s.replace(/(?<!\()((?:\.\.\/)*\b[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g, (_m, p: string) => {
380391
const route = sourcePathToDocsRoute(p);
381392
return route ? `[${p}](${route})` : `\`${p}\``;
382393
}));

0 commit comments

Comments
 (0)