From f54ec4dcfb3c3583d608572351bfac7adcc993b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:29:56 +0000 Subject: [PATCH] docs: write the positions, status and latest-release claims to the source (#1014 #1011 #1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pages made a present-tense claim that had stopped being true, and each had a guard that was looking somewhere else. getting-started/introduction sold "a 10-role hierarchy" in all three locales. The stack registers 12 positions, and ADR-0090 D3 removed the hierarchy itself — positions are flat capability-distribution groups and nothing rolls up. The bullet and the docs-home security row now say that; `positions` joins the count guard's REGISTERED/CLAIMS, read from the stack rather than written down. docs/STATUS.md called itself the source of truth while every figure was stale (16/318 objects/fields against 17/344, 4 dashboards against 5, 23 flows against 24, 13 views against 14, platform rc.1 against the installed rc.3). Its validator transcript is now pinned against the registered stack and its runtime table against package.json. The "Snapshot date" is gone — the page is present tense — and the test row states a verdict, not a size no gate can check. content/docs/whats-new announced `v5.0` on "ObjectStack 5.0" with console@5.0 / account@5.0; the app is 2.2.2 on platform 17.0.0-rc.3 and v5.0 was never either. The section states the real versions and points at the pages that own each area instead of keeping a second hand-copied release table; the v1.0 record is untouched. A new rule holds any "Latest release" section, in every locale, to the manifest version and the installed platform version. The exemption that hid the last of these excused the whole whats-new page as a historical record — right for the v1.0 section, wrong for the one beside it. Exemptions are now scoped to the section they were granted for. --- ...stale-claims-positions-status-whats-new.md | 13 + 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 | 4 +- content/docs/index.zh-Hans.mdx | 4 +- content/docs/index.zh-Hant.mdx | 4 +- content/docs/whats-new.mdx | 74 +-- content/docs/whats-new.zh-Hans.mdx | 58 +- content/docs/whats-new.zh-Hant.mdx | 58 +- docs/STATUS.md | 40 +- test/docs-drift.test.ts | 543 +++++++++++++++++- 12 files changed, 633 insertions(+), 171 deletions(-) create mode 100644 .changeset/stale-claims-positions-status-whats-new.md diff --git a/.changeset/stale-claims-positions-status-whats-new.md b/.changeset/stale-claims-positions-status-whats-new.md new file mode 100644 index 00000000..55929bba --- /dev/null +++ b/.changeset/stale-claims-positions-status-whats-new.md @@ -0,0 +1,13 @@ +--- +'hotcrm': patch +--- + +Three pages that told readers something about "today" had all stopped being true, and each one had a guard that was looking somewhere else. + +**Getting started no longer sells a role hierarchy that was removed.** `getting-started/introduction` advertised "a 10-role hierarchy" in all three locales. Both halves were wrong: the app registers **12** positions, and ADR-0090 D3 removed the hierarchy itself — positions are flat capability-distribution groups, and nothing rolls up from one to another, which is why each rung that needs a record is named by its own sharing rule. The bullet now says that, and the docs home's security row says it too instead of listing "role hierarchy" as a feature. Position counts are now read from the registered stack by the same guard that pins objects, flows, dashboards and datasets. + +**`docs/STATUS.md` is a current-state page again.** It called itself the source of truth for the repo while every figure on it was stale — 16 objects against 17, 318 fields against 344, 4 dashboards against 5, 23 flows against 24, 13 views against 14, and a platform line two release candidates behind the installed `17.0.0-rc.3`. The validator transcript and the runtime-requirements table are now both pinned: the transcript against the registered stack (every label, including ones nobody was checking), the table against `package.json`'s `engines`, `@objectstack/*` line and dev-server port. The misleading "Snapshot date" is gone — the page is present tense and enforced — and the test row now states a verdict rather than a size, because "N files, M tests" moves on nearly every PR and nothing can check it from inside the suite it describes. + +**"What's New" no longer announces a release that never happened.** Its latest-release section claimed `v5.0` on "ObjectStack 5.0", offering `@objectstack/console@5.0` and `@objectstack/account@5.0`; the app is **2.2.2** and the platform is **17.0.0-rc.3**, twelve majors away, and `v5.0` was never either. That section now states the two versions that are actually shipped and points at the pages that own each area, rather than keeping a second, hand-copied release table — the release-by-release history stays in `CHANGELOG.md`, compiled from changesets. The v1.0 record is untouched. A new rule holds any section marked "Latest release", in every locale, to the version the manifest declares and the platform version `package.json` installs. + +The exemption that hid the last of these is also fixed: it excused the whole "What's New" page as a historical record, which was right for the v1.0 section and wrong for the one next to it. Exemptions are now scoped to the section they were granted for. diff --git a/content/docs/getting-started/introduction.mdx b/content/docs/getting-started/introduction.mdx index 69e464d1..47fdec30 100644 --- a/content/docs/getting-started/introduction.mdx +++ b/content/docs/getting-started/introduction.mdx @@ -55,7 +55,7 @@ If you're a developer, see [For Developers](/docs/getting-started/for-developers The system you get on day one already includes: - **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. +- **12 positions** — flat groups covering the usual sales / service / marketing functions, used to distribute record access. Nothing rolls up between them, so each rung that needs a record is named by its own sharing rule. - **Built-in automation** for lead routing, opportunity wins, contract expiration, quote expiration, campaign auto-enrollment, and SLA escalation. - **Approval processes** for high-value discounts. - **Standard list views, dashboards, and reports** for every role. diff --git a/content/docs/getting-started/introduction.zh-Hans.mdx b/content/docs/getting-started/introduction.zh-Hans.mdx index c8a875e2..bae5535a 100644 --- a/content/docs/getting-started/introduction.zh-Hans.mdx +++ b/content/docs/getting-started/introduction.zh-Hans.mdx @@ -55,7 +55,7 @@ HotCRM 运行于 [**ObjectStack**](https://github.com/objectstack-ai/framework) 你第一天拿到的系统已经包含: - **17 个业务对象**(客户、线索、联系人、商机、产品、报价单、合同、案例、营销活动等),具备行业标准字段、选择列表和验证。 -- **10 级角色层级**,映射到典型的销售 / 服务 / 市场营销组织结构。 +- **12 个岗位(position)** —— 覆盖常见销售 / 服务 / 市场营销职能的扁平群体,用于分发记录访问权限。岗位之间什么都不会上卷,每个需要某条记录的层级都由它自己的共享规则点名。 - **内置自动化**,用于线索路由、商机赢单、合同到期、报价单到期、营销活动自动注册和 SLA 升级。 - **审批流程**,用于高价值折扣。 - **标准列表视图、仪表盘和报表**,面向每个角色。 diff --git a/content/docs/getting-started/introduction.zh-Hant.mdx b/content/docs/getting-started/introduction.zh-Hant.mdx index e979f937..fe20b0eb 100644 --- a/content/docs/getting-started/introduction.zh-Hant.mdx +++ b/content/docs/getting-started/introduction.zh-Hant.mdx @@ -51,7 +51,7 @@ HotCRM 執行於 [**ObjectStack**](https://github.com/objectstack-ai/framework) 你第一天拿到的系統已經包含: - **17 個業務物件**(客戶、線索、聯絡人、商機、產品、報價單、合約、案例、行銷活動等),具備產業標準欄位、選擇清單和驗證。 -- **10 級角色層級**,映射到典型的銷售 / 服務 / 市場行銷組織結構。 +- **12 個職位(position)** —— 涵蓋常見銷售 / 服務 / 市場行銷職能的扁平群體,用於分發記錄存取權限。職位之間什麼都不會上捲,每個需要某條記錄的層級都由它自己的共用規則點名。 - **內建自動化**,用於線索路由、商機贏單、合約到期、報價單到期、行銷活動自動註冊和 SLA 升級。 - **審批流程**,用於高價值折扣。 - **標準清單檢視、儀表板和報表**,面向每個角色。 diff --git a/content/docs/index.mdx b/content/docs/index.mdx index c3c76112..2f6fa730 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -8,7 +8,7 @@ description: The AI-Native enterprise CRM that rewrites itself when your busines > **HotCRM is the CRM that rewrites itself when business changes.** > Add a field in the admin console. Ten seconds later, the AI > assistant uses it in its next answer. No retraining, no redeploy. -> [See what's new in v1.0 →](/docs/whats-new) +> [See what's new →](/docs/whats-new) **HotCRM** is a full-featured enterprise CRM with AI as a structural feature, not a bolted-on widget. It helps your team: @@ -67,7 +67,7 @@ This documentation is organised by **what you want to do**. Pick the section tha | **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** | 5 dashboards (CRM / Sales / Sales Activity / Service / Executive), 10+ standard reports | -| **Security** | 6 user profiles, role hierarchy, sharing rules, multi-step approvals | +| **Security** | 6 user profiles, 12 positions (flat — nothing rolls up), 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 6de2d53b..09527a52 100644 --- a/content/docs/index.zh-Hans.mdx +++ b/content/docs/index.zh-Hans.mdx @@ -8,7 +8,7 @@ description: AI 原生的企业级 CRM——业务变化时它会自我重写。 > **HotCRM 是一款会在业务变化时自我重写的 CRM。** > 在管理后台新增一个字段,十秒后 AI 助手就会在下一次回答中用上它。 > 无需重新训练,无需重新部署。 -> [查看 v1.0 新特性 →](/zh-Hans/docs/whats-new) +> [查看新特性 →](/zh-Hans/docs/whats-new) **HotCRM** 是一款功能完整的企业级 CRM,AI 是其结构性能力,而非外挂式小部件。它帮助你的团队: @@ -66,7 +66,7 @@ description: AI 原生的企业级 CRM——业务变化时它会自我重写。 | **营收** | 产品、合同(5 种类型)、折扣审批 | | **AI** | 平台助手上的 6 项技能(实时数据、线索资格判定、工单分流、邮件起草、营收预测、客户 360) | | **分析** | 5 个仪表盘(CRM / 销售 / 销售活动 / 服务 / 高管)、10+ 标准报表 | -| **安全** | 6 种用户权限档案、角色层级、共享规则、多步审批 | +| **安全** | 6 种用户权限档案、12 个岗位(扁平——什么都不会上卷)、共享规则、多步审批 | ## 角色与起步路径 diff --git a/content/docs/index.zh-Hant.mdx b/content/docs/index.zh-Hant.mdx index 6822330f..28d1d791 100644 --- a/content/docs/index.zh-Hant.mdx +++ b/content/docs/index.zh-Hant.mdx @@ -8,7 +8,7 @@ description: AI 原生的企業級 CRM——業務變化時它會自我重寫。 > **HotCRM 是一款會在業務變化時自我重寫的 CRM。** > 在管理後台新增一個欄位,十秒後 AI 助手就會在下一次回答中用上它。 > 無需重新訓練,無需重新部署。 -> [查看 v1.0 新特性 →](/zh-Hant/docs/whats-new) +> [查看新特性 →](/zh-Hant/docs/whats-new) **HotCRM** 是一款功能完整的企業級 CRM,AI 是其結構性能力,而非外掛式小工具。它幫助你的團隊: @@ -66,7 +66,7 @@ description: AI 原生的企業級 CRM——業務變化時它會自我重寫。 | **營收** | 產品、合約(5 種類型)、折扣審批 | | **AI** | 平台助手上的 6 項技能(即時資料、潛客資格判定、工單分流、郵件草擬、營收預測、客戶 360) | | **分析** | 5 個儀表板(CRM / 銷售 / 銷售活動 / 服務 / 高階主管)、10+ 標準報表 | -| **安全** | 6 種使用者權限設定檔、角色層級、共用規則、多步審批 | +| **安全** | 6 種使用者權限設定檔、12 個職位(扁平——什麼都不會上捲)、共用規則、多步審批 | ## 角色與起步路徑 diff --git a/content/docs/whats-new.mdx b/content/docs/whats-new.mdx index 9545c120..c806b3e0 100644 --- a/content/docs/whats-new.mdx +++ b/content/docs/whats-new.mdx @@ -63,53 +63,33 @@ http://localhost:4001 and create the first admin account. --- -## v5.0 — Latest release - -The biggest release yet — a foundational upgrade plus a major documentation overhaul. - -### Platform - -- **🚀 Upgraded to ObjectStack 5.0** across all packages. -- **New** `@objectstack/console@5.0` and `@objectstack/account@5.0` available on npm. -- **Faster cold-start** and **lower memory footprint** in the runtime. - -### Sales - -- **Refined opportunity stages** — clearer probability defaults, better forecast accuracy. -- **Pipeline by Stage** dashboard now includes weighted and unweighted views side by side. -- **Slipping deals report** added — surfaces opportunities whose close date moved out this week. - -### Service - -- **Case triage skill** — the AI Copilot now suggests priority, category, queue, and the top 3 matching KB articles in one click. -- **SLA breach monitor** runs every 5 minutes (down from 15) — faster alerts on at-risk cases. - -### Revenue - -- **Contract Renewal Reminder** flow now triggers based on `renewal_notice_days` on each contract. -- **Discount approval** lock-fields behaviour added — amount/stage/close-date locked during approval. - -### Marketing - -- **Campaign auto-complete** runs nightly at 2 AM. -- **Campaign auto-enroll** added — every Monday at 9 AM, active campaigns re-evaluate their audience. - -### AI Copilot - -- **Six built-in skills**: live data, lead qualification, email drafting, revenue forecasting, customer 360°, case triage. -- **One knowledge article library**: the Knowledge Article object, filed under seven categories, queried by Customer 360°. HotCRM ships no separate Sales / Product / Competitive knowledge bases. -- The Copilot now **respects sharing and field-level security** end-to-end. - -### Analytics - -- **Nine built-in datasets**: Opportunity, Case, Lead, Account, Forecast, Activity, Task, Product and Contact Metrics — the semantic layer every dashboard tile and report binds by name. HotCRM declares no cube of its own: the analytics service compiles each dataset into one internally (ADR-0021), so there are no *Sales* / *Pipeline* / *Service* / *Marketing* cubes to open. -- **Subscription emails** for dashboards and reports — get the weekly digest in your inbox. - -### Documentation - -- 📚 **Documentation rewritten** for a business-and-admin audience — less code, more *"what you see, what you do, what happens automatically"*. -- New sections: **Sales, Service, Marketing, Revenue, AI Copilot, Analytics, Administration**. -- **Customization** section retained for developers building extensions. +## v2.2.2 — Latest release + +**HotCRM 2.2.2**, running on **ObjectStack 17.0.0-rc.3** across every +`@objectstack/*` package. The app manifest declares the matching +`^17.0.0-rc.3` protocol range, so a runtime on a different protocol major +refuses the load up front with a structured diagnostic instead of failing deep +in a schema parse. + +> **This page does not keep a second release table.** The release-by-release +> history is `CHANGELOG.md` in the repository, compiled from the `.changeset/` +> entry every pull request adds. The versions between v1.0 and 2.2.2 are +> recorded there and are deliberately not re-listed here — a hand-maintained +> copy of that history is exactly how this section came to advertise a "v5.0" +> that was never an app version, on a platform major that never shipped it. + +### Where the current shape of the product is written down + +Rather than restate the inventory here — where nothing would check it — each +area is described on the page that owns it, and those pages are held to the +registered metadata by the repository's docs-drift guard: + +- **What ships in the box** — [Introduction](/docs/getting-started/introduction). +- **Dashboards, reports, and the datasets underneath them** — [Analytics](/docs/analytics/index). +- **Profiles, positions, and sharing** — [Sharing & security](/docs/administration/sharing-and-security). +- **The AI surface** — [AI assistant](/docs/ai-copilot/index). HotCRM ships + skills only; they attach to the platform assistant, and the app defines no + agent of its own. --- diff --git a/content/docs/whats-new.zh-Hans.mdx b/content/docs/whats-new.zh-Hans.mdx index 6fc268b3..b7938844 100644 --- a/content/docs/whats-new.zh-Hans.mdx +++ b/content/docs/whats-new.zh-Hans.mdx @@ -45,53 +45,27 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh --- -## v5.0 —— 最新发布 +## v2.2.2 —— 最新发布 -迄今为止最大的一次发布 —— 一次基础性升级,外加一次重大的文档全面改造。 +**HotCRM 2.2.2**,全线 `@objectstack/*` 包运行在 **ObjectStack 17.0.0-rc.3** 上。 +应用清单声明了与之匹配的 `^17.0.0-rc.3` 协议区间,因此协议大版本不同的运行时会在 +装载之初就带着结构化诊断拒绝加载,而不是在某次 schema 解析的深处才失败。 -### 平台 +> **这一页不维护第二张发布表。** 逐版本的发布历史在仓库的 `CHANGELOG.md` 里,由每个 +> 拉取请求补充的 `.changeset/` 条目编译而成。v1.0 与 2.2.2 之间的各个版本都记录在那里, +> 这里刻意不再重列一遍 —— 手工誊抄那份历史,正是这一节最终会宣称一个从来不曾存在过的 +> 应用版本「v5.0」、并把它挂在一个从未发布过它的平台大版本上的原因。 -- **🚀 全线包升级到 ObjectStack 5.0**。 -- **新增** `@objectstack/console@5.0` 和 `@objectstack/account@5.0`,已在 npm 上提供。 -- 运行时**更快的冷启动**和**更低的内存占用**。 +### 产品当前的形态写在哪里 -### 销售 +与其在这里重述一遍清单(这里没有任何东西会核对它),不如让每个领域由真正拥有它的页面 +来描述 —— 而那些页面都由仓库的 docs-drift 守卫按已注册的元数据看着: -- **优化的商机阶段** —— 更清晰的概率默认值,更高的预测准确度。 -- **按阶段划分的管道**仪表盘现在并排包含加权和未加权视图。 -- 新增**流失交易报告** —— 呈现本周关闭日期推后的商机。 - -### 服务 - -- **个案分诊技能** —— AI Copilot 现在一键就能建议优先级、分类、队列以及最匹配的前 3 篇 KB 文章。 -- **SLA 违约监控**每 5 分钟运行一次(从原来的 15 分钟下调)—— 对有风险的个案更快告警。 - -### 营收 - -- **合同续约提醒**流程现在根据每份合同上的 `renewal_notice_days` 触发。 -- 新增**折扣审批**锁字段行为 —— 审批期间金额/阶段/关闭日期被锁定。 - -### 营销 - -- **活动自动完成**每晚凌晨 2 点运行。 -- 新增**活动自动注册** —— 每周一上午 9 点,进行中的活动重新评估其受众。 - -### AI Copilot - -- **六项内置技能**:实时数据、线索资格评估、邮件起草、营收预测、客户 360°、个案分诊。 -- **一个知识文章库**:知识文章对象,按七个分类归档,由 Customer 360° 查询。HotCRM 不附带独立的销售 / 产品 / 竞争情报知识库。 -- Copilot 现在**端到端尊重共享和字段级安全**。 - -### 分析 - -- **九个内置 dataset**:Opportunity、Case、Lead、Account、Forecast、Activity、Task、Product、Contact Metrics —— 每块仪表盘磁贴和每张报表都按名字绑定的那层语义层。HotCRM 自己不声明任何 cube:由分析服务在内部把每个 dataset 编译成 cube(ADR-0021),所以并没有 *Sales* / *Pipeline* / *Service* / *Marketing* 这几个 cube 可打开。 -- 仪表盘和报告的**订阅邮件** —— 在收件箱里收到每周摘要。 - -### 文档 - -- 📚 **文档面向业务和管理受众重写** —— 更少代码,更多*“你看到什么、你做什么、什么会自动发生”*。 -- 新章节:**销售、服务、营销、营收、AI Copilot、分析、管理**。 -- 为构建扩展的开发者保留了**定制**章节。 +- **开箱即包含什么** —— [简介](/zh-Hans/docs/getting-started/introduction)。 +- **仪表盘、报表,以及它们底下的 dataset** —— [分析](/zh-Hans/docs/analytics/index)。 +- **权限档案、岗位与共享** —— [共享与安全](/zh-Hans/docs/administration/sharing-and-security)。 +- **AI 面** —— [AI 助手](/zh-Hans/docs/ai-copilot/index)。HotCRM 只提供技能(skill), + 技能挂在平台助手上;本应用自己不定义任何智能体。 --- diff --git a/content/docs/whats-new.zh-Hant.mdx b/content/docs/whats-new.zh-Hant.mdx index 088e0b48..2debcec5 100644 --- a/content/docs/whats-new.zh-Hant.mdx +++ b/content/docs/whats-new.zh-Hant.mdx @@ -45,53 +45,27 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh --- -## v5.0 —— 最新發行 +## v2.2.2 —— 最新發行 -迄今為止最大的一次發行 —— 一次基礎性升級,外加一次重大的文件全面改造。 +**HotCRM 2.2.2**,全線 `@objectstack/*` 套件執行在 **ObjectStack 17.0.0-rc.3** 上。 +應用清單宣告了與之相符的 `^17.0.0-rc.3` 協定區間,因此協定大版本不同的執行時會在 +載入之初就帶著結構化診斷拒絕載入,而不是在某次 schema 解析的深處才失敗。 -### 平台 +> **這一頁不維護第二張發行表。** 逐版本的發行歷史在儲存庫的 `CHANGELOG.md` 裡,由每個 +> 拉取請求補上的 `.changeset/` 條目編譯而成。v1.0 與 2.2.2 之間的各個版本都記錄在那裡, +> 這裡刻意不再重列一遍 —— 手工謄抄那份歷史,正是這一節最終會宣稱一個從來不曾存在過的 +> 應用版本「v5.0」、並把它掛在一個從未發行過它的平台大版本上的原因。 -- **🚀 全線套件升級到 ObjectStack 5.0**。 -- **新增** `@objectstack/console@5.0` 和 `@objectstack/account@5.0`,已在 npm 上提供。 -- 執行時**更快的冷啟動**和**更低的記憶體佔用**。 +### 產品當前的形態寫在哪裡 -### 銷售 +與其在這裡重述一遍清單(這裡沒有任何東西會核對它),不如讓每個領域由真正擁有它的頁面 +來描述 —— 而那些頁面都由儲存庫的 docs-drift 守衛按已註冊的元資料看著: -- **最佳化的商機階段** —— 更清晰的機率預設值,更高的預測準確度。 -- **依階段劃分的管線**儀表板現在並排包含加權和未加權檢視。 -- 新增**流失交易報告** —— 呈現本週結案日期推後的商機。 - -### 服務 - -- **案例分流技能** —— AI Copilot 現在一鍵就能建議優先順序、分類、佇列以及最相符的前 3 篇 KB 文章。 -- **SLA 違規監控**每 5 分鐘執行一次(從原來的 15 分鐘下調)—— 對有風險的案例更快告警。 - -### 營收 - -- **合約續約提醒**流程現在根據每份合約上的 `renewal_notice_days` 觸發。 -- 新增**折扣簽核**鎖欄位行為 —— 簽核期間金額/階段/結案日期被鎖定。 - -### 行銷 - -- **活動自動完成**每晚凌晨 2 點執行。 -- 新增**活動自動註冊** —— 每週一上午 9 點,進行中的活動重新評估其受眾。 - -### AI Copilot - -- **六項內建技能**:即時資料、潛在客戶資格評估、電子郵件草擬、營收預測、客戶 360°、案例分流。 -- **一個知識文章庫**:知識文章物件,按七個分類歸檔,由 Customer 360° 查詢。HotCRM 不附帶獨立的銷售 / 產品 / 競爭情報知識庫。 -- Copilot 現在**端對端尊重共用和欄位級安全**。 - -### 分析 - -- **九個內建 dataset**:Opportunity、Case、Lead、Account、Forecast、Activity、Task、Product、Contact Metrics —— 每塊儀表板磁貼和每張報表都按名字繫結的那層語義層。HotCRM 自己不宣告任何 cube:由分析服務在內部把每個 dataset 編譯成 cube(ADR-0021),所以並沒有 *Sales* / *Pipeline* / *Service* / *Marketing* 這幾個 cube 可打開。 -- 儀表板和報告的**訂閱電子郵件** —— 在收件匣裡收到每週摘要。 - -### 文件 - -- 📚 **文件面向業務和管理受眾重寫** —— 更少程式碼,更多*「你看到什麼、你做什麼、什麼會自動發生」*。 -- 新章節:**銷售、服務、行銷、營收、AI Copilot、分析、管理**。 -- 為建構擴充功能的開發者保留了**客製化**章節。 +- **開箱即包含什麼** —— [簡介](/zh-Hant/docs/getting-started/introduction)。 +- **儀表板、報表,以及它們底下的 dataset** —— [分析](/zh-Hant/docs/analytics/index)。 +- **權限設定檔、職位與共用** —— [共用與安全](/zh-Hant/docs/administration/sharing-and-security)。 +- **AI 面** —— [AI 助手](/zh-Hant/docs/ai-copilot/index)。HotCRM 只提供技能(skill), + 技能掛在平台助手上;本應用自己不定義任何智慧體。 --- diff --git a/docs/STATUS.md b/docs/STATUS.md index 6cf11848..d66c6370 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,11 @@ # HotCRM Status -> Snapshot date: July 30, 2026 -> Source of truth: `pnpm validate`, `pnpm typecheck`, and `pnpm test` +> **This page states the CURRENT state of `main` — it is not a dated snapshot.** +> Every number below is transcribed from a command you can re-run, and the ones +> that can be derived from the source tree are pinned by `test/docs-drift.test.ts` +> against the registered stack and `package.json`. A count that drifts fails CI +> here instead of quietly ageing on the page. +> Source of truth: `pnpm validate`, `pnpm typecheck`, and `pnpm test`. ## Summary @@ -9,13 +13,14 @@ HotCRM is a single ObjectStack marketplace app at version `2.2.2`. The app manif ## ObjectStack Validation -Latest local validation: +The summary `pnpm validate` prints — every figure read straight off the stack the +loader registers: ```text HotCRM v2.2.2 -Data: 16 Objects 318 Fields -UI: 1 Apps 13 Views 8 Pages 4 Dashboards 10 Reports 13 Actions -Logic: 23 Flows +Data: 17 Objects 344 Fields +UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 26 Actions +Logic: 24 Flows Security: 12 Positions 6 Permissions ``` @@ -25,13 +30,28 @@ Validation command: pnpm validate ``` +> **`26 Actions` is the REGISTRATION count, not a count of distinct action +> definitions.** One action bound to five objects registers five times, so this +> figure and the `13 actions` the README states are answering different +> questions (the source tree holds 6 `*.actions.ts` files, a third figure again). +> Which calibre a reader should be told is an open product question — +> [#1012](https://github.com/objectstack-ai/hotcrm/issues/1012) — and this block +> does not settle it: it transcribes what the command prints, which is the +> registered stack, exactly like every other number in it. + ## Local Checks | Check | Command | Current result | | --- | --- | --- | | ObjectStack metadata validation | `pnpm validate` | Passes | | TypeScript | `pnpm typecheck` | Passes | -| Unit tests | `pnpm test` | 9 files, 97 tests passing | +| Unit tests | `pnpm test` | Passes | + +The test row states a **verdict, not a size**. A "N files, M tests" figure moves +on nearly every PR, and nothing can check it from inside the suite it describes — +which is why the number that used to sit here (`9 files, 97 tests`) was wrong by +an order of magnitude against a suite of 79 files. Run `pnpm test` when you want +the current figure. Run the full project verification pipeline with: @@ -45,9 +65,13 @@ pnpm verify | --- | --- | | Node.js | `>=22` | | pnpm | `>=10.0.0` | -| ObjectStack packages | `17.0.0-rc.1` | +| ObjectStack packages | `17.0.0-rc.3` | | Local dev port | `4001` | +Each row above is asserted against `package.json` (`engines`, the `@objectstack/*` +dependency line, and the `dev` script's port) by `test/docs-drift.test.ts` — this +table is read as present-tense fact, so it is held to one. + ## Current Metadata Inventory | Area | Source | diff --git a/test/docs-drift.test.ts b/test/docs-drift.test.ts index 01ff718d..010a7c69 100644 --- a/test/docs-drift.test.ts +++ b/test/docs-drift.test.ts @@ -1077,6 +1077,55 @@ describe('product docs do not name a retired copilot persona (#612)', () => { }); }); +/** + * `package.json`, parsed once — three rules below read it (the version pair, + * the whats-new latest-release section, and the STATUS.md runtime table). + */ +const PACKAGE_JSON = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { + version?: string; + scripts?: Record; + engines?: Record; + dependencies?: Record; + devDependencies?: Record; +}; + +/** `^17.0.0-rc.3` and `17.0.0-rc.3` state the same version. */ +const bareRange = (range: string | undefined): string => (range ?? '').replace(/^[\^~>=<\s]+/, ''); + +/** + * The `@objectstack/*` versions this app installs, deduplicated. + * + * A SET rather than one package's value, because the docs speak of "ObjectStack + * packages" in the plural and that sentence only means anything while the line + * is uniform — the platform packages are version-locked (AGENTS.md §Platform + * Upgrades: "bump all `@objectstack/*` packages together"). Both rules that use + * this assert the set has exactly one member before comparing to it, so a + * half-finished upgrade fails where the message is true rather than making one + * doc right against one package and wrong against the rest. + */ +const PLATFORM_VERSIONS: string[] = [ + ...new Set( + Object.entries({ ...PACKAGE_JSON.dependencies, ...PACKAGE_JSON.devDependencies }) + .filter(([name]) => name.startsWith('@objectstack/')) + .map(([, range]) => bareRange(range)), + ), +]; + +/** A version that `12.2.2` / `2.2.22` cannot satisfy — see the #612 rule below. */ +const statesVersion = (text: string, version: string): boolean => + new RegExp(`(? { + const out: { heading: string; body: string[] }[] = []; + for (const line of text.split('\n')) { + const m = /^## +(.*)$/.exec(line); + if (m) out.push({ heading: m[1].trim(), body: [] }); + else if (out.length) out[out.length - 1].body.push(line); + } + return out.map((s) => ({ heading: s.heading, body: s.body.join('\n') })); +}; + /** * Version drift — the docs must print the version the app declares (#612). * @@ -1149,10 +1198,9 @@ describe('the docs print the version the manifest declares (#612)', () => { // The two halves RELEASE_STRATEGY.md's own "Version Sources" section tells a // releaser to keep aligned. Cheap to check, and a mismatch here would make // every doc below correct against one source and wrong against the other. - const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as { version: string }; expect( - pkg.version, - `package.json is ${pkg.version} but objectstack.config.ts declares ${VERSION}. ` + + PACKAGE_JSON.version, + `package.json is ${PACKAGE_JSON.version} but objectstack.config.ts declares ${VERSION}. ` + 'These ship as one artifact; align them (docs/RELEASE_STRATEGY.md §Version Sources).', ).toBe(VERSION); }); @@ -1195,6 +1243,119 @@ describe('the docs print the version the manifest declares (#612)', () => { 'green over a page that says nothing.', ).toEqual([]); }); + + /* + * ─── A section marked "Latest release" states the version we ship (#1015) ─── + * + * The rules above cover the MAINTAINER docs. `content/docs/whats-new.mdx` is + * the product-side version page — `content/docs/index.mdx` sends a reader to + * it for "what changed in the latest version" — and it wrote + * `## v5.0 — Latest release`, on "ObjectStack 5.0", offering + * `@objectstack/console@5.0` and `@objectstack/account@5.0` "on npm". Not one + * of those numbers was ever real for this app: the manifest declares 2.2.2 + * and package.json installs 17.0.0-rc.3, twelve majors away. `v5.0` was + * neither an app version nor a platform version, so it dated from nothing at + * all. + * + * It survived because each rule that could have read it had a reason not to. + * VERSION_DOCS lists maintainer pages and this is a product page. The count + * rule (#729) had the whole page exempted as a release record — correctly for + * the v1.0 section it was granted for, and the exemption is now section-scoped + * so it stops covering this one. The persona rule exempts the page too. Three + * guards read this file and all three were told to look away from the one + * section on it that is a claim about TODAY. + * + * "Latest release" is present tense, so it is held to the two facts that are + * mechanically knowable about the current tree — the app version the manifest + * declares, and the platform version package.json installs. Everything else + * in the section is editorial and this rule does not touch it. + * + * The marker table is what makes this read all three locales rather than the + * English page alone (#725's lesson, applied at the point where a heading is + * translated). No separate probe test is needed for it: a marker that stopped + * matching takes its page's section count to zero, which the first rule below + * reports by name. + * + * Reverse verification: predicted and measured **red before, green after** — + * on the pre-fix tree the heading rule reported all three pages stating + * neither 2.2.2 nor 17.0.0-rc.3 in their latest-release section. Captured + * output is in the PR. + */ + const WHATS_NEW_PAGES = [ + 'content/docs/whats-new.mdx', + 'content/docs/whats-new.zh-Hans.mdx', + 'content/docs/whats-new.zh-Hant.mdx', + ]; + + /** How each locale marks the section as being about the current release. */ + const LATEST_MARKERS = ['Latest release', '最新发布', '最新發行']; + + const latestSectionsOf = (file: string): { heading: string; body: string }[] => + h2Sections(readFileSync(join(REPO_ROOT, file), 'utf8')).filter((s) => + LATEST_MARKERS.some((marker) => s.heading.includes(marker)), + ); + + it('every locale of whats-new marks exactly one section as the latest release', () => { + // Vacuity guard, both directions. Zero sections reads exactly like every + // section agreeing — the state a renamed or retranslated heading would put + // this rule in, silently. Two means the page claims two current releases. + const wrong = WHATS_NEW_PAGES.map((file) => ({ file, found: latestSectionsOf(file) })) + .filter((p) => p.found.length !== 1) + .map( + (p) => + `${p.file}: ${p.found.length} section(s) marked latest ` + + `(looking for ${LATEST_MARKERS.join(' | ')}; found headings: ` + + `${p.found.map((s) => s.heading).join(' | ') || 'none'})`, + ); + expect( + wrong, + `whats-new pages that do not mark exactly one latest-release section:\n ${wrong.join('\n ')}\n` + + 'Every locale ships this page and every locale makes the claim, so every locale is ' + + 'checked. If a locale renamed the heading, teach LATEST_MARKERS its word — a page this ' + + 'rule cannot find is a page nobody is checking.', + ).toEqual([]); + }); + + it('the latest-release heading states the version the manifest declares', () => { + const drifted = WHATS_NEW_PAGES.flatMap((file) => + latestSectionsOf(file) + .filter((s) => !statesVersion(s.heading, VERSION)) + .map((s) => `${file}: "## ${s.heading}" does not state ${VERSION}`), + ); + expect( + drifted, + `latest-release headings that do not state the declared version:\n ${drifted.join('\n ')}\n` + + 'A heading is what a reader sees in the table of contents, and "Latest release" is a ' + + 'claim about today. Name the version objectstack.config.ts declares — the number in ' + + 'this heading is the one thing on the page that cannot be a matter of taste.', + ).toEqual([]); + }); + + it('the latest-release section states the platform version package.json installs', () => { + // Guard the derivation before comparing against it: an empty or split + // `@objectstack/*` line would make this rule demand a version nobody + // installs, or pass over nothing. + expect( + PLATFORM_VERSIONS, + `package.json installs ${PLATFORM_VERSIONS.length} distinct @objectstack/* versions ` + + `(${PLATFORM_VERSIONS.join(', ') || 'none'}). They are version-locked and bumped ` + + 'together (AGENTS.md §Platform Upgrades); until they agree, no page can state "the" ' + + 'platform version.', + ).toHaveLength(1); + const platform = PLATFORM_VERSIONS[0]; + const drifted = WHATS_NEW_PAGES.flatMap((file) => + latestSectionsOf(file) + .filter((s) => !statesVersion(`${s.heading}\n${s.body}`, platform)) + .map((s) => `${file}: "## ${s.heading}" never states the installed platform ${platform}`), + ); + expect( + drifted, + `latest-release sections that do not state the installed platform version:\n ${drifted.join('\n ')}\n` + + 'This is the half that was twelve majors out ("Upgraded to ObjectStack 5.0" against an ' + + 'installed 17.x). The platform version a release runs on is a fact about package.json, ' + + 'not a number to be carried forward by hand.', + ).toEqual([]); + }); }); /* @@ -1330,6 +1491,13 @@ describe('product docs state the metadata counts the stack registers (#729)', () flows: (registered.flows ?? []).length, dashboards: (registered.dashboards ?? []).length, datasets: (registered.datasets ?? []).length, + // #1014: `getting-started/introduction` sold "a 10-role hierarchy" on the + // page a new reader opens first. Both halves were wrong — `CrmPositions` + // holds 12, and ADR-0090 D3 removed the hierarchy itself (positions are + // flat capability-distribution groups; the parent links went with the + // business-unit tree this app does not model). The wording is prose and + // this rule cannot judge it, but the number is a count like any other. + positions: (registered.positions ?? []).length, }; /** @@ -1355,20 +1523,52 @@ describe('product docs state the metadata counts the stack registers (#729)', () { kind: 'dashboards', re: /(\d+) 個儀表板/g }, { kind: 'datasets', re: /(\d+) datasets/g }, { kind: 'datasets', re: /semantic layer \((\d+)\)/g }, + // Positions, in the three spellings the pages settled on (#1014). The zh + // nouns are the ones `administration/sharing-and-security` already uses — + // 「岗位」 in zh-Hans, 「職位」 in zh-Hant — so the vocabulary is one word + // per locale across the docs, not one per page. + { kind: 'positions', re: /(\d+) positions/g }, + { kind: 'positions', re: /(\d+) 个岗位/g }, + { kind: 'positions', re: /(\d+) 個職位/g }, ]; /** - * Pages allowed to state a count that is not today's, each with the reason. + * SECTIONS 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. + * + * ## Why the key is a SECTION and not a file (#1015) + * + * It was a file. `content/docs/whats-new.mdx` carries the v1.0 record AND a + * "Latest release" section, which is a claim about TODAY — and a page-wide + * exemption granted for the first covered the second. Under it that section + * advertised `v5.0` / "Upgraded to ObjectStack 5.0" for however long it took + * someone to read the page: neither number was ever an app version (the + * manifest says 2.2.2) or the installed platform (17.0.0-rc.3), and the one + * rule that would have noticed had been told not to look. The exemption was + * right about the v1.0 section and wrong about the page. + * + * A claim is now exempt only when the `## ` section it sits under matches the + * pattern here. The v1.0 heading opens with `v1.0` in all three locales, so + * one pattern covers them; anything else on the page is checked like any + * other product page. */ - 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)', + const HISTORICAL: Record = { + 'content/docs/whats-new.mdx': { + section: /^v1\.0\b/, + reason: 'the v1.0 release record — the inventory that release shipped', + }, + 'content/docs/whats-new.zh-Hans.mdx': { + section: /^v1\.0\b/, + reason: 'the v1.0 release record (zh-Hans)', + }, + 'content/docs/whats-new.zh-Hant.mdx': { + section: /^v1\.0\b/, + reason: 'the v1.0 release record (zh-Hant)', + }, }; /** Depth-first walk of a docs tree, REPO_ROOT-relative. */ @@ -1381,25 +1581,64 @@ describe('product docs state the metadata counts the stack registers (#729)', () }); }; + /** + * The product surface #729 scoped this rule to: the README banner and the + * docs site. `docs/` is a different audience and a different claim shape — + * `docs/STATUS.md` states its counts as a transcript of what `pnpm validate` + * prints (`Data: 17 Objects 344 Fields`), which none of the prose spellings + * in CLAIMS matches. Adding the file here would widen the scan by one path + * and check nothing on it; the transcript is pinned by its own rule below + * (#1011), against the same registered stack. + */ const COUNT_DOCS = ['README.md', ...walkMdx('content/docs')]; - type Claim = { file: string; line: number; kind: string; text: string; stated: number }; + type Claim = { + file: string; + line: number; + /** The `## ` heading this claim sits under — '' before the first one. */ + section: string; + kind: string; + text: string; + stated: number; + }; + + /** + * The `## ` heading governing each 1-based line number. + * + * `### ` and deeper are deliberately not headings here: the v1.0 record files + * its inventory under `### What's in v1.0`, and a reader attributes that to + * the `## v1.0 …` section it is drawn inside. + */ + const sectionByLine = (text: string): string[] => { + const out: string[] = ['']; // index 0 is unused — line numbers are 1-based + let current = ''; + for (const line of text.split('\n')) { + const heading = /^## +(.*)$/.exec(line); + if (heading) current = heading[1].trim(); + out.push(current); + } + return out; + }; const claimsIn = (file: string): Claim[] => { const text = readFileSync(join(REPO_ROOT, file), 'utf8'); + const sections = sectionByLine(text); 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]), - })), + [...text.matchAll(re)].map((m) => { + const line = text.slice(0, m.index ?? 0).split('\n').length; + return { file, line, section: sections[line] ?? '', kind, text: m[0], stated: Number(m[1]) }; + }), ); }; const ALL_CLAIMS = COUNT_DOCS.flatMap(claimsIn); + /** A claim excused by HISTORICAL — file AND section must both match. */ + const isHistorical = (c: Claim): boolean => { + const entry = HISTORICAL[c.file]; + return entry !== undefined && entry.section.test(c.section); + }; + 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 @@ -1430,22 +1669,26 @@ describe('product docs state the metadata counts the stack registers (#729)', () ).toEqual([]); }); - it('every exempt page still states a count, so no exemption is dead', () => { + it('every exempt section 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), - ); + // excused silently widens the next time that page is edited. Section-scoped + // since #1015, so this also fails if the v1.0 record is renamed or its + // inventory moves out from under the heading the exemption names — either + // way the licence would otherwise go on standing over a heading nobody + // writes any more. + const dead = Object.entries(HISTORICAL) + .filter(([file]) => !ALL_CLAIMS.some((c) => c.file === file && isHistorical(c))) + .map(([file, entry]) => `${file} (section matching ${entry.section})`); expect( dead, - `HISTORICAL exempts pages that no longer state any count:\n ${dead.join('\n ')}\n` + + `HISTORICAL exempts sections 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], + (c) => !isHistorical(c) && c.stated !== REGISTERED[c.kind], ).map( (c) => `${c.file}:${c.line} says "${c.text}", the stack registers ${REGISTERED[c.kind]} ${c.kind}`, ); @@ -1457,3 +1700,257 @@ describe('product docs state the metadata counts the stack registers (#729)', () ).toEqual([]); }); }); + +/* + * ─── docs/STATUS.md states the current repo, not a stale snapshot (#1011) ──── + * + * `docs/STATUS.md` opens with "Source of truth: `pnpm validate`, `pnpm + * typecheck`, and `pnpm test`" and is the first page a maintainer — human or + * agent — opens to learn where the repo stands. Every figure on it had stopped + * being true: `16 Objects 318 Fields` against 17/344, `4 Dashboards` against + * 5, `23 Flows` against 24, `13 Views` against 14, and `ObjectStack packages + * 17.0.0-rc.1` two release candidates behind the installed rc.3. A page that + * claims to be the source of truth and is wrong in every row is worse than no + * page, because it is the one a reader stops checking things against. + * + * Nothing caught it. The #612 version rules list this file but compare only the + * APP version, which was right. The #729 count rule deliberately scans the + * product surface (README + `content/docs`), and — the reason it stopped there + * — its claims are prose spellings ("17 business objects") that match nothing + * in a machine transcript. So this file needed a rule keyed to the shape its + * claims actually have. + * + * Two present-tense tables, two derivations: + * + * - the fenced `pnpm validate` summary, every figure read off the registered + * stack, exactly like #729 reads its own; + * - `## Current Runtime Requirements`, every row read off `package.json`. + * + * ## What this rule deliberately does NOT decide (#1012) + * + * The transcript states `26 Actions`, and whether a READER should be told 26 + * (registrations), 13 (action families — an action bound to five objects + * registers five times) or 6 (`*.actions.ts` files) is an open product question + * on #1012. This rule does not answer it. It asserts that a block presented as + * the output of `pnpm validate` matches what the loader registers, which is + * what that command prints by construction — a fidelity check on a transcript, + * not a vote on the calibre. However #1012 lands, this assertion is unchanged; + * what may change is the prose around the block. + * + * ## Reverse verification (#1011) + * + * Predicted and measured **red before, green after**, in both halves: on the + * pre-fix tree the transcript rule reported five drifted figures (Objects, + * Fields, Views, Dashboards, Actions, Flows) and the runtime table reported + * `ObjectStack packages: page says 17.0.0-rc.1, package.json installs + * 17.0.0-rc.3`. Captured output is in the PR. + */ +describe('docs/STATUS.md states the current repository (#1011)', () => { + const STATUS = 'docs/STATUS.md'; + const text = readFileSync(join(REPO_ROOT, STATUS), 'utf8'); + const registered = stack as unknown as Record; + const len = (kind: string): number => (registered[kind] ?? []).length; + + /** + * Field totals come from the objects themselves — `fields` is a record keyed + * by field name, so this is the same sum the validator prints. + */ + const FIELD_TOTAL = ((registered.objects ?? []) as Record[]).reduce( + (total, o) => total + Object.keys((o.fields ?? {}) as Record).length, + 0, + ); + + /** Every label the transcript prints → the figure the stack registers. */ + const EXPECTED: Record = { + Objects: len('objects'), + Fields: FIELD_TOTAL, + Apps: len('apps'), + Views: len('views'), + Pages: len('pages'), + Dashboards: len('dashboards'), + Reports: len('reports'), + Actions: len('actions'), + Flows: len('flows'), + Positions: len('positions'), + Permissions: len('permissions'), + }; + + /** + * The fenced block transcribing the validator summary — identified by its + * `HotCRM v` first line rather than by position, so inserting a + * section above it does not silently point this rule at another fence. + */ + const TRANSCRIPT: string = (() => { + const fenced = [...text.matchAll(/```[a-zA-Z0-9_-]*\n([\s\S]*?)```/g)].map((m) => m[1]); + return fenced.find((body) => /^HotCRM v\d+\.\d+\.\d+/m.test(body)) ?? ''; + })(); + + /** + * `Data: 17 Objects 344 Fields` → `{ Objects: 17, Fields: 344 }`. + * + * The separator is horizontal whitespace, never `\s`: the block's first line + * is `HotCRM v2.2.2` and the next opens `Data:`, so a `\s+` reads the version's + * last digit and the following line's label as one pair and reports a figure + * called "Data" that no line states. + */ + const STATED: Record = Object.fromEntries( + [...TRANSCRIPT.matchAll(/(\d+)[ \t]+([A-Z][A-Za-z]*)/g)].map((m) => [m[2], Number(m[1])]), + ); + + it('the validator transcript is present and parses', () => { + // Vacuity guard #1. A reformatted or deleted block would leave STATED empty, + // and every comparison below would then agree with nothing at all — which is + // indistinguishable from a page that is correct. + expect( + TRANSCRIPT, + `${STATUS} carries no fenced block starting \`HotCRM v\`. Either the transcript ` + + 'was removed (drop this rule rather than leaving it green over nothing) or it was ' + + 'reformatted — teach the extraction the new shape.', + ).not.toBe(''); + expect( + Object.keys(STATED).length, + `${STATUS}'s transcript parsed ${Object.keys(STATED).length} labelled figures ` + + `(${JSON.stringify(STATED)}). The summary prints ${Object.keys(EXPECTED).length}.`, + ).toBeGreaterThanOrEqual(Object.keys(EXPECTED).length); + }); + + it('the transcript states every figure the summary prints', () => { + // Vacuity guard #2, pointed at silent subtraction: a row dropped from the + // block would take its figure out of STATED, and a rule that only compares + // what it finds would call that agreement. + const missing = Object.keys(EXPECTED).filter((label) => !(label in STATED)); + expect( + missing, + `${STATUS}'s transcript no longer states: ${missing.join(', ')}. It is presented as the ` + + 'output of `pnpm validate`; a summary missing a line is not that output. Re-run the ' + + 'command and paste what it prints.', + ).toEqual([]); + }); + + it('every figure the transcript states is one this rule knows', () => { + // The other direction: the validator growing a figure this rule has never + // heard of would land in the block unchecked, which is how the last set of + // numbers aged. Fail loudly and teach EXPECTED where it comes from. + const unknown = Object.keys(STATED).filter((label) => !(label in EXPECTED)); + expect( + unknown, + `${STATUS}'s transcript states figures this rule cannot derive: ${unknown.join(', ')}. ` + + 'Add them to EXPECTED with their source on the registered stack, or drop them from the ' + + 'block — an unchecked number on this page is exactly what #1011 was.', + ).toEqual([]); + }); + + it('every figure the transcript states is the one the stack registers', () => { + const drifted = Object.entries(EXPECTED) + .filter(([label, count]) => label in STATED && STATED[label] !== count) + .map(([label, count]) => `${label}: page says ${STATED[label]}, the stack registers ${count}`); + expect( + drifted, + `${STATUS} transcribes figures that are not the current ones:\n ${drifted.join('\n ')}\n` + + 'Re-run `pnpm validate` and paste its summary. This page calls itself the source of ' + + 'truth for the repo state — a stale figure here is read as fact by the next maintainer.', + ).toEqual([]); + }); + + it('the transcript names the version the manifest declares', () => { + const declared: string = ((stack as any).manifest ?? {}).version; + const stated = /^HotCRM v(\d+\.\d+\.\d+[^\s]*)/m.exec(TRANSCRIPT)?.[1]; + expect( + stated, + `${STATUS}'s transcript opens with "HotCRM v${stated}" but objectstack.config.ts declares ` + + `${declared}.`, + ).toBe(declared); + }); + + /** + * `## Current Runtime Requirements` — a present-tense fact table, so it is + * held to the file those facts live in. The section is sliced out by heading + * rather than the whole page scanned: `## Local Checks` above it has + * backticked second cells too, and would be read as requirement rows. + */ + const REQUIREMENTS_HEADING = 'Current Runtime Requirements'; + + const requirementRows: Record = (() => { + const section = h2Sections(text).find((s) => s.heading.includes(REQUIREMENTS_HEADING)); + if (!section) return {}; + return Object.fromEntries( + [...section.body.matchAll(/^\|\s*([^|]+?)\s*\|\s*`([^`]+)`\s*\|/gm)].map((m) => [ + m[1].trim(), + m[2].trim(), + ]), + ); + })(); + + /** Each row's label → where the value actually lives. */ + const REQUIREMENT_SOURCES: { label: string; source: string; value: () => string }[] = [ + { + label: 'Node.js', + source: 'package.json engines.node', + value: () => (PACKAGE_JSON.engines ?? {}).node ?? '', + }, + { + label: 'pnpm', + source: 'package.json engines.pnpm', + value: () => (PACKAGE_JSON.engines ?? {}).pnpm ?? '', + }, + { + label: 'ObjectStack packages', + source: 'the @objectstack/* dependency line in package.json', + // Asserted to be a single version by the test below before it is read. + value: () => PLATFORM_VERSIONS[0] ?? '', + }, + { + label: 'Local dev port', + // `objectstack dev -p 4001` — the port a reader is told to open is the + // port the script actually binds, not one remembered from a past release. + source: "package.json scripts.dev (`-p `)", + value: () => /-p\s+(\d+)/.exec((PACKAGE_JSON.scripts ?? {}).dev ?? '')?.[1] ?? '', + }, + ]; + + it('the runtime-requirements table parses, with a row for every requirement', () => { + // Vacuity guard #3: an unparsed table would leave this rule comparing an + // empty map, agreeing with any page at all. + const missing = REQUIREMENT_SOURCES.map((r) => r.label).filter((l) => !(l in requirementRows)); + expect( + missing, + `${STATUS} §${REQUIREMENTS_HEADING} states no row for: ${missing.join(', ')} ` + + `(parsed: ${Object.keys(requirementRows).join(', ') || 'nothing'}). Either the table ` + + 'was reformatted — teach the extraction its shape — or a requirement stopped being ' + + 'stated, which is a decision to make deliberately rather than by deletion.', + ).toEqual([]); + }); + + it('every value this rule derives is readable from package.json', () => { + // Guard the derivations before comparing against them, for the reason the + // whats-new rule guards PLATFORM_VERSIONS: a source that went absent would + // make every row "wrong" for a reason that has nothing to do with the page. + expect( + PLATFORM_VERSIONS, + `package.json installs ${PLATFORM_VERSIONS.length} distinct @objectstack/* versions ` + + `(${PLATFORM_VERSIONS.join(', ') || 'none'}) — they are version-locked and bumped ` + + 'together (AGENTS.md §Platform Upgrades).', + ).toHaveLength(1); + const unreadable = REQUIREMENT_SOURCES.filter((r) => r.value() === '').map( + (r) => `${r.label}: nothing read from ${r.source}`, + ); + expect( + unreadable, + `requirement values this rule can no longer derive:\n ${unreadable.join('\n ')}\n` + + 'Point the derivation at the new shape rather than leaving the row unchecked.', + ).toEqual([]); + }); + + it('every runtime requirement matches package.json', () => { + const drifted = REQUIREMENT_SOURCES.filter( + (r) => r.label in requirementRows && requirementRows[r.label] !== r.value(), + ).map((r) => `${r.label}: page says \`${requirementRows[r.label]}\`, ${r.source} says \`${r.value()}\``); + expect( + drifted, + `${STATUS} §${REQUIREMENTS_HEADING} disagrees with package.json:\n ${drifted.join('\n ')}\n` + + 'This table is written in the present tense and read as fact — the platform row alone ' + + 'sat two release candidates behind for two upgrades (#1011). package.json is the source; ' + + 'the page follows it.', + ).toEqual([]); + }); +});