Skip to content

Commit 2c26040

Browse files
os-zhuangclaude
andauthored
fix(spec): 参考文档模块标题改为声明式,qa 不再渲染成 "Qa Protocol" (#5853) (#6308)
build-docs.ts 过去按目录名猜标题:默认首字母大写,只对 ['UI','AI','API'] 这三个当初有人想到的缩写做全大写例外。qa 同样是缩写(src/qa/index.ts 的 文件头就写着 "Quality Assurance (QA) Protocol"),但不在名单里,于是被 单方面降级成 "Qa Protocol",一次发布到三处:分类页标题、qa/meta.json 的 侧边栏标签,以及 references/index.mdx 的导航行与章节标题。 测量到的形状问题(而不是「漏了一个缩写」): - src/ 下 17 个模块目录由 readdirSync 运行时发现,没有任何东西提醒补名单; - 其中 4 个是缩写(ai/api/ui/qa),名单覆盖 3 个 —— 25% 漏报; - check:docs 比对「生成 vs 已提交」,而错误的标题是稳定的,所以永远绿。 这就是 Qa Protocol 熬过每一次重生成、直到 #4759 并排印出 14 个标题才被 人眼发现的原因。猜出来的标题错得无法被发现。 改法:标题在 scripts/lib/category-title.ts 里逐个声明,对磁盘目录全覆盖, 无兜底无推导;resolveCategoryTitles() 是构造该映射的唯一入口,双向缺口 抛错。新增模块目录会让 gen:docs 指名失败并给出要补的那一行,而不是默默 发布 "Iam Protocol"。沿用旁边 CATEGORY_BLURBS 的既有惯例(blurbCoverage / formatBlurbCoverage,#4759)。 content/docs/references 由 pnpm --filter @objectstack/spec gen:docs 重生成, diff 恰为 4 行。 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82bfb6e commit 2c26040

7 files changed

Lines changed: 377 additions & 16 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): 参考文档的模块标题改为声明式,`qa` 不再被渲染成 "Qa Protocol" (#5853)
6+
7+
`build-docs.ts` 过去是****模块标题的:默认首字母大写,再对 `['UI', 'AI', 'API']`
8+
这三个当初有人想到的缩写做全大写例外。`qa` 同样是缩写 —— `src/qa/index.ts` 自己的
9+
文件头写的就是 "Quality Assurance (QA) Protocol" —— 但不在名单里,于是生成器单方面
10+
把它降级成 **"Qa Protocol"**,一次发布到三处:分类页标题、`qa/meta.json` 里的侧边栏
11+
标签,以及(#4759 把根索引纳入生成之后)`references/index.mdx` 的导航行与章节标题。
12+
13+
## 为什么不是把 `QA` 加进名单就完事
14+
15+
- `packages/spec/src/` 下有 **17** 个模块目录,由 `readdirSync` 在运行时发现 ——
16+
没有任何东西提醒作者在新增目录时去补名单。
17+
- 这 17 个里 **4 个是缩写**(`ai``api``ui``qa`),名单覆盖了 3 个:在它唯一
18+
服务的那一类上,漏报率 25%。
19+
- **任何门禁都不可能发现它。** `check:docs` 比对的是「生成结果 vs 已提交结果」,
20+
而一个错误的标题是**稳定的**,所以它永远是绿的。`Qa Protocol``src/qa/` 建立
21+
那天起熬过了每一次重生成,直到 #4759 把 14 个标题并排印出来才被人眼看见。
22+
23+
所以真正的缺陷不是「漏了一个缩写」,而是**猜出来的标题错得无法被发现**
24+
25+
## 现在的形状
26+
27+
标题改为在 `scripts/lib/category-title.ts` 里逐个**声明**(`CATEGORY_TITLES`),并且
28+
该表对磁盘上的目录是**全覆盖**的:没有兜底、没有推导,`resolveCategoryTitles()`
29+
构造这张映射的唯一入口,双向缺口一律抛错。新增一个模块目录会让 `gen:docs` 直接失败
30+
并指名道姓地告诉你补哪一行,而不是默默发布一个 "Iam Protocol"。这与旁边 `CATEGORY_BLURBS`
31+
用了一个数据项的既有写法(`blurbCoverage` / `formatBlurbCoverage`,#4759)是同一套惯例
32+
—— 标题只是最后一个还在靠猜的按模块数据项。
33+
34+
面向读者的变化:参考文档三处落点现在都读作 **"QA Protocol"**

content/docs/references/index.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ counts are sums of the rows they head. Regenerate with
2727
| [Identity Protocol](/docs/references/identity) | 5 | 28 | Users and accounts, organizations, positions, API keys, SCIM provisioning. |
2828
| [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
2929
| [Kernel Protocol](/docs/references/kernel) | 31 | 187 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
30-
| [Qa Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
30+
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
3131
| [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. |
3232
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
@@ -255,7 +255,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser
255255

256256
---
257257

258-
## Qa Protocol
258+
## QA Protocol
259259

260260
**Source:** `packages/spec/src/qa/` · **Import:** `@objectstack/spec/qa` · **1 page, 8 schemas**
261261

content/docs/references/qa/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: Qa Protocol
2+
title: QA Protocol
33
description: Complete reference for all qa protocol schemas
44
---
55

content/docs/references/qa/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"title": "Qa Protocol",
2+
"title": "QA Protocol",
33
"pages": [
44
"testing"
55
]

packages/spec/scripts/build-docs.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import path from 'path';
2626
// a confident page from a tree nobody rebuilt (#4675, #4723).
2727
import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs';
2828

29+
import { resolveCategoryTitles } from './lib/category-title';
2930
import {
3031
evaluateBaseline,
3132
loadEntrySurfaces,
@@ -119,19 +120,28 @@ const { emit, manageDir, wasEmitted, flush } = createSink({
119120
repoRoot: REPO_ROOT,
120121
});
121122

122-
// Dynamically discover categories from src directory
123-
const getCategoryTitle = (dir: string) => {
124-
const upper = dir.toUpperCase();
125-
if (['UI', 'AI', 'API'].includes(upper)) return `${upper} Protocol`;
126-
return `${dir.charAt(0).toUpperCase() + dir.slice(1)} Protocol`;
127-
};
123+
// Categories are discovered from the src directory; their TITLES are declared,
124+
// not derived from the directory name (#5853 — see lib/category-title.ts for
125+
// why a derived title is wrong in a way no gate can see). A directory with no
126+
// declared title stops the build here rather than publishing a guess.
127+
const CATEGORY_DIRS = fs.readdirSync(SRC_DIR)
128+
.filter(file => fs.statSync(path.join(SRC_DIR, file)).isDirectory());
129+
130+
// `resolveCategoryTitles` is the ONLY way this map gets built, and it is total:
131+
// it throws on a directory with no title and on a title with no directory. The
132+
// check lives inside the constructor rather than beside it so a future caller
133+
// cannot obtain a CATEGORIES map without it — what this replaces was exactly a
134+
// fallback nobody had to opt out of.
135+
function loadCategoryTitles(): Record<string, string> {
136+
try {
137+
return resolveCategoryTitles(CATEGORY_DIRS);
138+
} catch (err) {
139+
console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
140+
process.exit(1);
141+
}
142+
}
128143

129-
const CATEGORIES = fs.readdirSync(SRC_DIR)
130-
.filter(file => fs.statSync(path.join(SRC_DIR, file)).isDirectory())
131-
.reduce((acc, dir) => {
132-
acc[dir] = getCategoryTitle(dir);
133-
return acc;
134-
}, {} as Record<string, string>);
144+
const CATEGORIES = loadCategoryTitles();
135145

136146
// Track all zod files per category
137147
const categoryZodFiles = new Map<string, Set<string>>();
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pins the declared protocol-module titles (#5853) from both ends.
5+
*
6+
* The bug was `qa` rendering as **"Qa Protocol"** in three published places at
7+
* once, because `build-docs.ts` guessed the title from the directory name and
8+
* upper-cased only the three abbreviations someone had thought of
9+
* (`['UI', 'AI', 'API']`). `qa` is the fourth.
10+
*
11+
* What makes this worth more than one line of test: the wrong title was
12+
* **undetectable**. `check:docs` compares generation against what was
13+
* committed, so a stable wrong title is green forever — `Qa Protocol` survived
14+
* every regeneration from the day `src/qa/` was created until #4759 printed 14
15+
* titles side by side and a human read it. So the pin has to cover the shape,
16+
* not just the instance:
17+
*
18+
* - **the table** — `qa` resolves to `QA Protocol`, every abbreviation module
19+
* keeps its capitalisation, and the declared keys match the directories on
20+
* disk in both directions;
21+
* - **the visibility property** the declared table was chosen for — a module
22+
* directory with no entry is REPORTED BY NAME, where the old shape silently
23+
* invented `Iam Protocol`. This is the assertion that would have to be
24+
* deleted, not merely edited, to bring the silence back;
25+
* - **the artifacts** — the three committed landing sites. Without these,
26+
* deleting the generator's use of the table would leave every unit test
27+
* green while the published pages went back to `Qa Protocol` (the same
28+
* reason `root-index.test.ts` asserts over the committed `.mdx`).
29+
*/
30+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
31+
import { dirname, join, resolve } from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
import { describe, expect, it } from 'vitest';
34+
35+
import {
36+
CATEGORY_TITLES,
37+
categoryTitleCoverage,
38+
formatCategoryTitleCoverage,
39+
resolveCategoryTitles,
40+
} from './lib/category-title';
41+
42+
const HERE = dirname(fileURLToPath(import.meta.url));
43+
const SRC_DIR = resolve(HERE, '../src');
44+
const DOCS_ROOT = resolve(HERE, '../../../content/docs/references');
45+
46+
const moduleDirsOnDisk = () =>
47+
readdirSync(SRC_DIR)
48+
.filter(entry => statSync(join(SRC_DIR, entry)).isDirectory())
49+
.sort();
50+
51+
describe('CATEGORY_TITLES', () => {
52+
it('titles qa as "QA Protocol" — the abbreviation the derived shape downgraded (#5853)', () => {
53+
expect(CATEGORY_TITLES.qa).toBe('QA Protocol');
54+
expect(CATEGORY_TITLES.qa).not.toBe('Qa Protocol');
55+
});
56+
57+
it('keeps every abbreviation module upper-cased, not title-cased', () => {
58+
// The four abbreviations among the module directories. `qa` sat outside the
59+
// old hard-coded trio for no reason other than nobody having added it, and
60+
// `src/qa/index.ts` itself opens with "Quality Assurance (QA) Protocol".
61+
expect({
62+
ai: CATEGORY_TITLES.ai,
63+
api: CATEGORY_TITLES.api,
64+
qa: CATEGORY_TITLES.qa,
65+
ui: CATEGORY_TITLES.ui,
66+
}).toEqual({
67+
ai: 'AI Protocol',
68+
api: 'API Protocol',
69+
qa: 'QA Protocol',
70+
ui: 'UI Protocol',
71+
});
72+
});
73+
74+
it('declares a title for exactly the module directories under packages/spec/src', () => {
75+
// Both directions, against the real tree — the same rule `blurbCoverage`
76+
// holds the root-index blurbs to. A missing entry is the bug this file
77+
// exists for; a stale entry is a display name outliving its directory.
78+
expect(Object.keys(CATEGORY_TITLES).sort()).toEqual(moduleDirsOnDisk());
79+
});
80+
81+
it('resolves the real tree without throwing, and titles every directory in it', () => {
82+
const dirs = moduleDirsOnDisk();
83+
const resolved = resolveCategoryTitles(dirs);
84+
85+
expect(Object.keys(resolved).sort()).toEqual(dirs);
86+
expect(Object.values(resolved).every(title => title.length > 0)).toBe(true);
87+
});
88+
});
89+
90+
describe('categoryTitleCoverage — the property the declared table was chosen for', () => {
91+
const declared = { ai: 'AI Protocol', qa: 'QA Protocol' };
92+
93+
it('is silent when the declarations and the directories agree', () => {
94+
expect(categoryTitleCoverage(['ai', 'qa'], declared)).toEqual({ missing: [], extra: [] });
95+
});
96+
97+
it('REPORTS a new module directory instead of inventing a title for it', () => {
98+
// The regression this replaces, in its next incarnation: under the old
99+
// "upper-case then look the abbreviation up in a list" shape, a new `iam`
100+
// directory silently published "Iam Protocol" and no gate could see it,
101+
// because a wrong title is a stable one. Now the run cannot produce a
102+
// title at all until someone declares it.
103+
const coverage = categoryTitleCoverage(['ai', 'iam', 'qa'], declared);
104+
105+
expect(coverage.missing).toEqual(['iam']);
106+
expect(coverage.extra).toEqual([]);
107+
expect(() => resolveCategoryTitles(['ai', 'iam', 'qa'], declared)).toThrow(/iam/);
108+
});
109+
110+
it('reports a title whose directory is gone, so a display name cannot outlive it', () => {
111+
const coverage = categoryTitleCoverage(['ai'], declared);
112+
113+
expect(coverage.missing).toEqual([]);
114+
expect(coverage.extra).toEqual(['qa']);
115+
expect(() => resolveCategoryTitles(['ai'], declared)).toThrow(/qa/);
116+
});
117+
118+
it('names the directory, the file to edit, and the line to add', () => {
119+
// A message that only said "coverage mismatch" would send the reader back
120+
// to the guessing shape to work out what to write.
121+
const message = formatCategoryTitleCoverage(categoryTitleCoverage(['ai', 'iam', 'qa'], declared));
122+
123+
expect(message).toContain('iam');
124+
expect(message).toContain('scripts/lib/category-title.ts');
125+
expect(message).toContain('add one line');
126+
});
127+
});
128+
129+
describe('the committed landing sites', () => {
130+
// The three places the title is published, per #5853. Read from disk rather
131+
// than regenerated: this is the assertion that fails if the generator stops
132+
// using the table, or if someone hand-edits a generated page back.
133+
const read = (rel: string) => {
134+
const abs = join(DOCS_ROOT, rel);
135+
expect(existsSync(abs), `${rel} should exist`).toBe(true);
136+
return readFileSync(abs, 'utf-8');
137+
};
138+
139+
it('gives the qa category page the title "QA Protocol"', () => {
140+
expect(read('qa/index.mdx')).toContain('title: QA Protocol');
141+
});
142+
143+
it('gives the qa sidebar entry the label "QA Protocol"', () => {
144+
expect(JSON.parse(read('qa/meta.json')).title).toBe('QA Protocol');
145+
});
146+
147+
it('spells it "QA Protocol" in the root index nav row and section heading', () => {
148+
const index = read('index.mdx');
149+
150+
expect(index).toContain('[QA Protocol](/docs/references/qa)');
151+
expect(index).toContain('## QA Protocol');
152+
});
153+
154+
it('leaves no "Qa Protocol" anywhere under content/docs/references', () => {
155+
const offenders: string[] = [];
156+
const walk = (dir: string) => {
157+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
158+
const abs = join(dir, entry.name);
159+
if (entry.isDirectory()) {
160+
walk(abs);
161+
} else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.json')) {
162+
if (readFileSync(abs, 'utf-8').includes('Qa Protocol')) offenders.push(abs);
163+
}
164+
}
165+
};
166+
walk(DOCS_ROOT);
167+
168+
expect(offenders).toEqual([]);
169+
});
170+
});

0 commit comments

Comments
 (0)