Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/bare-path-parent-prefix-inside-link.md
Original file line number Diff line number Diff line change
@@ -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` 放在前缀组**之后**、紧贴第一个路径段:

```
/(?<!\()((?:\.\.\/)*\b[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g
```

`build-docs.ts` 的 `sourcePathToDocsRoute` 一行未改:它本来就以 `$` 收尾、以 `(?:^|/)` 起头,
带前缀的路径一直能解析到同一页 —— 缺陷从来不在路由解析,只在**交给它多长的一段路径**。

无路由可解的路径回退成行内代码,前缀同样跟着进去(此前是 `../../` 加一个反引号,把前缀漏在
代码段外面,与链接是同一个毛病)。

`{@link}` 那一步不受影响:它产出的链接在重新分词后是 `link` 段,本步骤看不到(#6136 的修法)。
语料中唯一另一处带前缀的裸路径在行内代码段里(`data/feed.zod.ts`),同样被分词器挡在外面。

重生成后差异**恰好是上述两页各一行**,与预测一致;`grep -rn '\.\./\[' content/docs/references/`
从 2 降到 0。除单元 pin 外另加一条语料级 pin(与 #6136 的嵌套链接 pin 并列),直接在渲染结果上
断言「`../` 不得留在链接或代码段外面」,即 issue 那条验收 grep 的等价物,只是从源码重新推导而
不是去读产物。
2 changes: 1 addition & 1 deletion content/docs/references/api/http-cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Implements P0 requirement for ObjectStack kernel.

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

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

<Callout type="info">
**Source:** `packages/spec/src/api/http-cache.zod.ts`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Callout type="info">
**Source:** `packages/spec/src/system/cache.zod.ts`
Expand Down
121 changes: 114 additions & 7 deletions packages/spec/scripts/file-description.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/scripts/lib/file-description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,19 @@ function renderProse(text: string, ctx: FileDescriptionContext): string {
// the whole of #6136's fix: the untitled `{@link}` branch above emits
// `[<path>](<route>)`, 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(/(?<!\()\b((?:\.\.\/)?[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g, (_m, p: string) => {
s.replace(/(?<!\()((?:\.\.\/)*\b[\w-]+\/[\w.-]+\.zod\.ts)\b(?!\))/g, (_m, p: string) => {
const route = sourcePathToDocsRoute(p);
return route ? `[${p}](${route})` : `\`${p}\``;
}));
Expand Down
Loading