diff --git a/.changeset/qa-protocol-category-title.md b/.changeset/qa-protocol-category-title.md new file mode 100644 index 0000000000..c556ce178c --- /dev/null +++ b/.changeset/qa-protocol-category-title.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 参考文档的模块标题改为声明式,`qa` 不再被渲染成 "Qa Protocol" (#5853) + +`build-docs.ts` 过去是**猜**模块标题的:默认首字母大写,再对 `['UI', 'AI', 'API']` +这三个当初有人想到的缩写做全大写例外。`qa` 同样是缩写 —— `src/qa/index.ts` 自己的 +文件头写的就是 "Quality Assurance (QA) Protocol" —— 但不在名单里,于是生成器单方面 +把它降级成 **"Qa Protocol"**,一次发布到三处:分类页标题、`qa/meta.json` 里的侧边栏 +标签,以及(#4759 把根索引纳入生成之后)`references/index.mdx` 的导航行与章节标题。 + +## 为什么不是把 `QA` 加进名单就完事 + +- `packages/spec/src/` 下有 **17** 个模块目录,由 `readdirSync` 在运行时发现 —— + 没有任何东西提醒作者在新增目录时去补名单。 +- 这 17 个里 **4 个是缩写**(`ai`、`api`、`ui`、`qa`),名单覆盖了 3 个:在它唯一 + 服务的那一类上,漏报率 25%。 +- **任何门禁都不可能发现它。** `check:docs` 比对的是「生成结果 vs 已提交结果」, + 而一个错误的标题是**稳定的**,所以它永远是绿的。`Qa Protocol` 从 `src/qa/` 建立 + 那天起熬过了每一次重生成,直到 #4759 把 14 个标题并排印出来才被人眼看见。 + +所以真正的缺陷不是「漏了一个缩写」,而是**猜出来的标题错得无法被发现**。 + +## 现在的形状 + +标题改为在 `scripts/lib/category-title.ts` 里逐个**声明**(`CATEGORY_TITLES`),并且 +该表对磁盘上的目录是**全覆盖**的:没有兜底、没有推导,`resolveCategoryTitles()` 是 +构造这张映射的唯一入口,双向缺口一律抛错。新增一个模块目录会让 `gen:docs` 直接失败 +并指名道姓地告诉你补哪一行,而不是默默发布一个 "Iam Protocol"。这与旁边 `CATEGORY_BLURBS` +用了一个数据项的既有写法(`blurbCoverage` / `formatBlurbCoverage`,#4759)是同一套惯例 +—— 标题只是最后一个还在靠猜的按模块数据项。 + +面向读者的变化:参考文档三处落点现在都读作 **"QA Protocol"**。 diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a8ac7b7839..ba295e7d8a 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -27,7 +27,7 @@ counts are sums of the rows they head. Regenerate with | [Identity Protocol](/docs/references/identity) | 5 | 28 | Users and accounts, organizations, positions, API keys, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 31 | 187 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | -| [Qa Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | +| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [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 --- -## Qa Protocol +## QA Protocol **Source:** `packages/spec/src/qa/` · **Import:** `@objectstack/spec/qa` · **1 page, 8 schemas** diff --git a/content/docs/references/qa/index.mdx b/content/docs/references/qa/index.mdx index e53688768a..e30ece74c0 100644 --- a/content/docs/references/qa/index.mdx +++ b/content/docs/references/qa/index.mdx @@ -1,5 +1,5 @@ --- -title: Qa Protocol +title: QA Protocol description: Complete reference for all qa protocol schemas --- diff --git a/content/docs/references/qa/meta.json b/content/docs/references/qa/meta.json index f546c48247..6b1fe5c5d8 100644 --- a/content/docs/references/qa/meta.json +++ b/content/docs/references/qa/meta.json @@ -1,5 +1,5 @@ { - "title": "Qa Protocol", + "title": "QA Protocol", "pages": [ "testing" ] diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 8e0c3ca4cb..067392a671 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -26,6 +26,7 @@ import path from 'path'; // a confident page from a tree nobody rebuilt (#4675, #4723). import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs'; +import { resolveCategoryTitles } from './lib/category-title'; import { evaluateBaseline, loadEntrySurfaces, @@ -119,19 +120,28 @@ const { emit, manageDir, wasEmitted, flush } = createSink({ repoRoot: REPO_ROOT, }); -// Dynamically discover categories from src directory -const getCategoryTitle = (dir: string) => { - const upper = dir.toUpperCase(); - if (['UI', 'AI', 'API'].includes(upper)) return `${upper} Protocol`; - return `${dir.charAt(0).toUpperCase() + dir.slice(1)} Protocol`; -}; +// Categories are discovered from the src directory; their TITLES are declared, +// not derived from the directory name (#5853 — see lib/category-title.ts for +// why a derived title is wrong in a way no gate can see). A directory with no +// declared title stops the build here rather than publishing a guess. +const CATEGORY_DIRS = fs.readdirSync(SRC_DIR) + .filter(file => fs.statSync(path.join(SRC_DIR, file)).isDirectory()); + +// `resolveCategoryTitles` is the ONLY way this map gets built, and it is total: +// it throws on a directory with no title and on a title with no directory. The +// check lives inside the constructor rather than beside it so a future caller +// cannot obtain a CATEGORIES map without it — what this replaces was exactly a +// fallback nobody had to opt out of. +function loadCategoryTitles(): Record { + try { + return resolveCategoryTitles(CATEGORY_DIRS); + } catch (err) { + console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} -const CATEGORIES = fs.readdirSync(SRC_DIR) - .filter(file => fs.statSync(path.join(SRC_DIR, file)).isDirectory()) - .reduce((acc, dir) => { - acc[dir] = getCategoryTitle(dir); - return acc; - }, {} as Record); +const CATEGORIES = loadCategoryTitles(); // Track all zod files per category const categoryZodFiles = new Map>(); diff --git a/packages/spec/scripts/category-title.test.ts b/packages/spec/scripts/category-title.test.ts new file mode 100644 index 0000000000..01a8ecfcfd --- /dev/null +++ b/packages/spec/scripts/category-title.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the declared protocol-module titles (#5853) from both ends. + * + * The bug was `qa` rendering as **"Qa Protocol"** in three published places at + * once, because `build-docs.ts` guessed the title from the directory name and + * upper-cased only the three abbreviations someone had thought of + * (`['UI', 'AI', 'API']`). `qa` is the fourth. + * + * What makes this worth more than one line of test: the wrong title was + * **undetectable**. `check:docs` compares generation against what was + * committed, so a stable wrong title is green forever — `Qa Protocol` survived + * every regeneration from the day `src/qa/` was created until #4759 printed 14 + * titles side by side and a human read it. So the pin has to cover the shape, + * not just the instance: + * + * - **the table** — `qa` resolves to `QA Protocol`, every abbreviation module + * keeps its capitalisation, and the declared keys match the directories on + * disk in both directions; + * - **the visibility property** the declared table was chosen for — a module + * directory with no entry is REPORTED BY NAME, where the old shape silently + * invented `Iam Protocol`. This is the assertion that would have to be + * deleted, not merely edited, to bring the silence back; + * - **the artifacts** — the three committed landing sites. Without these, + * deleting the generator's use of the table would leave every unit test + * green while the published pages went back to `Qa Protocol` (the same + * reason `root-index.test.ts` asserts over the committed `.mdx`). + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + CATEGORY_TITLES, + categoryTitleCoverage, + formatCategoryTitleCoverage, + resolveCategoryTitles, +} from './lib/category-title'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = resolve(HERE, '../src'); +const DOCS_ROOT = resolve(HERE, '../../../content/docs/references'); + +const moduleDirsOnDisk = () => + readdirSync(SRC_DIR) + .filter(entry => statSync(join(SRC_DIR, entry)).isDirectory()) + .sort(); + +describe('CATEGORY_TITLES', () => { + it('titles qa as "QA Protocol" — the abbreviation the derived shape downgraded (#5853)', () => { + expect(CATEGORY_TITLES.qa).toBe('QA Protocol'); + expect(CATEGORY_TITLES.qa).not.toBe('Qa Protocol'); + }); + + it('keeps every abbreviation module upper-cased, not title-cased', () => { + // The four abbreviations among the module directories. `qa` sat outside the + // old hard-coded trio for no reason other than nobody having added it, and + // `src/qa/index.ts` itself opens with "Quality Assurance (QA) Protocol". + expect({ + ai: CATEGORY_TITLES.ai, + api: CATEGORY_TITLES.api, + qa: CATEGORY_TITLES.qa, + ui: CATEGORY_TITLES.ui, + }).toEqual({ + ai: 'AI Protocol', + api: 'API Protocol', + qa: 'QA Protocol', + ui: 'UI Protocol', + }); + }); + + it('declares a title for exactly the module directories under packages/spec/src', () => { + // Both directions, against the real tree — the same rule `blurbCoverage` + // holds the root-index blurbs to. A missing entry is the bug this file + // exists for; a stale entry is a display name outliving its directory. + expect(Object.keys(CATEGORY_TITLES).sort()).toEqual(moduleDirsOnDisk()); + }); + + it('resolves the real tree without throwing, and titles every directory in it', () => { + const dirs = moduleDirsOnDisk(); + const resolved = resolveCategoryTitles(dirs); + + expect(Object.keys(resolved).sort()).toEqual(dirs); + expect(Object.values(resolved).every(title => title.length > 0)).toBe(true); + }); +}); + +describe('categoryTitleCoverage — the property the declared table was chosen for', () => { + const declared = { ai: 'AI Protocol', qa: 'QA Protocol' }; + + it('is silent when the declarations and the directories agree', () => { + expect(categoryTitleCoverage(['ai', 'qa'], declared)).toEqual({ missing: [], extra: [] }); + }); + + it('REPORTS a new module directory instead of inventing a title for it', () => { + // The regression this replaces, in its next incarnation: under the old + // "upper-case then look the abbreviation up in a list" shape, a new `iam` + // directory silently published "Iam Protocol" and no gate could see it, + // because a wrong title is a stable one. Now the run cannot produce a + // title at all until someone declares it. + const coverage = categoryTitleCoverage(['ai', 'iam', 'qa'], declared); + + expect(coverage.missing).toEqual(['iam']); + expect(coverage.extra).toEqual([]); + expect(() => resolveCategoryTitles(['ai', 'iam', 'qa'], declared)).toThrow(/iam/); + }); + + it('reports a title whose directory is gone, so a display name cannot outlive it', () => { + const coverage = categoryTitleCoverage(['ai'], declared); + + expect(coverage.missing).toEqual([]); + expect(coverage.extra).toEqual(['qa']); + expect(() => resolveCategoryTitles(['ai'], declared)).toThrow(/qa/); + }); + + it('names the directory, the file to edit, and the line to add', () => { + // A message that only said "coverage mismatch" would send the reader back + // to the guessing shape to work out what to write. + const message = formatCategoryTitleCoverage(categoryTitleCoverage(['ai', 'iam', 'qa'], declared)); + + expect(message).toContain('iam'); + expect(message).toContain('scripts/lib/category-title.ts'); + expect(message).toContain('add one line'); + }); +}); + +describe('the committed landing sites', () => { + // The three places the title is published, per #5853. Read from disk rather + // than regenerated: this is the assertion that fails if the generator stops + // using the table, or if someone hand-edits a generated page back. + const read = (rel: string) => { + const abs = join(DOCS_ROOT, rel); + expect(existsSync(abs), `${rel} should exist`).toBe(true); + return readFileSync(abs, 'utf-8'); + }; + + it('gives the qa category page the title "QA Protocol"', () => { + expect(read('qa/index.mdx')).toContain('title: QA Protocol'); + }); + + it('gives the qa sidebar entry the label "QA Protocol"', () => { + expect(JSON.parse(read('qa/meta.json')).title).toBe('QA Protocol'); + }); + + it('spells it "QA Protocol" in the root index nav row and section heading', () => { + const index = read('index.mdx'); + + expect(index).toContain('[QA Protocol](/docs/references/qa)'); + expect(index).toContain('## QA Protocol'); + }); + + it('leaves no "Qa Protocol" anywhere under content/docs/references', () => { + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + walk(abs); + } else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.json')) { + if (readFileSync(abs, 'utf-8').includes('Qa Protocol')) offenders.push(abs); + } + } + }; + walk(DOCS_ROOT); + + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/spec/scripts/lib/category-title.ts b/packages/spec/scripts/lib/category-title.ts new file mode 100644 index 0000000000..512cdb95e4 --- /dev/null +++ b/packages/spec/scripts/lib/category-title.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The display title of each protocol module — DECLARED, never derived (#5853). + * + * ## What was here before, and why it could only keep failing + * + * The title used to be guessed from the directory name: + * + * ```ts + * const upper = dir.toUpperCase(); + * if (['UI', 'AI', 'API'].includes(upper)) return `${upper} Protocol`; + * return `${dir.charAt(0).toUpperCase() + dir.slice(1)} Protocol`; + * ``` + * + * Title-case by default, with a hard-coded escape hatch for the three + * abbreviations someone happened to think of. `qa` is an abbreviation too — + * `src/qa/index.ts` opens with "Quality Assurance (QA) Protocol" — but it was + * never added, so the generator unilaterally downgraded it and published + * **"Qa Protocol"** to three places at once: the category page title, the + * sidebar label in `qa/meta.json`, and (since #4759 folded the root index into + * generation) two rows of `references/index.mdx`. + * + * The measurement that decided the shape rather than the one-line fix: + * + * - `packages/spec/src/` holds **17** module directories, discovered at run + * time by `readdirSync` — nothing anywhere prompts an author to extend a + * list when they add one. + * - **4 of those 17 are abbreviations** (`ai`, `api`, `ui`, `qa`). The list + * covered 3. A 25% miss rate on the single class it exists to serve. + * - **No gate could ever have caught it.** `check:docs` compares generated + * output against committed output; a wrong title is *stable*, so it is + * green forever. `Qa Protocol` survived every regeneration since the + * directory was created and was found by eye only once #4759 printed 14 + * titles side by side, `Qa Protocol` wedged between `AI` / `API` / `UI`. + * + * So the failure mode is not "one abbreviation was forgotten"; it is that a + * guessed title is **undetectably wrong**. Adding `QA` to the list fixes the + * one instance and leaves the next `iam` / `rbac` / `sso` directory to publish + * "Iam Protocol" with the same silence. + * + * ## The shape, and the one property it has to buy + * + * Moving the guess into a lookup table with a fallback would buy nothing — a + * missing entry would still fall through to title-case, still silently. The + * point of declaring the titles is that a missing entry is **visible**: + * + * - the map is **total** over the directories on disk. There is no fallback + * and no derivation left, so there is nothing left to be quietly wrong; + * - {@link categoryTitleCoverage} checks it in **both** directions and the + * caller stops the build, so a new directory fails `gen:docs` with a + * message naming it, and an entry cannot outlive the directory it titles. + * + * This is deliberately the idiom `CATEGORY_BLURBS` already uses one datum + * over (`blurbCoverage` / `formatBlurbCoverage` in `lib/root-index.ts`, #4759) + * — the title was simply the last per-category datum still being guessed + * instead of declared. It costs a new module directory exactly one line, in + * the same PR that must already add its blurb line, and the error message says + * which line to add. + */ + +/** + * One declared title per `packages/spec/src/` module directory. + * + * Keys are the directory names — the stable identifier the pages, the sidebar + * and the root index are all keyed by. Held to the directories on disk in both + * directions by {@link categoryTitleCoverage}; do not add an entry + * speculatively, and do not delete a directory without deleting its line. + */ +export const CATEGORY_TITLES: Readonly> = { + ai: 'AI Protocol', + api: 'API Protocol', + automation: 'Automation Protocol', + cloud: 'Cloud Protocol', + contracts: 'Contracts Protocol', + conversions: 'Conversions Protocol', + data: 'Data Protocol', + identity: 'Identity Protocol', + integration: 'Integration Protocol', + kernel: 'Kernel Protocol', + migrations: 'Migrations Protocol', + qa: 'QA Protocol', + security: 'Security Protocol', + shared: 'Shared Protocol', + studio: 'Studio Protocol', + system: 'System Protocol', + ui: 'UI Protocol', +}; + +/** + * Module directories with no declared title, and titles for directories that + * are not there. + * + * Checked in BOTH directions, for the same reason `blurbCoverage` is. A + * missing title is the `Qa Protocol` failure repeating: without this the + * generator would have to invent one, and an invented title is wrong in a way + * that stays green. A title whose directory is gone is the `src/hub` failure + * in miniature — a display name outliving the thing it names. + */ +export function categoryTitleCoverage( + dirs: readonly string[], + titles: Readonly>, +): { missing: string[]; extra: string[] } { + const onDisk = new Set(dirs); + const declared = new Set(Object.keys(titles)); + + return { + missing: [...onDisk].filter(d => !declared.has(d)).sort(), + extra: [...declared].filter(d => !onDisk.has(d)).sort(), + }; +} + +/** The build-stopping message for {@link categoryTitleCoverage}. */ +export function formatCategoryTitleCoverage(coverage: { missing: string[]; extra: string[] }): string { + return ( + `CATEGORY_TITLES in scripts/lib/category-title.ts does not match the module directories under packages/spec/src/:\n\n` + + [ + ...coverage.missing.map(d => ` + ${d} (directory exists, no title — add one line: ${d}: '...')`), + ...coverage.extra.map(d => ` - ${d} (title, no directory — delete the line)`), + ].join('\n') + + `\n\nEvery reference page title, sidebar label and root-index row for a module is this string.\n` + + `It is declared rather than derived on purpose: a title guessed from the directory name is\n` + + `wrong for every abbreviation and no gate can see it, because a wrong title is a STABLE one\n` + + `and check:docs only compares generation against what was committed. That is how "Qa Protocol"\n` + + `stayed published across every regeneration until a human read the nav table (#5853).\n` + + `Write the module's real display name — the abbreviations are spelled the way the module\n` + + `spells itself (AI, API, QA, UI), not the way title-case would.\n` + ); +} + +/** + * The `directory -> title` map `build-docs.ts` runs on, or a thrown error. + * + * Total by construction: every directory handed in gets its declared title, and + * a gap is an exception rather than a fallback. The caller reports it with + * {@link formatCategoryTitleCoverage} and exits. + */ +export function resolveCategoryTitles( + dirs: readonly string[], + titles: Readonly> = CATEGORY_TITLES, +): Record { + const coverage = categoryTitleCoverage(dirs, titles); + if (coverage.missing.length > 0 || coverage.extra.length > 0) { + throw new Error(formatCategoryTitleCoverage(coverage)); + } + return Object.fromEntries(dirs.map(dir => [dir, titles[dir]])); +}