feat(lint): report a declared field with zero consumers across the registered metadata roots (#15922) - #16323
Conversation
…gistered metadata roots New advisory rule `field-no-consumers` (`validateFieldConsumers`): an object field that nothing in the stack reads or displays is a warning on validate, build and lint. Object-aware (the same name on two objects gets two verdicts), carriers (translations, seeds, mappings, permission grants, flow writes, prose) never count, and the finding carries the verdict, the carrier paths a removal must clean, and the roots scanned. Exemptions are derived from the spec: injected system columns, the ADR-0079 title field, master_detail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…o-consumers is registered `check:docs-transcript-drift` holds the four hand-written `os validate` / `os build` transcripts equal to what `authoringRulesFor()` derives; the new registry entry moves that count from 42 to 43. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 Docs Drift CheckThis PR changes 1 package(s): 74 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 93af4788b79249c26c60aaffc638d3c23ca5f257 && git checkout 93af4788b79249c26c60aaffc638d3c23ca5f257
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ac6213ee95e49fa62af824b60a062b3f97dfc87f ad909f3709ea7721cd8fca35c4ee82fec04e7c0a && git checkout -B drift-repro ac6213ee95e49fa62af824b60a062b3f97dfc87f && git merge --no-ff ad909f3709ea7721cd8fca35c4ee82fec04e7c0a
node scripts/docs-audit/affected-docs.mjs --json ac6213ee95e49fa62af824b60a062b3f97dfc87f
|
Fixes #15922
Clause-②: yes
What this adds
A new advisory rule in
@objectstack/lint,field-no-consumers(validateFieldConsumers), registered inAUTHORING_RULESsoos validate,os buildandos lintall run it. It reports, as awarning, an object field that nothing in the stack reads or displays — the field-level remainder of #4698 (that card is not addressed here; this is the platform half of the hotcrm#1543 ruling F, verbatim 「1543 选 F」, under which HotCRM retired its own scanner).The retired scanner was read as design input, not ported: its judgements (object-aware resolution, the consumer/carrier split, prose is not a read, a flow WRITE is a carrier) are carried; its implementation is not. The rule is built on the package's shared primitives —
collectionEntries(positional paths on both collection shapes),recordsOf,injectedColumnsFor, and the spec'sresolveDisplayField.Derived judgments (for the in-seat contract review)
behavioursites and zerodisplaysites across the scanned roots;carriersites never rescue it. Exempt: registry-injected system columns an author re-declared (injectedColumnsFor, i.e.resolveInjectedSystemColumnsin@objectstack/spec/data), the record title field (resolveDisplayField, the ADR-0079nameFieldladder —packages/spec/src/data/display-name.ts:220), andmaster_detailfields (ADR-0035 —packages/objectql/src/master-detail.tsnames cascade delete,controlled_by_parentsharing, roll-ups and inline grids as readers of the relationship). A stack that declares no consumer root at all (objects only, or objects plus carriers) is not judged.validateFieldConsumers,FIELD_NO_CONSUMERS,FIELD_CONSUMER_ROOTS,FIELD_CARRIER_ROOTS; typesFieldConsumerFinding,FieldConsumerVerdict,FieldConsumerSeverity. The finding shape is the standard six keys plusobject,field,verdict,carriers[](config paths a removal must clean) androotsScanned[].tier: 'advisory',input: 'normalized',commands: ALL,surfaces: CLI_ONLYwithsurfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT— the sharpest instance of that reason in the table: the verdict is the ABSENCE of a reference across views / pages / flows / datasets, none of which the per-write snapshot carries, so on an object write it would report every field of the written object as inert.Taxonomy decision — one rule id, verdict on the finding
HotCRM's scanner produced
live/display-only/inertwith a separate carrier count (its code has three verdicts, not four). Decided on the four axes:display-onlyis the ordinary state of most fields (a drawnphone) and the scanner itself listed it only under--all— reporting it would be noise, so it is not a verdict here.verdict: 'inert' | 'carrier-only',carriers[]); if a consumer ever needs separate ids the split is additive, whereas retiring two ids later is a breaking change.carrier-onlyas "fine" and laundering the inert class through it.Severity:
warning, per triage's placement; a refusal would narrow the authorable surface (today-valid metadata would start being refused) and is the maintainer's call, not this PR's. The rule never emitserror(the wiring guard reads its source to hold that).Boundaries, stated in the rule and in every finding
rootsScannedand the hint: consumersobjects, views, pages, apps, flows, dashboards, reports, datasets, actions, hooks, jobs, emailTemplates, agents, tools, skills, apis, webhooks, sharingRules, analyticsCubes; carrierstranslations, data, mappings, permissions.test/fixtures are never scanned — the boundary that made hotcrm#1543 a decision is written down rather than left as lore.object/objectName/targetObject/data.object/config.objectName/list.data.object/ adatasetresolved to its object / a map keyed by object name / a flow's trigger object), and only when that object declares the token; in a text blob the nearest preceding mention of a declared object is a second candidate. The same name on two objects gets two verdicts, and the finding names the other objects so a per-object verdict is never mistaken for a name-level one.{record.x}in a notify message) is a read.source,expression,condition, …) a bare identifier is a read (total_amount >= 5000in a flow trigger); elsewhere only reference shapes count (record.x, a quoted name, a{x}token, an object-literal key).Corpus reading —
examples/*atad909f370Spot-checked against the source rather than trusted: app-crm's
crm_account.*/crm_contact.*rows are real (that example ships no view for either object; the views index exports Opportunity, Lead and Activity only),crm_opportunity.days_to_closeis a formula nothing draws,showcase_task.sync_statusis only ever WRITTEN by a flow, andshowcase_field_zoo.f_imageis named nowhere outside its declaration. Two spot checks found walker misses and were fixed with pins: theceltagged-template envelope'ssourcetext was being skipped (quantity/unit_price on the CRM line item), and a bare identifier in a triggerconditionwas not a reference shape (total_amounton the showcase expense report).packages/apps/*(setup / studio / account) are plugins, not authored stacks — their own tests say so — so there is no stack to run the rule over; no reading is invented for them. HotCRM's currentmainwas not re-derived: thehotcrmrepository is outside this session's scope (its retired scanner source was readable, its current stack was not loaded), so no HotCRM count appears here and the historical 15 is not used as an acceptance number.These findings are data, not things this PR fixes. The one design question the corpus raises is recorded below for the maintainer.
Tests and gates — pinned to
ad909f370packages/lint/src/validate-field-consumers.test.ts— 25 tests: the five-verdict fixture with the same-name pair (assert by rule id AND declaration path), positional path on the array shape, negative control (all consumed → zero findings), the skip gate, each derived exemption both ways, one credit per root, carriers never rescue, prose vs template, bare identifier in an expression, and registry wiring end to end throughrunAuthoringRuleson all three commands.pnpm --filter @objectstack/lint test—Test Files 100 passed (100) · Tests 3405 passed (3405);pnpm --filter @objectstack/lint typecheck— tsc clean andcheck:test-typecheck: OK.5 failed | 20 passed), restored withgit checkout HEAD -- path, blob hash equal to HEAD's andgit diff HEADempty. The test imports the rule fromsrc(relative.jsspecifier, noexportshop), so no dist preflight applies.dist:@objectstack/cliunit tier180 passed (180)(one pin file first refused on an unbuilt CLI, then measured green afterpnpm --filter @objectstack/cli build; the integration tier is declared to CI),@objectstack/metadata-protocol166 passed | 2 skipped,@objectstack/platform-objects35 passed,@objectstack/example-crm5 passed,@objectstack/example-todo4 passed.node scripts/pm/dispatch-gates.mjs --ranatad909f370:82 derived, 82 run, 0 UNRUN. Two of those runs are NOT MEASURED, not green:pnpm check:type-check-debtandpnpm check:dual-build-cjs-loadsboth exit 3PREREQUISITE NOT MET(unbuilt dists in this worktree). Every other family exited 0, includingcheck:docs-transcript-driftafter the second commit.check:docs-transcript-driftholds four hand-writtenos validate/os buildtranscripts undercontent/docs/equal to whatauthoringRulesFor()derives; the new registry entry moves that count from 42 to 43, so those four lines moved with it.Changeset:
.changeset/lint-field-no-consumers.md—@objectstack/lintminor.Open question for the maintainer (not a blocker)
Should default-layout rendering count as display? A form view with no declared sections renders every field, and a nav-exposed object gets a default list and detail — so on the showcase, roughly half the rows are fields drawn only by a default layout. The rule reads declared metadata only (the same definition HotCRM's adjudicated ledger used, and AGENTS.md's "explicit composition over default magic"), and states the boundary in every finding. Recommendation: keep declared-only, and read the corpus counts as the volume cost of that choice; if the maintainer prefers the default-layout credit, it is a bounded follow-up (credit every non-hidden field of an object that has a section-less form view or a nav entry).
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Generated by Claude Code