Repository navigation
feat(scripts): object-aware field consumer ledger, and the product docs it corrects (#1193, #1194) - #1202
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-steve
marked this pull request as ready for review
August 17, 2026 05:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1193
Fixes #1194
crm_product.tax_rateis inert, and the sweep that produced #1182's row set could not have found it. This PR ships the scan that can, the complete list of what that scan surfaces, and the docs corrections for both cards. It does not touchtax_rateitself — see The verdict below.1. The object-aware scan —
pnpm scan:fieldsscripts/scan-field-consumers.ts. The sweep it replaces called a field consumed when its name appeared in asrc/**/*.tsfile outsideviews/andtranslations/. That is line-oriented and object-blind:tax_ratematches lines inquote_line_item.object.ts, so the product's field read as consumed and never reached a card, whileis_taxable— two declarations away, inert for the same reason — did reach it because its name is unique.The new scan resolves each hit to the object whose declaration encloses it, not the file it sits in. The whole consumer surface of this app is the registered stack, so it walks
objectstack.config.tsand carries an object context down the tree (object/objectName/targetObject/data.object/list.data.object,datasetresolved through the dataset's own object, a flow's triggerobjectNamehoisted to its later nodes, a map keyed by object name). Text blobs — hook handlers, action bodies, CEL sources — get the same rule applied inside the blob, withapi.object('crm_product')switching context mid-body. Tokens that resolve to no object are counted and reported, never dropped; a scan that silently discards what it cannot place is how the previous one granted immunity.Consumption is bucketed rather than boolean, because a boolean would have contradicted #1182's own verdicts (it removed fields a view column displayed, and kept one a roll-up computed):
highlightFields,searchableFields, indexes;live= has a behaviour site.display-only= drawn, nothing reads it.inert= neither.Report-only, not a
pnpm verifygate — the argumentThe 2026-08-17 ruling was 「逐个 enforce-or-remove(推荐)」: a verdict per field. A gate that failed on any inert field would encode the blanket rule that 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 needed fixed was not inertness — it was invisibility, and a ledger fixes that.
So the count is not gated; the scan's ability to see is.
test/field-consumer-scan.test.tspins that two objects sharing a field name still get different verdicts, that no site is ever attributed to an object that does not declare the field, and that #1182's enforced row still reads live. A gate on the number would have been cheaper and would have guarded the wrong thing.Reverse verification of the scan, on the pre-removal tree
Run in a comparison worktree at
c83aa744(the commit before #1195 removed the fields), because that is the only tree where the question is answerable. Of the twelve fields #1182 deleted, two readinert, ten readdisplay-only, none readlive— and on that same tree:Two objects, one token, opposite verdicts, from one run. That discrimination is the whole card. Positive controls on the same run:
crm_product.list_priceread=23,crm_opportunity.amountread=56 — the scan is not calling everything inert.The enforce half of #1182 tracks too:
crm_account.parent_accountreadsdisplay-onlyon the pre tree andliveon this one, becausechild_account_revenuenow names it as itsrelationshipField.Ablation of the guard test: replacing the per-object credit with an object-blind one (credit every object declaring the token) turns exactly the two discrimination assertions red —
expected 'live' to be 'inert'— while the seven population-level ones stay green. Predicted red, measured red, restored from the commit and re-run green.2. What else was masked — the complete list
The card's most useful output.
pnpm scan:fieldson62560210, 334 declared fields across 18 objects: live 276 · display-only 43 · inert 15.Inert (15). "Masked" = the name is also declared on another object, so the old token grep read it as consumed and it could never have reached #1182.
crm_product.tax_ratecrm_quote_line_itemcrm_campaign.descriptiondescriptionon onecrm_contract.descriptioncrm_quote_line_item.descriptioncrm_quote_line_item.line_numbercrm_opportunity_line_itemcrm_quote_line_item.subtotalcrm_quotecrm_contact.mailing_streetcrm_contact.mailing_citycrm_contact.mailing_statecrm_contact.mailing_postal_codecrm_contact.mailing_countrycrm_account.logocrm_article_feedback.commentcrm_campaign_member.added_datecampaign-enrollment.flowand seeded, read by nothing, drawn nowherecrm_forecast.seed_keyexternalIdby deliberate design (#613) — listed for completeness, not a defectSix masked rows, five of them new. Every one was immune to the old method by construction.
None of these is touched here. Each is a separate adjudication; they are recorded for the maintainer as #1199, with two clusters called out — the five
crm_contact.mailing_*fields (inert and asked for on the shipped customer import template, which is worse than merely inert) and the three unshowndescriptionfields.3. The verdict on
crm_product.tax_rate— enforce is wrong, and removal is not mineEnforce was mine to make and would have made a published promise true, so it was measured rather than assumed. Real engine, shipped stack, memory driver, product at
tax_rate: 10/list_price: 1000, one line atquantity: 2:subtotaltotal_pricesubtotaltotal_priceThe quote total comes out 200 below the sum of its own line totals. The two halves model tax on different calibres and neither reads the other: the line's
total_priceformula applies a per-line percentage, whilequote_total_rolluprecomputes from rawquantity/unit_price/discountand addscrm_quote.tax, a manual amount. A rep who also fills that field taxes the customer twice.src/data/revenue.seed.tsalready refuses to seed a line rate for this exact reason.Also measured, and recorded so they are not rediscovered: the price-fill factory is shared with
crm_opportunity_line_item, which declares notax_rateat all — and the engine stores the undeclared key anyway rather than refusing it, which is a finding in its own right (#1200).ctx.objectis on the hook context, so a conditional is writable and the QuickJS body-only rule is not the obstacle; the arithmetic is.Since #1195 removed
crm_product.is_taxable, "is this taxable" no longer exists as a concept — only a rate does, and rate 0 has to mean "not taxed". Arithmetically fine; it does mean a stamped 0 and an unset one are the same thing.So enforce is wrong, removal is a maintainer decision, and the field is unchanged here. The verdict request is #1198, filed as its own card so that it stays open once this card is closed — with the measurement, the removal cost, and a third option (deriving
crm_quote.taxfrom the product rate) that is a design change rather than an adjudication. #1198 is not addressed by this PR.What is corrected is the page, which was false today under any future ruling: the pricing table now says the rate is stored and applied nowhere, a short paragraph explains that tax is a quote-level amount, and the sales-rep tip that claimed "pricing and tax are auto-filled" now says what is actually filled in. All three locales.
4. #1194, and a guard for the class
Verified against
src/views/product.view.tsrather than taken from the finding: the object shipsall_products("All Products", grid, sorted by name,groupingoncategory) andproduct_catalog("Product Catalog", gallery,imageas cover), reachable from two switcher tabs. Active Products, By Category and By Family are not views; the category grouping is a setting on the one grid, and nothing groups by family. Rewritten in the table shape the corrected sibling pages already use, in all three locales.The guard: coverage, not "no phantom names" — because the obvious rule was measured first
test/docs-view-rosters.test.ts. The obvious rule is that every bolded name in a roster section must resolve to a shipped view. It was written first and produces thirteen false positives across five of the ten English pages, because these sections legitimately bold things that are not views: column and filter names inside the same table, whole sentences (**Status is the filter you already have.**), tab labels where the view name is in a later column (sales/activitiesis written that way throughout) — and worst, the pages that are right:service/casescloses 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.So the rule runs the other way: every view the stack ships for the object a page documents must be named in that page's roster. Zero false positives, derived from
src/views/**rather than written down, and it caught this card's defect directly. Stated limitation, in the file: a roster that names every real view plus an invented one still passes. What can no longer happen is a roster written from imagination instead of from source, which is what all four instances of this defect have been.Only the English face resolves names — most translated pages spell view labels in Chinese (
revenue/contracts.zh-Hanssays 全部合同) and there is no zh-Hant bundle in this app at all (supportedLocalesis en / zh-CN / ja-JP / es-ES), so a traditional-Chinese label has nothing to resolve against. The faces are held to structure instead — same section, same number of roster entries — the same honest split #736 made for callouts, and it is exactly the failure mode #1194 was: one wrong roster copied into three faces.It lives in its own file rather than in
test/docs-drift.test.tsbecause that file is 97KB against this repo's 100KB source-file cap; appending there failspnpm hygiene, measured.Two pages the guard turned red, fixed in place
Same defect class, mechanically fixed against source, no other claim on either file, same gate family. Both named here rather than slipped in, and the claim's file surface was amended in the same round.
content/docs/marketing/campaigns*.mdx— the section named six views (Active Campaigns, Upcoming Campaigns, Completed This Quarter, Over Budget, By Type, Best ROI).src/views/campaign.view.tsships four — All Campaigns, Campaign Schedule, Launch Calendar, Marketing Timeline — and not one of the six exists. Rewritten from the view file, three locales. ROI is called out explicitly as not being a grid column: it is on the record under Costs.content/docs/service/cases*.mdx— the roster saidcrm_caseships "seven" views and listed seven; it ships eight. Unassigned — triage was missing from a page that states its own count. One row added and the count corrected, three locales.Reverse verification of the guard
Predicted red before the content fix, green after. Measured by restoring the three English pages from
origin/mainon top of the guard:Seven missing views across three pages, and the parity half caught the half-fixed locale faces — which is precisely what it is there for. Restored from the commit; green after.
Gates
Run locally, all green, on the final commit
62560210(the union was re-run on this HEAD after the last commit, including the full suite):pnpm validatecolSpanadvisories)pnpm typecheckscripts/**/*.tsandtest/**/*are insidetsconfig.includepnpm lintpnpm lint:i18n-gatei18n/missing-*issuespnpm hygienepnpm hygiene:tokenssrc/file —git diff origin/main -- src/ objectstack.config.tsis emptypnpm buildpnpm testnode scripts/check-stackblitz-lock.mjspnpm verifydoes notLeft to CI: everything else in the lint/CI workflow beyond the list above, per #1149 — a green local
verifyis not the full CI set.MDX validated structurally, not compiled. Per #1169 the
Docs Appworkflow does not trigger on a docs-only change, so nothing will compile these nine files. Checked mechanically instead, all nine: frontmatter present and closed with atitle, every table's column count consistent with a separator row,**balanced on every line, and — the two that actually break an MDX build — no bare<before a letter and no bare{outside a code span. Section headings and line counts stay identical across the three faces of each page.Not touched:
crm_account.health_score(#1186), andcrm_product.tax_rateitself (#1198 remains open).Generated by Claude Code