diff --git a/.changeset/object-aware-field-consumer-scan.md b/.changeset/object-aware-field-consumer-scan.md new file mode 100644 index 000000000..f944e0fcc --- /dev/null +++ b/.changeset/object-aware-field-consumer-scan.md @@ -0,0 +1,23 @@ +--- +"hotcrm": patch +--- + +Product docs: the *Standard list views* section now describes the two views the +catalog actually ships — **All Products** (the grid, grouped by category) and +**Product Catalog** (the gallery) — in all three locales. It previously named +*Active Products*, *By Category* and *By Family*, none of which exists; the +category grouping is a setting on the grid, not a saved view, and nothing groups +by family. The same section on the campaigns page named six views that do not +exist and is corrected the same way, and the cases page's roster gained the +*Unassigned — triage* view it had been omitting while claiming to list them all. + +The products page also stops promising that tax is applied. *Default Tax Rate %* +is stored on the product and read by nothing: tax on a quote is an amount you +enter on the quote itself, and a quote line item carries its own per-line rate. +The sales-rep tip that said "pricing and tax are auto-filled" now says what is +actually filled in — the list price, and the sales price on a new line. + +For maintainers: `pnpm scan:fields` prints an object-aware ledger of which +declared field is read by what. The sweep it replaces matched field names across +the whole source tree, so a field whose name is also used on another object read +as consumed whatever it did — `crm_product.tax_rate` was invisible that way. diff --git a/content/docs/marketing/campaigns.mdx b/content/docs/marketing/campaigns.mdx index 99c3fbdbe..245208aa5 100644 --- a/content/docs/marketing/campaigns.mdx +++ b/content/docs/marketing/campaigns.mdx @@ -94,12 +94,16 @@ For deals influenced by multiple campaigns (multi-touch), the convention is to r ## Standard list views -- **Active Campaigns** — status = *In Progress*. -- **Upcoming Campaigns** — start date in the future, status = *Planning*. -- **Completed This Quarter** — status = *Completed* AND end date in current quarter. -- **Over Budget** — actual cost > budgeted cost. -- **By Type** — grouped. -- **Best ROI** — sorted by ROI descending. +Campaigns saves **four** views (`src/views/campaign.view.ts`), and the tab row above the list changes how the same campaigns are *drawn*, not which campaigns are in them: + +| View | What it shows | Tab | +| --- | --- | --- | +| **All Campaigns** | The grid: name (pinned left, and the link into the record), channel, status, start and end date, budgeted cost and expected revenue — the last two with column totals. Ordered by **Start Date**, earliest first. 25 rows a page. | *All* | +| **Campaign Schedule** | One gantt bar per campaign, from start date to end date, titled by name, so overlapping runs line up against one another. | *Schedule* | +| **Launch Calendar** | The same campaigns on a month calendar, placed on their **Start Date** and coloured by channel. | *Launches* | +| **Marketing Timeline** | The same start-to-end spans on a month scale, grouped by channel and coloured by status. | *Timeline* | + +None of the four filters anything, so **every campaign you can see appears in all four** and switching tabs only redraws them. There is no saved *Active Campaigns*, *Upcoming Campaigns*, *Completed This Quarter*, *Over Budget*, *By Type* or *Best ROI*: to narrow the list to one status, sort and filter the grid columns yourself. **ROI** is not among those columns — it is on the campaign record, under *Costs*, and in the marketing dashboards. ## Sharing — campaigns are public by default diff --git a/content/docs/marketing/campaigns.zh-Hans.mdx b/content/docs/marketing/campaigns.zh-Hans.mdx index b345a181f..9a4d5f6b0 100644 --- a/content/docs/marketing/campaigns.zh-Hans.mdx +++ b/content/docs/marketing/campaigns.zh-Hans.mdx @@ -94,12 +94,16 @@ description: 规划、启动并衡量市场营销举措——电子邮件、网 ## 标准列表视图 -- **Active Campaigns** — 状态 = *In Progress*。 -- **Upcoming Campaigns** — 开始日期在未来,状态 = *Planning*。 -- **Completed This Quarter** — 状态 = *Completed* 且结束日期在本季度。 -- **Over Budget** — 实际成本 > 预算成本。 -- **By Type** — 已分组。 -- **Best ROI** — 按 ROI 降序排列。 +营销活动一共保存了**四个**视图(`src/views/campaign.view.ts`),列表上方的标签行改的是同一批营销活动**怎么画**,而不是里面有哪些营销活动: + +| 视图 | 它展示什么 | 标签 | +| --- | --- | --- | +| **All Campaigns** | 表格:名称(固定在左侧,也是进入记录的链接)、渠道、状态、开始与结束日期、预算成本和预期营收——后两列带列合计。按**开始日期**升序排列,每页 25 行。 | *All* | +| **Campaign Schedule** | 每个营销活动一根甘特条,从开始日期到结束日期,以名称为标题,让相互重叠的档期对齐排布。 | *Schedule* | +| **Launch Calendar** | 同一批营销活动排在月历上,落在各自的**开始日期**,按渠道着色。 | *Launches* | +| **Marketing Timeline** | 同样的起止跨度画在月度刻度上,按渠道分组、按状态着色。 | *Timeline* | + +四个视图都没有任何过滤条件,所以**你能看到的每个营销活动在四个标签里都在**,切换标签只是换一种画法。不存在名为 *Active Campaigns*、*Upcoming Campaigns*、*Completed This Quarter*、*Over Budget*、*By Type* 或 *Best ROI* 的已保存视图:要把列表收窄到某个状态,请自己对表格的列排序和过滤。**ROI** 并不在这些列里——它在营销活动记录的*成本*区块上,以及市场仪表盘里。 ## 共享——营销活动默认公开 diff --git a/content/docs/marketing/campaigns.zh-Hant.mdx b/content/docs/marketing/campaigns.zh-Hant.mdx index 50138770b..298d5bc9e 100644 --- a/content/docs/marketing/campaigns.zh-Hant.mdx +++ b/content/docs/marketing/campaigns.zh-Hant.mdx @@ -94,12 +94,16 @@ description: 規劃、啟動並衡量行銷舉措——電子郵件、網路研 ## 標準清單檢視 -- **Active Campaigns** — 狀態 = *In Progress*。 -- **Upcoming Campaigns** — 開始日期在未來,狀態 = *Planning*。 -- **Completed This Quarter** — 狀態 = *Completed* 且結束日期在本季。 -- **Over Budget** — 實際成本 > 預算成本。 -- **By Type** — 已分組。 -- **Best ROI** — 按 ROI 降序排列。 +行銷活動一共儲存了**四個**檢視(`src/views/campaign.view.ts`),列表上方的標籤行改的是同一批行銷活動**怎麼畫**,而不是裡面有哪些行銷活動: + +| 檢視 | 它展示什麼 | 標籤 | +| --- | --- | --- | +| **All Campaigns** | 表格:名稱(釘在左側,也是進入記錄的連結)、通路、狀態、開始與結束日期、預算成本和預期營收——後兩欄帶欄位合計。按**開始日期**升序排列,每頁 25 列。 | *All* | +| **Campaign Schedule** | 每個行銷活動一根甘特條,從開始日期到結束日期,以名稱為標題,讓相互重疊的檔期對齊排布。 | *Schedule* | +| **Launch Calendar** | 同一批行銷活動排在月曆上,落在各自的**開始日期**,按通路著色。 | *Launches* | +| **Marketing Timeline** | 同樣的起訖跨度畫在月度刻度上,按通路分組、按狀態著色。 | *Timeline* | + +四個檢視都沒有任何過濾條件,所以**你能看到的每個行銷活動在四個標籤裡都在**,切換標籤只是換一種畫法。不存在名為 *Active Campaigns*、*Upcoming Campaigns*、*Completed This Quarter*、*Over Budget*、*By Type* 或 *Best ROI* 的已儲存檢視:要把列表收窄到某個狀態,請自己對表格的欄位排序和過濾。**ROI** 並不在這些欄位裡——它在行銷活動記錄的*成本*區塊上,以及行銷儀表板裡。 ## 共享——行銷活動預設公開 diff --git a/content/docs/revenue/products.mdx b/content/docs/revenue/products.mdx index 6d775e9b8..bc5f6b218 100644 --- a/content/docs/revenue/products.mdx +++ b/content/docs/revenue/products.mdx @@ -44,10 +44,12 @@ Categories and families let you slice reports by product mix — *"how much SMB- | --- | --- | | **List Price** | Standard published price — what shows up on a quote by default | | **Cost** | Your cost — used for margin calculation | -| **Default Tax Rate %** | The rate carried on the product record | +| **Default Tax Rate %** | Stored on the product. Nothing applies it — see below | The system warns you if Cost is greater than List Price (which would be a losing sale). +**Tax is a quote-level figure, and the product rate is not applied anywhere.** The price fill that copies a product's List Price onto a quote or opportunity line item does not copy the rate, and a quote's total is *subtotal − discount + Tax + Shipping*, where **Tax** is an amount you type on the quote itself. A quote line item has its own **Tax Rate %**, defaulted to 0 and set per line. So setting *Default Tax Rate %* on a product changes no total anywhere today. + ## No inventory, no billing schedule A product record is a catalog entry and nothing more. HotCRM holds **no stock figure** — no quantity on hand, no reorder point, and nothing decrements on a closed deal — and **no billing schedule or unit of measure**: how a subscription is billed lives in the contract and the quote, not on the catalog row. Those fields used to be declared here and were read by nothing, which made the catalog look like an inventory and billing system it never was. Stock belongs to your fulfilment system; see [Billing Handoff](/docs/revenue/billing-handoff). @@ -73,9 +75,14 @@ The product catalog is **public read-only** for the whole organisation. Sales re ## Standard list views -- **Active Products** — `is_active = true`. -- **By Category** — grouped. -- **By Family** — grouped. +Products saves **two** views (`src/views/product.view.ts`), and the two tabs above the list switch between them: + +| View | What it shows | Tab | +| --- | --- | --- | +| **All Products** | The grid: product code (pinned left, and the link into the record), name, category, family, SKU, list price with a column average, cost and Active. Sorted by name and **grouped by category**, 50 rows a page. | *All* | +| **Product Catalog** | The same products drawn as cards, with the **Product Image** as the cover and product code, category, family, list price and SKU on the face. | *Catalog* | + +Neither view filters anything, so **every product appears in both**, inactive ones included, and switching tabs only redraws them. There is no saved *Active Products*, *By Category* or *By Family* view: grouping by category is a setting on the grid above, not a second saved view, and nothing groups by family at all. To work with active products only, sort or filter the **Active** column yourself. ## Tips for product managers @@ -85,7 +92,7 @@ The product catalog is **public read-only** for the whole organisation. Sales re ## Tips for sales reps -- ✅ Use products from the catalog rather than typing line items by hand — pricing and tax are auto-filled. +- ✅ Use products from the catalog rather than typing line items by hand — the List Price is filled in for you, and on a new line the Sales Price starts there too. Tax is not filled in: enter it on the quote. - ✅ Check the datasheet before proposing — it usually has the exact spec sheet you need for the proposal. ## Tips for admins diff --git a/content/docs/revenue/products.zh-Hans.mdx b/content/docs/revenue/products.zh-Hans.mdx index 15cff8136..214c9c781 100644 --- a/content/docs/revenue/products.zh-Hans.mdx +++ b/content/docs/revenue/products.zh-Hans.mdx @@ -44,10 +44,12 @@ description: 你所销售内容的目录——编码、标价、SKU 和税率。 | --- | --- | | **List Price** | 标准公布价——默认显示在报价上的价格 | | **Cost** | 你的成本——用于利润计算 | -| **Default Tax Rate %** | 产品记录上携带的税率 | +| **Default Tax Rate %** | 存在产品记录上。没有任何东西会应用它——见下 | 如果成本大于标价(这会是一笔亏本的销售),系统会警告你。 +**税是报价单层面的数字,产品上的税率不会被应用到任何地方。** 把产品标价填到报价或商机明细行上的那段价格填充逻辑并不会复制税率;报价单的总额是 *小计 − 折扣 + 税额 + 运费*,其中**税额**是你在报价单上自己填的一个金额。报价明细行有它自己的 **Tax Rate %**,默认为 0、按行设置。所以今天在产品上设置 *Default Tax Rate %*,不会改变任何地方的任何金额。 + ## 没有库存,也没有计费周期 产品记录就是一条目录条目,仅此而已。HotCRM **不保存任何库存数字**——没有库存数量、没有补货阈值,赢单也不会扣减任何东西——也**没有计费周期或计量单位**:订阅如何计费属于合同和报价,而不属于目录行。这些字段过去声明在这里、却没有任何东西读取,让目录看起来像一套它从来不是的库存与计费系统。库存属于你的履约系统;参见 [计费交接](/zh-Hans/docs/revenue/billing-handoff)。 @@ -73,9 +75,14 @@ description: 你所销售内容的目录——编码、标价、SKU 和税率。 ## 标准列表视图 -- **Active Products**——`is_active = true`。 -- **By Category**——分组。 -- **By Family**——分组。 +产品一共保存了**两个**视图(`src/views/product.view.ts`),列表上方的两个标签在它们之间切换: + +| 视图 | 它展示什么 | 标签 | +| --- | --- | --- | +| **All Products** | 表格:产品编码(固定在左侧,也是进入记录的链接)、名称、类别、产品系列、SKU、标价(列尾带平均值)、成本和是否活跃。按名称排序,并**按类别分组**,每页 50 行。 | *All* | +| **Product Catalog** | 同一批产品画成卡片,以 **Product Image** 作为封面,卡面上是产品编码、类别、产品系列、标价和 SKU。 | *Catalog* | + +两个视图都没有任何过滤条件,所以**每个产品在两个视图里都在**(包括已停用的),切换标签只是换一种画法。不存在名为 *Active Products*、*By Category* 或 *By Family* 的已保存视图:按类别分组是上面那个表格的一项设置,而不是第二个已保存视图,而按产品系列分组则根本不存在。只想看活跃产品,请自己对**是否活跃**这一列排序或过滤。 ## 给产品经理的提示 @@ -85,7 +92,7 @@ description: 你所销售内容的目录——编码、标价、SKU 和税率。 ## 给销售代表的提示 -- ✅ 使用目录中的产品,而不是手动输入明细行——定价和税会被自动填充。 +- ✅ 使用目录中的产品,而不是手动输入明细行——标价会自动填好,新建明细行时销售价格也从标价起步。税不会被填充:请在报价单上填写。 - ✅ 在提案前检查规格表——它通常包含你提案所需的确切规格表。 ## 给管理员的提示 diff --git a/content/docs/revenue/products.zh-Hant.mdx b/content/docs/revenue/products.zh-Hant.mdx index 7f84669ce..aa66e20b3 100644 --- a/content/docs/revenue/products.zh-Hant.mdx +++ b/content/docs/revenue/products.zh-Hant.mdx @@ -44,10 +44,12 @@ description: 你所銷售內容的目錄——編碼、標價、SKU 和稅率。 | --- | --- | | **List Price** | 標準公佈價——預設顯示在報價上的價格 | | **Cost** | 你的成本——用於利潤計算 | -| **Default Tax Rate %** | 產品記錄上攜帶的稅率 | +| **Default Tax Rate %** | 存在產品記錄上。沒有任何東西會套用它——見下 | 如果成本大於標價(這會是一筆虧本的銷售),系統會警告你。 +**稅是報價單層級的數字,產品上的稅率不會被套用到任何地方。** 把產品標價填到報價或商機明細行上的那段價格填充邏輯並不會複製稅率;報價單的總額是 *小計 − 折扣 + 稅額 + 運費*,其中**稅額**是你在報價單上自己填的一個金額。報價明細行有它自己的 **Tax Rate %**,預設為 0、逐行設定。所以今天在產品上設定 *Default Tax Rate %*,不會改變任何地方的任何金額。 + ## 沒有庫存,也沒有計費週期 產品記錄就是一條目錄條目,僅此而已。HotCRM **不保存任何庫存數字**——沒有庫存數量、沒有補貨閾值,贏單也不會扣減任何東西——也**沒有計費週期或計量單位**:訂閱如何計費屬於合約和報價,而不屬於目錄列。這些欄位過去宣告在這裡、卻沒有任何東西讀取,讓目錄看起來像一套它從來不是的庫存與計費系統。庫存屬於你的履約系統;參見 [計費交接](/zh-Hant/docs/revenue/billing-handoff)。 @@ -73,9 +75,14 @@ description: 你所銷售內容的目錄——編碼、標價、SKU 和稅率。 ## 標準列表檢視 -- **Active Products**——`is_active = true`。 -- **By Category**——分組。 -- **By Family**——分組。 +產品一共儲存了**兩個**檢視(`src/views/product.view.ts`),列表上方的兩個標籤在它們之間切換: + +| 檢視 | 它展示什麼 | 標籤 | +| --- | --- | --- | +| **All Products** | 表格:產品編碼(釘在左側,也是進入記錄的連結)、名稱、類別、產品系列、SKU、標價(欄尾帶平均值)、成本和是否活躍。按名稱排序,並**按類別分組**,每頁 50 列。 | *All* | +| **Product Catalog** | 同一批產品畫成卡片,以 **Product Image** 作為封面,卡面上是產品編碼、類別、產品系列、標價和 SKU。 | *Catalog* | + +兩個檢視都沒有任何過濾條件,所以**每個產品在兩個檢視裡都在**(包括已停用的),切換標籤只是換一種畫法。不存在名為 *Active Products*、*By Category* 或 *By Family* 的已儲存檢視:按類別分組是上面那個表格的一項設定,而不是第二個已儲存檢視,而按產品系列分組則根本不存在。只想看活躍產品,請自己對**是否活躍**這一欄排序或過濾。 ## 給產品經理的提示 @@ -85,7 +92,7 @@ description: 你所銷售內容的目錄——編碼、標價、SKU 和稅率。 ## 給銷售代表的提示 -- ✅ 使用目錄中的產品,而不是手動輸入明細行——定價和稅會被自動填充。 +- ✅ 使用目錄中的產品,而不是手動輸入明細行——標價會自動填好,新增明細行時銷售價格也從標價起步。稅不會被填充:請在報價單上填寫。 - ✅ 在提案前檢查規格表——它通常包含你提案所需的確切規格表。 ## 給管理員的提示 diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 209563bdd..d8bf8d357 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -149,7 +149,7 @@ See [SLA & Escalation](/docs/service/sla-and-escalation) for the full mechanics. ## Standard list views -`crm_case` ships **seven** views (`src/views/case.view.ts`), and the case list surfaces them as tabs: +`crm_case` ships **eight** views (`src/views/case.view.ts`), and the case list surfaces them as tabs: | View | What it shows | Tab | | --- | --- | --- | @@ -158,6 +158,7 @@ See [SLA & Escalation](/docs/service/sla-and-escalation) for the full mechanics. | **SLA Calendar** | Cases laid out on their **SLA Due Date** — every case carries one now, so the calendar shows the whole open backlog | *SLA* | | **Case Timeline** | Cases on a *created → closed* timeline, grouped by owner | *Timeline* | | **Escalated Cases** | `is_escalated` is true | *Escalated* | +| **Unassigned — triage** | Open cases with no owner at all, urgent first then soonest due — where a web-to-case submission lands when nobody holds the Service Agent position | *Triage* | | **⏰ SLA at Risk** | Open **High and Critical** cases, soonest due date first | *SLA at Risk* | | **My Open Cases** | Your cases that are not closed | *My Cases* | diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index ecdeb4886..2aca3ce83 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -149,7 +149,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ## 标准列表视图 -`crm_case` 一共发布 **7 个**视图(`src/views/case.view.ts`),工单列表把它们做成页签: +`crm_case` 一共发布 **8 个**视图(`src/views/case.view.ts`),工单列表把它们做成页签: | 视图 | 展示什么 | 页签 | | --- | --- | --- | @@ -158,6 +158,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 | **SLA Calendar** | 按 **SLA 截止日期** 把工单铺在日历上——现在每张工单都带截止日期,所以它显示的是整个未结积压 | *SLA* | | **Case Timeline** | 工单按「创建 → 关闭」铺在时间轴上,按负责人分组 | *Timeline* | | **Escalated Cases** | `is_escalated` 为真 | *Escalated* | +| **Unassigned — triage** | 完全没有负责人的未结工单,先按紧急程度、再按最近的截止日期——没有人担任服务坐席时,Web 提交的工单就落在这里 | *Triage* | | **⏰ SLA at Risk** | 未结的 **High 与 Critical** 工单,截止日期最近的排在前面 | *SLA at Risk* | | **My Open Cases** | 你名下尚未关闭的工单 | *My Cases* | diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index 47be71bc5..f1d8299ff 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -149,7 +149,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ## 標準列表檢視 -`crm_case` 一共發布 **7 個**檢視(`src/views/case.view.ts`),工單列表把它們做成頁籤: +`crm_case` 一共發布 **8 個**檢視(`src/views/case.view.ts`),工單列表把它們做成頁籤: | 檢視 | 展示什麼 | 頁籤 | | --- | --- | --- | @@ -158,6 +158,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 | **SLA Calendar** | 按 **SLA 截止日期** 把工單鋪在行事曆上——現在每張工單都帶截止日期,所以它顯示的是整個未結積壓 | *SLA* | | **Case Timeline** | 工單按「建立 → 關閉」鋪在時間軸上,按負責人分組 | *Timeline* | | **Escalated Cases** | `is_escalated` 為真 | *Escalated* | +| **Unassigned — triage** | 完全沒有負責人的未結工單,先按緊急程度、再按最近的截止日期——沒有人擔任服務座席時,Web 提交的工單就落在這裡 | *Triage* | | **⏰ SLA at Risk** | 未結的 **High 與 Critical** 工單,截止日期最近的排在前面 | *SLA at Risk* | | **My Open Cases** | 你名下尚未關閉的工單 | *My Cases* | diff --git a/package.json b/package.json index c62aec338..7121f7666 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:e2e": "playwright test", "hygiene": "node scripts/check-source-hygiene.mjs", "hygiene:tokens": "node scripts/check-source-token-ratchet.mjs", + "scan:fields": "tsx scripts/scan-field-consumers.ts", "reconcile:analytics": "tsx scripts/analytics-reconcile/run.ts", "verify": "pnpm validate && pnpm typecheck && pnpm lint && pnpm lint:i18n-gate && pnpm hygiene && pnpm hygiene:tokens && pnpm build && pnpm test", "changeset": "changeset", diff --git a/scripts/scan-field-consumers.ts b/scripts/scan-field-consumers.ts new file mode 100644 index 000000000..d64affcbd --- /dev/null +++ b/scripts/scan-field-consumers.ts @@ -0,0 +1,494 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Object-aware field consumer ledger (#1193). + * + * pnpm scan:fields # the ledger a human reads + * pnpm scan:fields --all # every field, with its verdict + * pnpm scan:fields --sites . # where one field is read + * pnpm scan:fields --json # the same measurement, machine-readable + * + * ## Why this exists — the false negative that cannot show up in a grep + * + * #1182 adjudicated ten declared-but-inert fields. Its row set came from a scan + * that called a field consumed when its NAME appeared in a `src/**\/*.ts` file + * outside `views/` and `translations/`. That scan is line-oriented and + * **object-blind**, and one field paid for it: `crm_product.tax_rate` read as + * consumed because `crm_quote_line_item.tax_rate` — a different object's field, + * with its own formula reading its own rate — spells the same token. The + * product's rate reached no card. `is_taxable`, two declarations away in the + * same field group and inert for exactly the same reason, did reach it, because + * its name happens to be unique. + * + * The failure mode is what makes it worth a tool rather than a fix. A false + * NEGATIVE is invisible by construction: the scan's own output cannot show what + * it filtered out, so re-running it — however carefully — re-derives the same + * blind spot, and every field whose name is shared across objects keeps the same + * immunity. There is no amount of care with a grep that closes this. + * + * ## What "object-aware" means here + * + * A hit is resolved to **the object whose declaration it sits inside**, not to + * the file it appears in. The whole consumer surface of this app is the + * registered stack, so the scan walks `objectstack.config.ts` itself and carries + * an OBJECT CONTEXT down the tree: `object` / `objectName` / `targetObject` / + * `data.object` / `list.data.object`, and `dataset` resolved through the + * dataset's own object. A view column, a flow node config, a hook body and a + * validation predicate each answer "which object?" from the declaration that + * encloses them. + * + * Text blobs — hook handlers, action bodies, CEL sources, skill instructions — + * are scanned with the same rule applied WITHIN the blob: an object named in the + * text (`api.object('crm_product')`, a bare `crm_product`) sets the context for + * everything after it. Two candidates are credited for each token, the nearest + * preceding mention and the enclosing declaration's own object, and only when + * that object actually declares the token. Crediting both is a deliberate, + * stated approximation: a handler that reads its own object and one it loads + * genuinely reads both, and under-crediting the declaring object would invent + * inert fields, which is the noisy direction rather than the silent one. + * + * Any token that resolves to NO object is counted and reported (`unresolved`), + * never dropped. A scan that silently discards what it cannot place is how the + * previous one granted immunity. + * + * ## Consumption is not one thing — the three buckets + * + * #1182 removed fields that a view column displayed (`quantity_on_hand` had a + * whole list view), and kept one that a roll-up computed. So a boolean + * "consumed" would have argued against that card's own verdicts. Every site is + * therefore bucketed, and the verdict reads off the buckets: + * + * - **behaviour** — the field makes something happen: a formula or summary + * expression, a validation predicate, a view FILTER / sort / grouping (they + * decide which records you see), a flow node, a hook or action body, a + * dataset dimension or measure, a dashboard or report config, a sharing-rule + * condition, a skill's instructions. + * - **display** — the field is only drawn: a view column, a form or page + * field, `highlightFields`, `searchableFields`, an index. + * - **carrier** — the field is merely carried along: locale bundles, seed + * values, import-mapping columns, and prose (`label` / `description` / + * `message` …). These are what a REMOVAL must clean up; none of them is + * evidence that anything reads the field. + * + * `live` = at least one behaviour site. + * `display-only` = drawn somewhere, but nothing reads it. + * `inert` = neither. This is the ledger #1182 adjudicated row by row. + * + * ## Report-only, deliberately — this is not a `pnpm verify` gate + * + * Argued in the PR for #1193 and recorded here because the next reader will ask. + * The maintainer ruling of 2026-08-17 was 「逐个 enforce-or-remove(推荐)」 — + * per-field adjudication, not a blanket rule. A gate that failed on any inert + * field would encode the blanket rule the ruling declined to make, and would go + * red the moment someone lands a field one PR before its consumer, which is + * ordinary in a metadata app. What this card actually needed fixed was not + * inertness but INVISIBILITY, and a tool that prints a ledger fixes that. + * + * The capability is guarded instead of the count: `test/field-consumer-scan.test.ts` + * pins that the resolver stays object-aware — a field name shared across two + * objects must resolve to the object that declares the reader, not to both — + * and re-derives #1182's own verdicts from this scan. A gate on the number + * would have been cheaper and would have guarded the wrong thing. + */ + +import stack from '../objectstack.config'; + +type AnyRec = Record; + +/** Where one field name was read, and what kind of surface read it. */ +export type Site = { + object: string; + field: string; + /** Top-level `objectstack.config.ts` key the site was found under. */ + root: string; + /** Dotted path from that key, so a ledger row can be checked by hand. */ + path: string; + bucket: Bucket; +}; + +export type Bucket = 'behaviour' | 'display' | 'carrier'; +export type Verdict = 'live' | 'display-only' | 'inert'; + +/** + * Stack roots whose contents carry a field without reading it. + * + * `translations` is the reason the previous scan excluded it too: a locale row + * is a label for a field, not a consumer of one. `data` seeds VALUES — every + * one of #1182's removed fields was seeded, `is_taxable` on all 13 catalog + * products — and a seeded value that nothing reads is precisely the shape being + * hunted. `mappings` offers an import column, which is a customer-facing + * surface a removal must clean, not evidence of a reader. + */ +const CARRIER_ROOTS = new Set(['translations', 'data', 'mappings']); + +/** + * Leaf keys whose value is prose for a human, not a reference. + * + * A field name occurring inside a sentence is not a read. Bucketed as `carrier` + * rather than dropped, so `--sites` still shows the sentence: prose that names a + * field is exactly what has to be rewritten when the field goes. + */ +const PROSE_KEYS = new Set([ + 'label', 'pluralLabel', 'description', 'message', 'successMessage', 'errorMessage', + 'title', 'placeholder', 'helpText', 'emptyText', 'tooltip', 'subtitle', +]); + +/** + * Path segments that make a site presentational. + * + * `views` and `pages` are display by default and earn `behaviour` back through + * BEHAVIOUR_SEGMENTS below — a column draws a field, a filter decides which rows + * exist at all, and #1182 turned on exactly that distinction. + */ +const DISPLAY_ROOTS = new Set(['views', 'pages', 'apps']); +const DISPLAY_SEGMENTS = ['highlightFields', 'searchableFields', 'indexes']; + +/** Inside a display root, these segments still change behaviour. */ +const BEHAVIOUR_SEGMENTS = [ + 'filter', 'filters', 'runtimeFilter', 'where', 'sort', 'grouping', 'groupBy', + 'groupField', 'startField', 'endField', 'dateField', 'coverField', 'titleField', + 'colorField', 'latitudeField', 'longitudeField', 'addressField', 'kanban', + 'calendar', 'gantt', 'timeline', 'map', 'rowTint', 'conditionalFormatting', +]; + +/** Keys whose object VALUE is a predicate map — `{ is_active: true }`. */ +const PREDICATE_KEYS = new Set([ + 'filter', 'filters', 'runtimeFilter', 'where', 'criteria', 'defaultFilter', 'conditions', +]); + +/** + * Keys whose object VALUE spells fields as keys it WRITES — a flow's + * `fields: { added_date: '{NOW()}' }`. + * + * Recorded as `carrier`, not as a read, and that is the whole point of naming + * them separately: a value that automation stamps and nothing ever reads is + * exactly the shape #1182 removed nine fields for (`is_taxable` was seeded on + * all 13 catalog products). Recording it keeps `--sites` honest — an adjudicator + * needs to see that a flow writes the field before deciding to delete it — while + * keeping the verdict on whether anything READS it. + */ +const WRITE_KEYS = new Set(['fields', 'values', 'set', 'record', 'data', 'input']); + +/** + * Keys whose value is a literal from some other vocabulary, never a field name. + * + * Without this list the scan reads `type: 'summary'` on a roll-up field as a + * reference to `crm_case.summary`, and `accept: ['image/png']` as a reference to + * `crm_account.image`. Both are accidents of two vocabularies sharing a word, + * which is the same class of mistake this whole script exists to stop making — + * one level down, on field TYPES instead of object names. Skipping them cut the + * unplaceable-token count from 4,374 to a set small enough to read. + */ +const LITERAL_KEYS = new Set([ + 'type', 'reference', 'reference_to', 'accept', 'provider', 'dialect', 'operator', + 'aggregate', 'mode', 'severity', 'language', 'surface', 'format', 'icon', 'variant', + 'colorVariant', 'align', 'order', 'defaultValue', 'value', 'sourceFormat', 'transform', + 'name', 'id', 'events', 'locations', 'version', 'width', 'cardSize', 'coverFit', + 'env', 'pinned', 'summary', 'chartType', 'dateGranularity', +]); + +/** + * The shapes a field name takes when it is actually being REFERENCED. + * + * A bare word inside a sentence is not a read; `record.tax_rate`, + * `fields: ['list_price']`, `{product_code}` and `input.unit_price` are. Applied + * to every blob alongside the whole-string rule below, so short declarative + * values (`field: 'annual_revenue'`) and code bodies are read by one rule set. + */ +const REFERENCE_SHAPES = [ + /\.([A-Za-z_][A-Za-z0-9_]*)\b/g, // record.x · input.x · l.x + /['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]/g, // 'x' · "x" · `x` + /\{([A-Za-z_][A-Za-z0-9_]*)\}/g, // {x} in a template string + /\b([A-Za-z_][A-Za-z0-9_]*)\s*:/g, // { x: … } object-literal key +]; + +const S = stack as unknown as AnyRec; +const OBJECTS = (S.objects ?? []) as AnyRec[]; + +/** Declared field names, per object. Read from the stack, never hand-listed. */ +export const fieldsByObject = new Map>( + OBJECTS.map((o) => [String(o.name), new Set(Object.keys((o.fields ?? {}) as AnyRec))]), +); + +/** Field name → every object declaring it. The masking analysis reads this. */ +export const objectsByField = ((): Map => { + const out = new Map(); + for (const [object, fields] of fieldsByObject) { + for (const field of fields) out.set(field, [...(out.get(field) ?? []), object]); + } + return out; +})(); + +/** Dataset name → the object it reads, so a report/widget inherits a context. */ +const datasetObject = new Map( + ((S.datasets ?? []) as AnyRec[]).map((d) => [String(d.name), String(d.object)]), +); + +/** Every `crm_*` token in a blob, with the index its mention ENDS at. */ +const mentionsIn = (text: string): { end: number; object: string }[] => { + const out: { end: number; object: string }[] = []; + for (const m of text.matchAll(/\bcrm_[a-z0-9_]+\b/g)) { + if (fieldsByObject.has(m[0])) out.push({ end: (m.index ?? 0) + m[0].length, object: m[0] }); + } + return out; +}; + +const sites: Site[] = []; +/** Tokens that looked like a field but resolved to no object. Never dropped. */ +export const unresolved: { token: string; root: string; path: string }[] = []; + +/** Every declared field name anywhere — the vocabulary the text scan matches. */ +const ALL_FIELD_NAMES = new Set(objectsByField.keys()); + +const bucketFor = (root: string, path: string, leafKey: string): Bucket => { + if (CARRIER_ROOTS.has(root)) return 'carrier'; + if (PROSE_KEYS.has(leafKey)) return 'carrier'; + const segments = path.split('.'); + if (BEHAVIOUR_SEGMENTS.some((s) => segments.includes(s))) return 'behaviour'; + if (DISPLAY_ROOTS.has(root)) return 'display'; + if (DISPLAY_SEGMENTS.some((s) => segments.includes(s))) return 'display'; + return 'behaviour'; +}; + +const record = (object: string, field: string, root: string, path: string, leafKey: string): void => { + sites.push({ object, field, root, path, bucket: bucketFor(root, path, leafKey) }); +}; + +/** + * Scan one text blob (a string value, or a stringified handler) for field names. + * + * The nearest preceding `crm_*` mention and the enclosing declaration's object + * are both candidates; a token is credited to each candidate that DECLARES it. + * See the header for why both rather than one. + */ +const scanText = (text: string, ctx: string | undefined, root: string, path: string, leafKey: string): void => { + if (text.length === 0 || text.length > 200_000) return; + if (LITERAL_KEYS.has(leafKey)) return; + const mentions = mentionsIn(text); + /** Every reference-shaped occurrence: the captured token and where it began. */ + const hits: { token: string; at: number }[] = []; + const trimmed = text.trim(); + if (ALL_FIELD_NAMES.has(trimmed)) hits.push({ token: trimmed, at: 0 }); + for (const shape of REFERENCE_SHAPES) { + for (const m of text.matchAll(shape)) { + hits.push({ token: m[1], at: (m.index ?? 0) + m[0].indexOf(m[1]) }); + } + } + for (const m of hits) { + const token = m.token; + if (!ALL_FIELD_NAMES.has(token)) continue; + const at = m.at; + let nearest: string | undefined; + for (const mention of mentions) { + if (mention.end <= at) nearest = mention.object; + else break; + } + const candidates = [...new Set([nearest, ctx])].filter((c): c is string => c !== undefined); + const credited = candidates.filter((c) => fieldsByObject.get(c)?.has(token)); + if (credited.length === 0) { + unresolved.push({ token, root, path }); + continue; + } + for (const object of credited) record(object, token, root, path, leafKey); + } +}; + +/** The object context a node establishes for its own subtree, if any. */ +const contextOf = (node: AnyRec, ctx: string | undefined): string | undefined => { + const named = (v: unknown): string | undefined => + typeof v === 'string' && fieldsByObject.has(v) ? v : undefined; + const nested = (v: unknown, key: string): string | undefined => + v && typeof v === 'object' ? named((v as AnyRec)[key]) : undefined; + return ( + named(node.object) ?? + named(node.objectName) ?? + named(node.targetObject) ?? + nested(node.data, 'object') ?? + nested(node.config, 'objectName') ?? + nested(node.config, 'object') ?? + // A `views` element is `{ list, form, listViews }` with the object named + // only inside `list.data` — without this hoist the FORM section's fields + // would resolve to nothing, which is the same silent drop being fixed. + (node.list && typeof node.list === 'object' + ? nested((node.list as AnyRec).data, 'object') + : undefined) ?? + named(node.name) ?? + (typeof node.dataset === 'string' ? datasetObject.get(node.dataset) : undefined) ?? + // A flow names its object on the TRIGGER node, and its later nodes read + // `{record.x}` with no object of their own — `billing-handoff`'s http body + // reads eleven contract fields that way. Without this hoist those eleven + // resolve to nothing and eleven live fields read as inert, which is the + // loud direction of the same mistake rather than the silent one, but still + // wrong. Per-node `objectName` still wins inside its own subtree. + (Array.isArray(node.nodes) + ? (node.nodes as AnyRec[]) + .map((n) => nested(n.config, 'objectName') ?? nested(n.config, 'object')) + .find((o) => o !== undefined) + : undefined) ?? + ctx + ); +}; + +const walk = (node: unknown, ctx: string | undefined, root: string, path: string, leafKey: string): void => { + if (node === null || node === undefined) return; + if (typeof node === 'function') { + scanText(Function.prototype.toString.call(node), ctx, root, path, leafKey); + return; + } + if (typeof node === 'string') { + scanText(node, ctx, root, path, leafKey); + return; + } + if (typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, ctx, root, `${path}[${i}]`, leafKey)); + return; + } + const rec = node as AnyRec; + const inner = contextOf(rec, ctx); + const isFieldDeclarationMap = root === 'objects' && /^\[\d+\]\.fields$/.test(path); + for (const [key, value] of Object.entries(rec)) { + const childPath = `${path}.${key}`; + // A predicate map spells the field as its KEY: `{ is_active: true }`. + // Nowhere else are keys treated as references — `type`, `name` and `label` + // are ubiquitous schema keys AND plausible field names, and crediting them + // wholesale would hand out exactly the immunity this scan removes. + const keyIsReference = + !isFieldDeclarationMap && + ALL_FIELD_NAMES.has(key) && + (PREDICATE_KEYS.has(leafKey) || WRITE_KEYS.has(leafKey)); + if (keyIsReference) { + const owner = inner && fieldsByObject.get(inner)?.has(key) ? inner : undefined; + if (!owner) unresolved.push({ token: key, root, path }); + else if (WRITE_KEYS.has(leafKey)) sites.push({ object: owner, field: key, root, path, bucket: 'carrier' }); + else record(owner, key, root, path, leafKey); + } + // A map KEYED by object name — `translations[].en.objects.crm_campaign`, + // `permissions[].objects.crm_lead` — names its object in a position no + // `object:` lookup reaches. Without this the locale rows for every field + // resolve to nothing, and the ledger cannot show an adjudicator the four + // bundles a removal has to clean. + walk(value, fieldsByObject.has(key) ? key : inner, root, childPath, key); + } +}; + +for (const [key, value] of Object.entries(S)) { + if (key === 'manifest' || key === 'i18n' || key === 'requires') continue; + walk(value, undefined, key, '', key); +} + +// ─────────────────────────────────────────────────────────── the ledger ── + +export type Row = { + object: string; + field: string; + verdict: Verdict; + behaviour: number; + display: number; + carrier: number; + /** Other objects declaring the same field name — the masking set. */ + sharedWith: string[]; +}; + +const byField = new Map(); +for (const s of sites) byField.set(`${s.object}.${s.field}`, [...(byField.get(`${s.object}.${s.field}`) ?? []), s]); + +export const rows: Row[] = [...fieldsByObject].flatMap(([object, fields]) => + [...fields].map((field) => { + const found = byField.get(`${object}.${field}`) ?? []; + const count = (b: Bucket): number => found.filter((s) => s.bucket === b).length; + const behaviour = count('behaviour'); + const display = count('display'); + return { + object, + field, + verdict: (behaviour > 0 ? 'live' : display > 0 ? 'display-only' : 'inert') as Verdict, + behaviour, + display, + carrier: count('carrier'), + sharedWith: (objectsByField.get(field) ?? []).filter((o) => o !== object), + }; + }), +); + +export const sitesOf = (object: string, field: string): Site[] => byField.get(`${object}.${field}`) ?? []; + +// ───────────────────────────────────────────────────────────── reporting ── + +const argv = process.argv.slice(2); +const has = (flag: string): boolean => argv.includes(flag); + +const inert = rows.filter((r) => r.verdict === 'inert'); +const displayOnly = rows.filter((r) => r.verdict === 'display-only'); +/** + * The card's headline set: inert AND sharing its name with another object's + * field, so the old token grep read it as consumed and it could never have + * reached a sweep. `crm_product.tax_rate` is the row that produced #1193. + */ +const masked = inert.filter((r) => r.sharedWith.length > 0); + +const main = (): void => { + if (has('--json')) { + console.log(JSON.stringify({ rows, unresolved: unresolved.length }, null, 2)); + return; + } + + const sitesFlag = argv.indexOf('--sites'); + if (sitesFlag !== -1) { + const target = argv[sitesFlag + 1] ?? ''; + const dot = target.lastIndexOf('.'); + const [object, field] = [target.slice(0, dot), target.slice(dot + 1)]; + const found = sitesOf(object, field); + console.log(`${target} — ${found.length} site(s)\n`); + for (const s of found) console.log(` ${s.bucket.padEnd(9)} ${s.root}${s.path}`); + if (found.length === 0) console.log(' (none — this field is inert)'); + return; + } + + console.log( + `Field consumer ledger — ${rows.length} declared fields across ${fieldsByObject.size} objects\n` + + ' resolved against the registered stack, per OBJECT rather than per file\n', + ); + console.log( + ` live ${rows.filter((r) => r.verdict === 'live').length}` + + ` · display-only ${displayOnly.length}` + + ` · inert ${inert.length}` + + ` · unresolved tokens ${unresolved.length}\n`, + ); + + const table = (label: string, list: Row[]): void => { + if (list.length === 0) { + console.log(` ${label}: none\n`); + return; + } + console.log(` ${label} (${list.length}):`); + for (const r of [...list].sort((a, b) => `${a.object}.${a.field}`.localeCompare(`${b.object}.${b.field}`))) { + const shared = r.sharedWith.length ? ` ← name also on ${r.sharedWith.join(', ')}` : ''; + console.log(` ${`${r.object}.${r.field}`.padEnd(46)}${shared}`); + } + console.log(''); + }; + + table('INERT — read by nothing, drawn nowhere', inert); + table('MASKED — inert, and invisible to a name-only grep', masked); + if (has('--all')) { + table('DISPLAY-ONLY — drawn, but nothing reads it', displayOnly); + } else { + console.log(` display-only: ${displayOnly.length} (re-run with --all to list them)\n`); + } + + // A scan that finds nothing to place has stopped working; say so rather than + // printing a confident empty ledger. + if (sites.length === 0) { + console.error('✗ no field reference resolved anywhere — the stack shape moved; fix this scan.'); + process.exitCode = 1; + return; + } + console.log( + ' This is a ledger, not a gate: each inert row is a separate enforce-or-remove\n' + + ' decision (maintainer ruling 2026-08-17, 「逐个 enforce-or-remove(推荐)」),\n' + + ' and removing a published field is the maintainer’s call, not the sweep’s.\n', + ); +}; + +if (process.argv[1] && process.argv[1].includes('scan-field-consumers')) main(); diff --git a/test/docs-view-rosters.test.ts b/test/docs-view-rosters.test.ts new file mode 100644 index 000000000..63a85581d --- /dev/null +++ b/test/docs-view-rosters.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROOT } from './helpers/repo-root'; +import stack from '../objectstack.config'; + +type AnyRec = Record; + +/* + * ─── A docs list-view roster names the views the app actually ships (#1194) ── + * + * `content/docs/revenue/products.mdx` carried a *Standard list views* section + * naming three views — **Active Products**, **By Category**, **By Family** — + * and `src/views/product.view.ts` declares neither those nor anything like + * them: the real inventory is *All Products* (a grid grouped by category) and + * *Product Catalog* (a gallery). All three locale faces said it, row for row. + * The two grouping entries look like a misreading of one real setting — + * `all_products` sets `grouping: { fields: [{ field: 'category' }] }`, which is + * one grid grouped by category, not two saved views. + * + * That is the section a manager reads to learn what queues exist, so naming + * three that cannot be found in the UI is a product defect. Nothing was + * checking it. The #729 count rule in `docs-drift.test.ts` counts objects, + * flows, dashboards, datasets, actions and positions, and views are not among + * them; the STATUS.md transcript rule beside it counts registered view FILES + * (one per object), not the individual saved views a page like this describes. + * This file is that missing rule, and it lives here rather than in + * `docs-drift.test.ts` because that file is 97KB against this repo's 100KB + * source-file cap — adding it there fails `pnpm hygiene`, measured. + * + * ## The rule is COVERAGE, not "no phantom names" — and that was measured + * + * The obvious rule is the other one: every bolded name in the section must + * resolve to a shipped view. It was written and measured first, and it is not + * viable. Across the ten English pages carrying this section it produces + * thirteen false positives on five pages, because the section legitimately + * bolds things that are not views: + * + * - column and filter names inside the same table (`**Health Score**`, + * `**Annual Revenue of $10M or more**`); + * - whole sentences (`**Status is the filter you already have.**`); + * - TAB labels in the first column where the view name is in a later one + * (`sales/activities` is written that way throughout); + * - and, worst, the pages that are RIGHT: `service/cases` closes with + * "Six names this section used to list are not views at all" and then names + * them, so the corrected page fails hardest. + * + * Excusing thirteen cases needs an exemption map bigger than the roster it + * guards — the hand-maintained list #729's header argues against. So the rule + * runs the other direction: every view the stack SHIPS for the object a page + * documents must be named somewhere in that page's roster section. It caught + * this card's defect (products named neither real view), and it is derived from + * `src/views/**` rather than written down here, so it moves when a view does. + * + * Stated limitation, since a guard that oversells itself is worse than none: a + * roster that names every real view AND one invented one passes. What cannot + * happen any more is a roster written from imagination instead of from source, + * which is what all four instances of this defect have been. + * + * ## Why only the English face resolves names + * + * The translated faces spell view labels in Chinese on most pages + * (`revenue/contracts.zh-Hans` says 全部合同), and there is no zh-Hant locale + * bundle in this app at all — `i18n.supportedLocales` is en / zh-CN / ja-JP / + * es-ES — so a traditional-Chinese label has nothing to resolve against. The + * faces are held to STRUCTURE instead, the same honest split #736 made for + * callouts: same section, same number of roster entries. That is what the + * defect actually looked like — one roster, wrong, replicated three times. + * + * ## Reverse verification + * + * Predicted **red before the content fix, green after**, and measured as such. + * Before: three English pages failed coverage — `revenue/products` (missing + * both of its views), `marketing/campaigns` (missing all four; the section + * named six views that do not exist, the same defect one page over) and + * `service/cases` (missing *Unassigned — triage*, on a page that says how many + * views it is listing). After the fixes in this PR: 0. The rule is not vacuous + * either — it resolves 54 shipped labels across ten pages, so it is comparing + * real rosters, not empty sets against empty sets. + */ +describe('a docs list-view roster names the views the app ships (#1194)', () => { + /** Every spelling the roster heading has settled into, across three faces. */ + const ROSTER_HEADING = + /^## +(Standard list views|标准列表视图|標準列表檢視|標準清單檢視|標準列表視圖|標準清單視圖)/; + + /** + * Which object each page documents. + * + * Hand-written because nothing links a docs page to an object — but it cannot + * silently go stale: the vacuity test below fails when an English page grows + * a roster section and is not listed here, so a new page joins the guard + * rather than escaping it. + */ + const PAGE_OBJECT: Record = { + 'content/docs/sales/accounts.mdx': 'crm_account', + 'content/docs/sales/contacts.mdx': 'crm_contact', + 'content/docs/sales/leads.mdx': 'crm_lead', + 'content/docs/sales/opportunities.mdx': 'crm_opportunity', + 'content/docs/sales/activities.mdx': 'crm_task', + 'content/docs/sales/quotes.mdx': 'crm_quote', + 'content/docs/service/cases.mdx': 'crm_case', + 'content/docs/marketing/campaigns.mdx': 'crm_campaign', + 'content/docs/revenue/contracts.mdx': 'crm_contract', + 'content/docs/revenue/products.mdx': 'crm_product', + }; + + /** Object → every saved view label it ships, read off the registered stack. */ + const LABELS: Map = new Map(); + for (const view of ((stack as AnyRec).views ?? []) as AnyRec[]) { + const object = view.list?.data?.object; + if (typeof object !== 'string') continue; + const labels = [ + view.list?.label, + ...Object.values((view.listViews ?? {}) as Record).map((v) => v?.label), + ].filter((l): l is string => typeof l === 'string' && l.length > 0); + LABELS.set(object, [...(LABELS.get(object) ?? []), ...labels]); + } + + /** The body of the roster section, or null when the page has none. */ + const rosterOf = (file: string): string | null => { + const path = join(REPO_ROOT, file); + if (!existsSync(path)) return null; + const lines = readFileSync(path, 'utf8').split('\n'); + const start = lines.findIndex((l) => ROSTER_HEADING.test(l)); + if (start === -1) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^## /.test(lines[i])) { + end = i; + break; + } + } + return lines.slice(start + 1, end).join('\n'); + }; + + /** Roster entries: bolded table rows plus bolded top-level bullets. */ + const entryCount = (body: string): number => + [...body.matchAll(/^\| *\*\*/gm)].length + [...body.matchAll(/^- +\*\*/gm)].length; + + const walkMdxPages = (dir: string): string[] => { + const root = join(REPO_ROOT, dir); + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const rel = join(dir, entry.name); + return entry.isDirectory() ? walkMdxPages(rel) : rel.endsWith('.mdx') ? [rel] : []; + }); + }; + + it('every English page with a roster section is mapped to an object', () => { + // Vacuity guard #1, and the thing that keeps PAGE_OBJECT from rotting: a + // page that grows one of these sections joins the rule automatically. + const unmapped = walkMdxPages('content/docs') + .filter((f) => !/\.zh-Han[st]\.mdx$/.test(f)) + .filter((f) => rosterOf(f) !== null) + .filter((f) => PAGE_OBJECT[f] === undefined); + expect( + unmapped, + `pages carrying a list-view roster that this rule does not check:\n ${unmapped.join('\n ')}\n` + + 'Add the page to PAGE_OBJECT with the object it documents — a roster nobody checks is ' + + 'how #1194 shipped three views that do not exist, in three locales.', + ).toEqual([]); + }); + + it('the stack ships views for every mapped object, and the pages have rosters', () => { + // Vacuity guard #2, both halves. A stack whose view shape moved would leave + // every label list empty and the rule would pass by comparing nothing; a + // renamed heading would make every roster null and do the same. + const empty = Object.entries(PAGE_OBJECT) + .filter(([, object]) => (LABELS.get(object) ?? []).length === 0) + .map(([file, object]) => `${file} → ${object}`); + expect( + empty, + `no view labels resolved for:\n ${empty.join('\n ')}\n` + + 'Either the registered view shape moved, or those objects lost their views.', + ).toEqual([]); + + const missingSection = Object.keys(PAGE_OBJECT).filter((f) => rosterOf(f) === null); + expect( + missingSection, + `mapped pages with no roster section any more:\n ${missingSection.join('\n ')}\n` + + 'Teach ROSTER_HEADING the new spelling, or drop the page from PAGE_OBJECT deliberately.', + ).toEqual([]); + + const resolved = [...new Set(Object.values(PAGE_OBJECT))].reduce( + (n, object) => n + (LABELS.get(object) ?? []).length, + 0, + ); + expect(resolved, 'this rule is comparing against an empty label set').toBeGreaterThan(40); + }); + + it('every view the app ships is named in its page’s roster', () => { + const drifted = Object.entries(PAGE_OBJECT).flatMap(([file, object]) => { + const body = rosterOf(file) ?? ''; + return (LABELS.get(object) ?? []) + .filter((label) => !body.includes(label)) + .map((label) => `${file} never names "${label}", which ${object} ships`); + }); + expect( + drifted, + `list-view rosters that do not match src/views:\n ${drifted.join('\n ')}\n` + + 'The registered view is the source of truth — name it on the page (and say what it ' + + 'shows), or delete the view. Do not remove the section to get green: it is the section ' + + 'a manager reads to learn which queues exist.', + ).toEqual([]); + }); + + it('every translated face carries the same roster, entry for entry', () => { + const drifted = Object.keys(PAGE_OBJECT).flatMap((en) => { + const enBody = rosterOf(en) ?? ''; + return ['.zh-Hans', '.zh-Hant'].flatMap((locale) => { + const translated = en.replace(/\.mdx$/, `${locale}.mdx`); + if (!existsSync(join(REPO_ROOT, translated))) return []; + const body = rosterOf(translated); + if (body === null) return [`${translated} has no list-view roster, but ${en} has one`]; + const [a, b] = [entryCount(body), entryCount(enBody)]; + return a === b ? [] : [`${translated}: ${a} roster entr(y|ies), but ${en} has ${b}`]; + }); + }); + expect( + drifted, + `translated rosters that do not match the English page:\n ${drifted.join('\n ')}\n` + + 'Names cannot be checked here — most translated pages spell view labels in Chinese and ' + + 'this app ships no zh-Hant bundle to resolve them against — so the faces are held to ' + + 'structure, the same split #736 made for callouts. #1194 was one wrong roster copied ' + + 'into three faces; fixing one face and not the others recreates it.', + ).toEqual([]); + }); +}); diff --git a/test/field-consumer-scan.test.ts b/test/field-consumer-scan.test.ts new file mode 100644 index 000000000..62447d284 --- /dev/null +++ b/test/field-consumer-scan.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + rows, + sitesOf, + fieldsByObject, + objectsByField, + type Row, +} from '../scripts/scan-field-consumers'; + +/** + * The consumer scan stays OBJECT-AWARE (#1193). + * + * `scripts/scan-field-consumers.ts` exists because the sweep that produced + * #1182's row set was object-blind: it called a field consumed when its NAME + * appeared in a `src/**\/*.ts` file outside `views/` and `translations/`. + * `crm_product.tax_rate` therefore read as consumed — the token also spells + * `crm_quote_line_item.tax_rate`, a different object's field whose own formula + * reads its own rate — and the product's rate reached no card. `is_taxable`, + * two declarations away in the same field group and inert for exactly the same + * reason, did reach it, because its name is unique. + * + * ## What this file guards, and what it deliberately does NOT + * + * It does not guard the NUMBER of inert fields. That was considered and + * rejected: the maintainer ruling of 2026-08-17 was 「逐个 enforce-or-remove + * (推荐)」 — a verdict per field — so a gate on the count would encode the + * blanket rule the ruling declined to make, and would go red the day someone + * lands a field one PR before its consumer. The scan is a ledger a human + * adjudicates, and `pnpm verify` does not run it. + * + * What must not regress is the scan's ABILITY TO SEE, because that failure is + * invisible: a false negative cannot appear in the scan's own output, so a + * resolver that quietly stopped distinguishing two objects would keep printing + * a confident, shorter ledger and nobody would notice. So the assertions below + * are about resolution, not about counts: + * + * 1. the two same-named `tax_rate` fields get DIFFERENT verdicts — the exact + * discrimination the old grep could not make; + * 2. every site attributed to a field belongs to an object that DECLARES it; + * 3. #1182's own verdicts are re-derivable from this scan (positive and + * negative controls, so the rule is not agreeing with everything); + * 4. the scan is not vacuous — it resolves thousands of sites, and the + * shared-name population it exists for is non-empty. + */ + +const verdictOf = (object: string, field: string): Row['verdict'] | 'absent' => + rows.find((r) => r.object === object && r.field === field)?.verdict ?? 'absent'; + +describe('the scan resolves a shared field name per object, not per file (#1193)', () => { + /** + * The card in one assertion. Both objects declare `tax_rate`; one is read by + * a formula on its own object and one is read by nothing. A name-only grep + * reports a single answer for both — and reported the wrong one. + */ + it('crm_quote_line_item.tax_rate is live and crm_product.tax_rate is not', () => { + expect(fieldsByObject.get('crm_product')?.has('tax_rate')).toBe(true); + expect(fieldsByObject.get('crm_quote_line_item')?.has('tax_rate')).toBe(true); + expect(verdictOf('crm_quote_line_item', 'tax_rate')).toBe('live'); + expect(verdictOf('crm_product', 'tax_rate')).toBe('inert'); + }); + + it('the live one is read by the total_price formula on its own object', () => { + const sites = sitesOf('crm_quote_line_item', 'tax_rate'); + expect(sites.some((s) => s.root === 'objects' && s.bucket === 'behaviour')).toBe(true); + }); + + /** + * The inert one has only CARRIER sites — the four locale bundles. That is the + * shape a removal has to clean, and the shape that proves nothing reads it. + * Asserted as "no behaviour and no display site" rather than "zero sites", + * because a locale row is not evidence of a reader and must not be mistaken + * for one in either direction. + */ + it('the inert one has locale rows and nothing else', () => { + const sites = sitesOf('crm_product', 'tax_rate'); + expect(sites.every((s) => s.bucket === 'carrier')).toBe(true); + expect(sites.map((s) => s.root)).toContain('translations'); + }); + + it('no site is ever attributed to an object that does not declare the field', () => { + const bogus = rows.flatMap((r) => + sitesOf(r.object, r.field) + .filter(() => !fieldsByObject.get(r.object)?.has(r.field)) + .map((s) => `${r.object}.${r.field} @ ${s.root}${s.path}`), + ); + expect(bogus, `sites attributed to objects that do not declare the field:\n ${bogus.join('\n ')}`).toEqual([]); + }); +}); + +describe('#1182 verdicts are re-derivable from this scan (#1193)', () => { + /** + * The reverse verification, pinned. + * + * Measured on the pre-removal tree (`c83aa744`, the commit before #1195 + * landed): of the twelve fields #1182 deleted, two read `inert` and ten read + * `display-only` — and NONE read `live`. On this tree they are gone, so what + * can be re-checked here is the other half of that card: the one row it KEPT + * and enforced. `crm_account.parent_account` read `display-only` before the + * roll-up existed and reads `live` now, because `child_account_revenue` + * names it as its `relationshipField`. A resolver that stopped reading + * summary operations would flip it back, silently. + */ + it('the row #1182 enforced now reads as live', () => { + expect(verdictOf('crm_account', 'parent_account')).toBe('live'); + const behavioural = sitesOf('crm_account', 'parent_account').filter((s) => s.bucket === 'behaviour'); + expect(behavioural.length).toBeGreaterThan(0); + }); + + it('the fields #1182 removed are gone, so the ledger cannot re-report them', () => { + for (const [object, field] of [ + ['crm_product', 'is_taxable'], ['crm_product', 'quantity_on_hand'], + ['crm_product', 'billing_type'], ['crm_case', 'parent_case'], + ['crm_contact', 'birthdate'], ['crm_campaign', 'parent_campaign'], + ] as const) { + expect(verdictOf(object, field), `${object}.${field}`).toBe('absent'); + } + }); + + /** + * Negative controls. A resolver that credited every token to every object + * would call everything live; one that credited nothing would call everything + * inert. These two rows are heavily read and must stay live, and their site + * counts are asserted as ">= 1 behaviour" rather than as a number, so + * ordinary work does not have to update a magic figure. + */ + it('heavily-read fields stay live', () => { + expect(verdictOf('crm_product', 'list_price')).toBe('live'); + expect(verdictOf('crm_opportunity', 'amount')).toBe('live'); + }); +}); + +describe('the scan is not vacuous (#1193)', () => { + it('resolves a real population of fields and sites', () => { + // A stack shape that moved would leave every row at zero sites and the + // ledger would report the whole app as inert — loud, but for the wrong + // reason. Fail here, where the message is true. + expect(rows.length).toBeGreaterThan(300); + expect(rows.filter((r) => r.verdict === 'live').length).toBeGreaterThan(200); + }); + + it('the shared-name population this scan exists for is non-empty', () => { + // If no field name were ever shared across objects, the object-aware + // resolution would be guarding nothing and this whole file would be + // agreeing with everything. + const shared = [...objectsByField].filter(([, objects]) => objects.length > 1); + expect(shared.length).toBeGreaterThan(10); + }); +});