From 5e420af314540886be4f98e3b20e1019c615d356 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:49:55 +0000 Subject: [PATCH] fix(metadata,docs): align the protocol declaration and the inventory counts with the stack (#728 #729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `objectstack.config.ts` declared `engines.protocol: "^17.0.0-rc.1"` while `objectstack.manifest.json` and the `@objectstack/*` dependency line had both moved to `17.0.0-rc.3`. Two consecutive platform upgrades bumped the manifest and the dependencies and left the config behind, so `dist/objectstack.json` advertised a protocol the app was not authored against. Only the major participates in the handshake, so nothing failed — which is why it survived two releases. The README banner, the "What you get" table, the fork guide and the docs overview still advertised 15 objects / 23 flows / 4 dashboards. Measured on this tree: 17 objects, 24 flows, 5 dashboards, 9 datasets. `crm_event` and `crm_event_attendee` join the README object table; the getting-started page, a generation further behind at 13 objects, is brought along. Every affected page is updated in all three locale faces. `whats-new` keeps its numbers — it is a dated record of what v1.0 shipped, not a claim about today. `actions` is deliberately left alone: the docs say 13 and the stack registers 26, and the two count different things (an action bound to five objects registers five times). Which number a reader should be told is a product call. Both classes are now gated in `test/docs-drift.test.ts`. One rule holds the config, the template manifest and the installed `@objectstack/spec` to a single protocol version. The other compares every count a doc states — English and Chinese spellings alike — against counts read from the registered stack at test time, never against a number written into the test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa --- ...ersion-and-count-claims-match-the-stack.md | 32 +++ README.md | 14 +- content/docs/getting-started/introduction.mdx | 2 +- .../getting-started/introduction.zh-Hans.mdx | 2 +- .../getting-started/introduction.zh-Hant.mdx | 2 +- content/docs/index.mdx | 2 +- content/docs/index.zh-Hans.mdx | 2 +- content/docs/index.zh-Hant.mdx | 2 +- content/docs/marketplace/fork-hotcrm.mdx | 2 +- .../docs/marketplace/fork-hotcrm.zh-Hans.mdx | 2 +- .../docs/marketplace/fork-hotcrm.zh-Hant.mdx | 2 +- objectstack.config.ts | 7 +- test/docs-drift.test.ts | 261 ++++++++++++++++++ 13 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 .changeset/version-and-count-claims-match-the-stack.md diff --git a/.changeset/version-and-count-claims-match-the-stack.md b/.changeset/version-and-count-claims-match-the-stack.md new file mode 100644 index 00000000..66cad804 --- /dev/null +++ b/.changeset/version-and-count-claims-match-the-stack.md @@ -0,0 +1,32 @@ +--- +'hotcrm': patch +--- + +Say the right protocol version and the right inventory, and gate both (#728, #729). + +Two claims the app makes about itself had drifted away from the app, and both +shipped to customers — one in the published artifact, one on the first page a +prospective customer reads. + +`objectstack.config.ts` still declared `engines.protocol: "^17.0.0-rc.1"` while +`objectstack.manifest.json` and the `@objectstack/*` dependency line had moved to +`17.0.0-rc.3`. Two platform upgrades in a row bumped the manifest and the +dependencies and left the config behind, so `dist/objectstack.json` advertised a +protocol version the app was not authored against. Only the major participates in +the runtime handshake, so nothing ever failed — which is why it survived two +releases. It now reads `^17.0.0-rc.3`. + +The README banner, the "What you get" table, the fork guide and the docs overview +still advertised 15 objects / 23 flows / 4 dashboards. The real inventory is 17 +objects, 24 flows, 5 dashboards and 9 datasets: `crm_event` and +`crm_event_attendee` joined the object table, and the getting-started page — which +was a generation further behind at 13 objects — now agrees with the rest. All +three locale faces of every affected page are updated. The `whats-new` page keeps +its numbers: it is a dated record of what v1.0 shipped, not a claim about today. + +Both classes are now gated in `test/docs-drift.test.ts` rather than left to a +comment. One rule holds `objectstack.config.ts`, `objectstack.manifest.json` and +the installed `@objectstack/spec` to a single protocol version. The other compares +every count a doc states — English and Chinese spellings alike — against the +counts read from the registered stack at test time, never against a number written +down in the test, so the guard cannot go stale the way the docs did. diff --git a/README.md b/README.md index 43da6b6f..d24351c1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # HotCRM > **The reference app for AI-written enterprise software.** A complete CRM — -> 15 objects, 23 flows, 4 dashboards, 6 AI skills, 4 languages — is roughly +> 17 objects, 24 flows, 5 dashboards, 6 AI skills, 4 languages — is roughly > **170k tokens** of typed [ObjectStack](https://github.com/objectstack-ai/objectstack) > metadata (~18,000 lines): the entire enterprise CRM fits in a single agent > context window, so an AI can hold it whole, reason about it, and refactor it. @@ -45,7 +45,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application* ## ✨ What you get -**15 business objects** spanning the full Lead-to-Cash cycle: +**17 business objects** spanning the full Lead-to-Cash cycle: | Sales | Service | Marketing | Revenue | |---|---|---|---| @@ -56,8 +56,10 @@ HotCRM is a complete, opinionated CRM built as the **first official application* | `crm_opportunity_line_item` | | | | | `crm_product` | | | | | `crm_forecast` | | | | +| `crm_event` | | | | +| `crm_event_attendee` | | | | -Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **4 dashboards**, **23 flows**, **13 actions**, **8 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. +Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **24 flows**, **13 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. > **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page. @@ -124,12 +126,12 @@ See [docs: Publishing your first marketplace app](content/docs/marketplace/publi hotcrm/ ├── objectstack.config.ts # manifest + defineStack() — single source of truth ├── src/ -│ ├── objects/ # *.object.ts — data model (15 objects) +│ ├── objects/ # *.object.ts — data model (17 objects) │ ├── actions/ # *.actions.ts — server actions + AI tools (13) -│ ├── flows/ # *.flow.ts — visual flows (23): screen, record-change & scheduled +│ ├── flows/ # *.flow.ts — visual flows (24): screen, record-change & scheduled │ ├── hooks/ # hook registry barrel │ ├── skills/ # *.skill.ts — AI skills (6) — skills-only surface, no agents -│ ├── datasets/ # *.dataset.ts — analytics semantic layer (8) +│ ├── datasets/ # *.dataset.ts — analytics semantic layer (9) │ ├── dashboards/, reports/ # analytics UI │ ├── pages/, views/, apps/ # UI definitions │ ├── profiles/, sharing/ # security diff --git a/content/docs/getting-started/introduction.mdx b/content/docs/getting-started/introduction.mdx index bf845d7a..69e464d1 100644 --- a/content/docs/getting-started/introduction.mdx +++ b/content/docs/getting-started/introduction.mdx @@ -54,7 +54,7 @@ If you're a developer, see [For Developers](/docs/getting-started/for-developers The system you get on day one already includes: -- **13 business objects** (account, lead, contact, opportunity, product, quote, contract, case, campaign, and more) with industry-standard fields, picklists, and validations. +- **17 business objects** (account, lead, contact, opportunity, product, quote, contract, case, campaign, and more) with industry-standard fields, picklists, and validations. - **A 10-role hierarchy** mapped to typical sales / service / marketing org structures. - **Built-in automation** for lead routing, opportunity wins, contract expiration, quote expiration, campaign auto-enrollment, and SLA escalation. - **Approval processes** for high-value discounts. diff --git a/content/docs/getting-started/introduction.zh-Hans.mdx b/content/docs/getting-started/introduction.zh-Hans.mdx index b3207219..c8a875e2 100644 --- a/content/docs/getting-started/introduction.zh-Hans.mdx +++ b/content/docs/getting-started/introduction.zh-Hans.mdx @@ -54,7 +54,7 @@ HotCRM 运行于 [**ObjectStack**](https://github.com/objectstack-ai/framework) 你第一天拿到的系统已经包含: -- **13 个业务对象**(客户、线索、联系人、商机、产品、报价单、合同、案例、营销活动等),具备行业标准字段、选择列表和验证。 +- **17 个业务对象**(客户、线索、联系人、商机、产品、报价单、合同、案例、营销活动等),具备行业标准字段、选择列表和验证。 - **10 级角色层级**,映射到典型的销售 / 服务 / 市场营销组织结构。 - **内置自动化**,用于线索路由、商机赢单、合同到期、报价单到期、营销活动自动注册和 SLA 升级。 - **审批流程**,用于高价值折扣。 diff --git a/content/docs/getting-started/introduction.zh-Hant.mdx b/content/docs/getting-started/introduction.zh-Hant.mdx index 557c7786..e979f937 100644 --- a/content/docs/getting-started/introduction.zh-Hant.mdx +++ b/content/docs/getting-started/introduction.zh-Hant.mdx @@ -50,7 +50,7 @@ HotCRM 執行於 [**ObjectStack**](https://github.com/objectstack-ai/framework) 你第一天拿到的系統已經包含: -- **13 個業務物件**(客戶、線索、聯絡人、商機、產品、報價單、合約、案例、行銷活動等),具備產業標準欄位、選擇清單和驗證。 +- **17 個業務物件**(客戶、線索、聯絡人、商機、產品、報價單、合約、案例、行銷活動等),具備產業標準欄位、選擇清單和驗證。 - **10 級角色層級**,映射到典型的銷售 / 服務 / 市場行銷組織結構。 - **內建自動化**,用於線索路由、商機贏單、合約到期、報價單到期、行銷活動自動註冊和 SLA 升級。 - **審批流程**,用於高價值折扣。 diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 75a264c1..c3c76112 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -66,7 +66,7 @@ This documentation is organised by **what you want to do**. Pick the section tha | **Marketing** | Campaigns (8 types), Campaign Members (lead/contact enrollment), ROI tracking | | **Revenue** | Products, Contracts (5 types), Discount Approvals | | **AI** | 6 skills on the platform assistant (live data, lead qualification, case triage, email drafting, revenue forecasting, customer 360) | -| **Analytics** | 4 dashboards (CRM / Sales / Service / Executive), 10+ standard reports | +| **Analytics** | 5 dashboards (CRM / Sales / Sales Activity / Service / Executive), 10+ standard reports | | **Security** | 6 user profiles, role hierarchy, sharing rules, multi-step approvals | ## Roles & where to start diff --git a/content/docs/index.zh-Hans.mdx b/content/docs/index.zh-Hans.mdx index 9d6ceceb..6de2d53b 100644 --- a/content/docs/index.zh-Hans.mdx +++ b/content/docs/index.zh-Hans.mdx @@ -65,7 +65,7 @@ description: AI 原生的企业级 CRM——业务变化时它会自我重写。 | **市场** | 营销活动(8 种类型)、营销活动成员(线索/联系人纳入)、ROI 追踪 | | **营收** | 产品、合同(5 种类型)、折扣审批 | | **AI** | 平台助手上的 6 项技能(实时数据、线索资格判定、工单分流、邮件起草、营收预测、客户 360) | -| **分析** | 4 个仪表盘(CRM / 销售 / 服务 / 高管)、10+ 标准报表 | +| **分析** | 5 个仪表盘(CRM / 销售 / 销售活动 / 服务 / 高管)、10+ 标准报表 | | **安全** | 6 种用户权限档案、角色层级、共享规则、多步审批 | ## 角色与起步路径 diff --git a/content/docs/index.zh-Hant.mdx b/content/docs/index.zh-Hant.mdx index 20a210fc..6822330f 100644 --- a/content/docs/index.zh-Hant.mdx +++ b/content/docs/index.zh-Hant.mdx @@ -65,7 +65,7 @@ description: AI 原生的企業級 CRM——業務變化時它會自我重寫。 | **行銷** | 行銷活動(8 種類型)、行銷活動成員(潛客/聯絡人納入)、ROI 追蹤 | | **營收** | 產品、合約(5 種類型)、折扣審批 | | **AI** | 平台助手上的 6 項技能(即時資料、潛客資格判定、工單分流、郵件草擬、營收預測、客戶 360) | -| **分析** | 4 個儀表板(CRM / 銷售 / 服務 / 高階主管)、10+ 標準報表 | +| **分析** | 5 個儀表板(CRM / 銷售 / 銷售活動 / 服務 / 高階主管)、10+ 標準報表 | | **安全** | 6 種使用者權限設定檔、角色層級、共用規則、多步審批 | ## 角色與起步路徑 diff --git a/content/docs/marketplace/fork-hotcrm.mdx b/content/docs/marketplace/fork-hotcrm.mdx index 000b2d9d..c4bf183a 100644 --- a/content/docs/marketplace/fork-hotcrm.mdx +++ b/content/docs/marketplace/fork-hotcrm.mdx @@ -74,7 +74,7 @@ Then update `src/translations/index.ts` to reference the new filename. ### 4. Cut what you don't need -HotCRM ships 15 objects across Sales / Service / Marketing / Revenue. Most apps need fewer: +HotCRM ships 17 objects across Sales / Service / Marketing / Revenue. Most apps need fewer: ```bash # Example: keep only Sales-related objects diff --git a/content/docs/marketplace/fork-hotcrm.zh-Hans.mdx b/content/docs/marketplace/fork-hotcrm.zh-Hans.mdx index e7af2fbc..d8297f80 100644 --- a/content/docs/marketplace/fork-hotcrm.zh-Hans.mdx +++ b/content/docs/marketplace/fork-hotcrm.zh-Hans.mdx @@ -74,7 +74,7 @@ mv src/translations/crm.translation.ts src/translations/acme.translation.ts ### 4. 删除你不需要的部分 -HotCRM 在销售 / 服务 / 营销 / 营收领域提供了 15 个对象。大多数应用需要更少: +HotCRM 在销售 / 服务 / 营销 / 营收领域提供了 17 个对象。大多数应用需要更少: ```bash # Example: keep only Sales-related objects diff --git a/content/docs/marketplace/fork-hotcrm.zh-Hant.mdx b/content/docs/marketplace/fork-hotcrm.zh-Hant.mdx index 06953081..7a153325 100644 --- a/content/docs/marketplace/fork-hotcrm.zh-Hant.mdx +++ b/content/docs/marketplace/fork-hotcrm.zh-Hant.mdx @@ -74,7 +74,7 @@ mv src/translations/crm.translation.ts src/translations/acme.translation.ts ### 4. 刪除你不需要的部分 -HotCRM 在銷售 / 服務 / 行銷 / 營收領域提供了 15 個物件。大多數應用程式需要更少: +HotCRM 在銷售 / 服務 / 行銷 / 營收領域提供了 17 個物件。大多數應用程式需要更少: ```bash # Example: keep only Sales-related objects diff --git a/objectstack.config.ts b/objectstack.config.ts index b3b7350d..ed2daaf5 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -41,8 +41,11 @@ export default defineStack({ // load with a structured OS_PROTOCOL_INCOMPATIBLE diagnostic (naming the // `objectstack migrate meta` replay) instead of failing deep in a schema // parse. Bump together with `specVersion` on every platform upgrade - // (docs/MAINTENANCE.md §3). - engines: { protocol: '^17.0.0-rc.1' }, + // (docs/MAINTENANCE.md §3) — `test/docs-drift.test.ts` now enforces that + // pairing against `objectstack.manifest.json` instead of trusting this + // comment, because two platform upgrades in a row (rc.2, then rc.3) moved + // the manifest and left this line behind (#728). + engines: { protocol: '^17.0.0-rc.3' }, }, // ─── Platform capabilities this app needs ───────────────────────── diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index 2c370912..01ff718d 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -1196,3 +1196,264 @@ describe('the docs print the version the manifest declares (#612)', () => { ).toEqual([]); }); }); + +/* + * ─── One protocol version, declared in three files (#728) ──────────────────── + * + * `objectstack.config.ts` drives the build artifact, `objectstack.manifest.json` + * is the marketplace template manifest, and `package.json` pins the + * `@objectstack/*` line the metadata is authored against. All three state the + * same protocol version, and `objectstack.config.ts`'s own comment has said so + * since it was written: "Bump together with `specVersion` on every platform + * upgrade (docs/MAINTENANCE.md §3)". + * + * A comment is not a gate. Two consecutive platform upgrades moved the template + * manifest and the dependency line and left `objectstack.config.ts` behind — the + * gap was rc.1 vs rc.2 when #728 was filed and had widened to rc.1 vs rc.3 by + * the time it was fixed. Neither `pnpm build` nor `publish:marketplace:dry-run` + * compares the two, so the stale value rode all the way into the published + * artifact (`dist/objectstack.json` → `manifest.engines.protocol`). + * + * Only the MAJOR participates in the runtime handshake, so this never blocked a + * boot — which is precisely why nothing caught it for two releases. It is still + * a metadata fact distributed to customers, and this turns the file's own + * maintenance rule from a convention into a gate. + * + * Reverse verification: predicted and measured **red before, green after**. On + * the pre-fix tree the first rule reported `objectstack.config.ts declares + * ^17.0.0-rc.1, objectstack.manifest.json declares ^17.0.0-rc.3`; the other two + * rules were already green, so the failure named the one file that was wrong. + */ +describe('one protocol version, declared in three files (#728)', () => { + const TEMPLATE = 'objectstack.manifest.json'; + + const configProtocol: string | undefined = (((stack as any).manifest ?? {}).engines ?? {}).protocol; + const template = JSON.parse(readFileSync(join(REPO_ROOT, TEMPLATE), 'utf8')) as { + specVersion?: string; + engines?: { protocol?: string }; + }; + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + const installedSpec: string | undefined = (pkg.dependencies ?? {})['@objectstack/spec']; + + /** `^17.0.0-rc.3` and `17.0.0-rc.3` state the same version. */ + const bare = (range: string | undefined): string => (range ?? '').replace(/^[\^~>=<\s]+/, ''); + + const SEMVERISH = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + + it('all three sources state a version this rule can compare', () => { + // Vacuity guard: any of these going absent would make every comparison below + // compare '' with '' and pass over nothing at all. + const sources: [string, string | undefined][] = [ + ['objectstack.config.ts manifest.engines.protocol', configProtocol], + [`${TEMPLATE} specVersion`, template.specVersion], + [`${TEMPLATE} engines.protocol`, template.engines?.protocol], + ['package.json dependencies["@objectstack/spec"]', installedSpec], + ]; + const unreadable = sources + .filter(([, value]) => !SEMVERISH.test(bare(value))) + .map(([label, value]) => `${label} = ${value ?? '(absent)'}`); + expect( + unreadable, + `protocol/spec declarations this guard cannot read:\n ${unreadable.join('\n ')}\n` + + 'Teach the guard the new shape rather than leaving it green over nothing.', + ).toEqual([]); + }); + + it('objectstack.config.ts declares the protocol the template manifest declares', () => { + expect( + bare(configProtocol), + `objectstack.config.ts declares ${configProtocol}, ${TEMPLATE} declares ` + + `${template.engines?.protocol}. These ship as one app: objectstack.config.ts drives ` + + `dist/objectstack.json's manifest.engines.protocol, and ${TEMPLATE} is what the ` + + 'marketplace reads. Bump both together (docs/MAINTENANCE.md §3).', + ).toBe(bare(template.engines?.protocol)); + }); + + it('the template manifest states one version in both of its fields', () => { + expect( + bare(template.specVersion), + `${TEMPLATE} states specVersion ${template.specVersion} and engines.protocol ` + + `${template.engines?.protocol} — one file, two spellings of a single fact.`, + ).toBe(bare(template.engines?.protocol)); + }); + + it('the declared protocol is the @objectstack/spec version package.json installs', () => { + // AGENTS.md §Constraint Checklist → Dependencies: "Keep `specVersion` in + // `objectstack.manifest.json` aligned with the installed `@objectstack/spec`." + // This is the half that catches an upgrade moving the dependency line and + // forgetting the metadata — the shape of #728 on both the rc.2 and rc.3 bumps. + expect( + bare(template.specVersion), + `${TEMPLATE} declares ${template.specVersion} but package.json installs ` + + `@objectstack/spec ${installedSpec}. The app claims to be authored against a spec ` + + 'version it does not depend on (AGENTS.md §Constraint Checklist → Dependencies).', + ).toBe(bare(installedSpec)); + }); +}); + +/* + * ─── Product docs count the metadata the stack registers (#729) ────────────── + * + * The README banner, the "What you get" section, the fork guide and the docs + * overview all state how many objects / flows / dashboards / datasets HotCRM + * ships. Every one of those numbers was written by hand and then left behind: + * #592 added `crm_event` and `crm_event_attendee` plus a fifth dashboard, and + * the docs still advertised 15 objects / 23 flows / 4 dashboards two releases + * later. The README banner is the first thing a reader sees, and the fork guide + * is what a customer follows — being told the wrong inventory there is a product + * defect, not a typo. + * + * The expected values are read from `objectstack.config.ts` at test time and are + * deliberately NOT written down here. A hard-coded expectation is just the same + * hand-maintained number moved into the test file: it would go stale on the very + * next object and take the guard with it. + * + * `actions` is deliberately absent. The docs say 13, the registered stack says + * 26, and the two are counting different things — an action bound to five + * objects registers five times (#729's closing note). Which one a reader should + * be told is a product call, so this guard does not pick a side; it also does + * not pretend to cover the number. See the out-of-scope note on #729. + * + * Reverse verification: predicted and measured **red before, green after**. On + * the pre-fix tree the rule listed nine drifted claims across seven files + * (README ×5, fork-hotcrm ×3 locales, index ×3 locales, introduction ×3 + * locales); after the fix the only surviving `15` is the exempt v1.0 record. + */ +describe('product docs state the metadata counts the stack registers (#729)', () => { + const registered = stack as unknown as Record; + + /** Read from the registered stack — never hard-coded. */ + const REGISTERED: Record = { + objects: (registered.objects ?? []).length, + flows: (registered.flows ?? []).length, + dashboards: (registered.dashboards ?? []).length, + datasets: (registered.datasets ?? []).length, + }; + + /** + * Every way the docs spell a count, one pattern per spelling so a failure + * names the sentence it read. Three locale faces ship for each page, and the + * translated ones spell the noun in Chinese — a rule that only reads English + * guards one third of the surface (#725 taught this file the same lesson about + * dashboard tiles). + */ + const CLAIMS: { kind: keyof typeof REGISTERED; re: RegExp }[] = [ + { kind: 'objects', re: /(\d+) business objects/g }, + { kind: 'objects', re: /(\d+) objects across/g }, + { kind: 'objects', re: /(\d+) objects,/g }, + { kind: 'objects', re: /data model \((\d+) objects\)/g }, + { kind: 'objects', re: /(\d+) 个业务对象/g }, + { kind: 'objects', re: /(\d+) 個業務物件/g }, + { kind: 'objects', re: /(\d+) 个对象/g }, + { kind: 'objects', re: /(\d+) 個物件/g }, + { kind: 'flows', re: /(\d+) flows/g }, + { kind: 'flows', re: /visual flows \((\d+)\)/g }, + { kind: 'dashboards', re: /(\d+) dashboards/g }, + { kind: 'dashboards', re: /(\d+) 个仪表盘/g }, + { kind: 'dashboards', re: /(\d+) 個儀表板/g }, + { kind: 'datasets', re: /(\d+) datasets/g }, + { kind: 'datasets', re: /semantic layer \((\d+)\)/g }, + ]; + + /** + * Pages allowed to state a count that is not today's, each with the reason. + * A map rather than a list, for the reason the persona rule above gives: an + * exemption with no stated reason is how a targeted whitelist becomes a + * blanket one. All three entries are the same page in its three locales, and + * the reason is the same one that exempts it from the persona rule — it is a + * dated release record describing what v1.0 shipped, not a claim about today. + */ + const HISTORICAL: Record = { + 'content/docs/whats-new.mdx': 'the v1.0 release record — the inventory that release shipped', + 'content/docs/whats-new.zh-Hans.mdx': 'the v1.0 release record (zh-Hans)', + 'content/docs/whats-new.zh-Hant.mdx': 'the v1.0 release record (zh-Hant)', + }; + + /** Depth-first walk of a docs tree, REPO_ROOT-relative. */ + const walkMdx = (dir: string): string[] => { + const root = join(REPO_ROOT, dir); + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const rel = join(dir, entry.name); + return entry.isDirectory() ? walkMdx(rel) : rel.endsWith('.mdx') ? [rel] : []; + }); + }; + + const COUNT_DOCS = ['README.md', ...walkMdx('content/docs')]; + + type Claim = { file: string; line: number; kind: string; text: string; stated: number }; + + const claimsIn = (file: string): Claim[] => { + const text = readFileSync(join(REPO_ROOT, file), 'utf8'); + return CLAIMS.flatMap(({ kind, re }) => + [...text.matchAll(re)].map((m) => ({ + file, + line: text.slice(0, m.index ?? 0).split('\n').length, + kind, + text: m[0], + stated: Number(m[1]), + })), + ); + }; + + const ALL_CLAIMS = COUNT_DOCS.flatMap(claimsIn); + + it('the stack registers a count for every kind this rule guards', () => { + // Vacuity guard #1: a config whose shape moved would leave every REGISTERED + // entry at 0, and the rule would then demand the docs say "0" — loud, but + // for the wrong reason. Fail here, where the message is true. + const empty = Object.entries(REGISTERED) + .filter(([, count]) => count === 0) + .map(([kind]) => kind); + expect( + empty, + `objectstack.config.ts registers nothing for: ${empty.join(', ')}. Either the stack ` + + 'shape moved (point REGISTERED at the new field) or the metadata is gone (drop the ' + + 'kind rather than leaving the rule green over an empty set).', + ).toEqual([]); + }); + + it('the scan finds a claim of every kind it guards', () => { + // Vacuity guard #2: a reworded sentence stops matching, and a rule that + // matches nothing agrees with everything. Per-kind, so rewording one noun + // cannot hide behind the other three still matching. + const unclaimed = Object.keys(REGISTERED).filter( + (kind) => !ALL_CLAIMS.some((c) => c.kind === kind), + ); + expect( + unclaimed, + `no doc states a count for: ${unclaimed.join(', ')} — the docs were reworded and this ` + + 'rule now guards nothing for those kinds. Teach CLAIMS the new spelling, or drop the ' + + 'kind deliberately.', + ).toEqual([]); + }); + + it('every exempt page still states a count, so no exemption is dead', () => { + // Same discipline as the persona rule: an exemption that outlives the text it + // excused silently widens the next time that page is edited. + const dead = Object.keys(HISTORICAL).filter( + (file) => !ALL_CLAIMS.some((c) => c.file === file), + ); + expect( + dead, + `HISTORICAL exempts pages that no longer state any count:\n ${dead.join('\n ')}\n` + + 'Drop the exemption — it now only serves to hide the next drift on that page.', + ).toEqual([]); + }); + + it('every count a doc states is the count the stack registers', () => { + const drifted = ALL_CLAIMS.filter( + (c) => !(c.file in HISTORICAL) && c.stated !== REGISTERED[c.kind], + ).map( + (c) => `${c.file}:${c.line} says "${c.text}", the stack registers ${REGISTERED[c.kind]} ${c.kind}`, + ); + expect( + drifted, + `doc counts that no longer match objectstack.config.ts:\n ${drifted.join('\n ')}\n` + + 'Update the doc — the registered stack is the source of truth, and the README banner ' + + 'plus the fork guide are the two pages a prospective customer reads first.', + ).toEqual([]); + }); +});