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
34 changes: 34 additions & 0 deletions .changeset/qa-protocol-category-title.md
Original file line number Diff line number Diff line change
@@ -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"**。
4 changes: 2 additions & 2 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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**

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/qa/index.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: Qa Protocol
title: QA Protocol
description: Complete reference for all qa protocol schemas
---

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/qa/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"title": "Qa Protocol",
"title": "QA Protocol",
"pages": [
"testing"
]
Expand Down
34 changes: 22 additions & 12 deletions packages/spec/scripts/build-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> {
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<string, string>);
const CATEGORIES = loadCategoryTitles();

// Track all zod files per category
const categoryZodFiles = new Map<string, Set<string>>();
Expand Down
170 changes: 170 additions & 0 deletions packages/spec/scripts/category-title.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading