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([]); + }); +});