Skip to content

feat(lint): report a declared field with zero consumers across the registered metadata roots (#15922) - #16323

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-15922-lint-field-zero-consumers
Sep 6, 2026
Merged

feat(lint): report a declared field with zero consumers across the registered metadata roots (#15922)#16323
baozhoutao merged 2 commits into
mainfrom
claude/issue-15922-lint-field-zero-consumers

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #15922

Clause-②: yes

What this adds

A new advisory rule in @objectstack/lint, field-no-consumers (validateFieldConsumers), registered in AUTHORING_RULES so os validate, os build and os lint all run it. It reports, as a warning, 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's resolveDisplayField.

Derived judgments (for the in-seat contract review)

  • New accept/report set. Nothing is refused. A field is reported when it has zero behaviour sites and zero display sites across the scanned roots; carrier sites never rescue it. Exempt: registry-injected system columns an author re-declared (injectedColumnsFor, i.e. resolveInjectedSystemColumns in @objectstack/spec/data), the record title field (resolveDisplayField, the ADR-0079 nameField ladder — packages/spec/src/data/display-name.ts:220), and master_detail fields (ADR-0035 — packages/objectql/src/master-detail.ts names cascade delete, controlled_by_parent sharing, 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.
  • Public surface gained. validateFieldConsumers, FIELD_NO_CONSUMERS, FIELD_CONSUMER_ROOTS, FIELD_CARRIER_ROOTS; types FieldConsumerFinding, FieldConsumerVerdict, FieldConsumerSeverity. The finding shape is the standard six keys plus object, field, verdict, carriers[] (config paths a removal must clean) and rootsScanned[].
  • Wiring. tier: 'advisory', input: 'normalized', commands: ALL, surfaces: CLI_ONLY with surfaceReason: 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 / inert with a separate carrier count (its code has three verdicts, not four). Decided on the four axes:

  • 实际业务需求 — measured on the adjudications the scanner fed (hotcrm#1198 / feat(spec): add fieldGroups MVP to ObjectSchema #1199): the actionable difference between rows was what a removal must clean, which is the carrier site list, not a second rule id. display-only is the ordinary state of most fields (a drawn phone) and the scanner itself listed it only under --all — reporting it would be noise, so it is not a verdict here.
  • 项目长远合理性 — the taxonomy stays machine-readable as data on the finding (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.
  • 防 AI 犯错 — one id, one fix sentence: give it a consumer or remove it and clean the listed carriers. Two ids invite reading carrier-only as "fine" and laundering the inert class through it.
  • 创业阶段不扩散需求 — the smallest exported surface that carries the whole reading.

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 emits error (the wiring guard reads its source to hold that).

Boundaries, stated in the rule and in every finding

  • Roots scanned, in rootsScanned and the hint: consumers objects, views, pages, apps, flows, dashboards, reports, datasets, actions, hooks, jobs, emailTemplates, agents, tools, skills, apis, webhooks, sharingRules, analyticsCubes; carriers translations, 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-aware by construction: a reference is credited to the object whose declaration encloses it (object / objectName / targetObject / data.object / config.objectName / list.data.object / a dataset resolved 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.
  • Carriers never count: a translation label, a seed value, an import-mapping column, a field-level permission grant, a flow that only writes the field, prose naming it. An interpolation token inside prose ({record.x} in a notify message) is a read.
  • Under expression-bearing keys (source, expression, condition, …) a bare identifier is a read (total_amount >= 5000 in a flow trigger); elsewhere only reference shapes count (record.x, a quoted name, a {x} token, an object-literal key).

Corpus reading — examples/* at ad909f370

app objects fields findings inert carrier-only
app-crm 6 49 10 4 6
app-showcase 24 210 63 32 31
app-todo 1 18 5 0 5
app-multi-package 2 5 3 3 0

Spot-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_close is a formula nothing draws, showcase_task.sync_status is only ever WRITTEN by a flow, and showcase_field_zoo.f_image is named nowhere outside its declaration. Two spot checks found walker misses and were fixed with pins: the cel tagged-template envelope's source text was being skipped (quantity/unit_price on the CRM line item), and a bare identifier in a trigger condition was not a reference shape (total_amount on 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 current main was not re-derived: the hotcrm repository 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 ad909f370

  • packages/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 through runAuthoringRules on all three commands.
  • pnpm --filter @objectstack/lint testTest Files 100 passed (100) · Tests 3405 passed (3405); pnpm --filter @objectstack/lint typecheck — tsc clean and check:test-typecheck: OK.
  • Ablation, from the committed state: mutated the verdict guard so a carrier rescues a field (anchor count 1→0, marker 0→1 proven on disk), suite went red on exactly the five carrier-sensitive assertions (5 failed | 20 passed), restored with git checkout HEAD -- path, blob hash equal to HEAD's and git diff HEAD empty. The test imports the rule from src (relative .js specifier, no exports hop), so no dist preflight applies.
  • Downstream consumers of the rule table, tested against a rebuilt lint dist: @objectstack/cli unit tier 180 passed (180) (one pin file first refused on an unbuilt CLI, then measured green after pnpm --filter @objectstack/cli build; the integration tier is declared to CI), @objectstack/metadata-protocol 166 passed | 2 skipped, @objectstack/platform-objects 35 passed, @objectstack/example-crm 5 passed, @objectstack/example-todo 4 passed.
  • node scripts/pm/dispatch-gates.mjs --ran at ad909f370: 82 derived, 82 run, 0 UNRUN. Two of those runs are NOT MEASURED, not green: pnpm check:type-check-debt and pnpm check:dual-build-cjs-loads both exit 3 PREREQUISITE NOT MET (unbuilt dists in this worktree). Every other family exited 0, including check:docs-transcript-drift after the second commit.
  • Second commit: check:docs-transcript-drift holds four hand-written os validate / os build transcripts under content/docs/ equal to what authoringRulesFor() 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/lint minor.

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

…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 111 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

74 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ac6213ee95e49fa62af824b60a062b3f97dfc87f.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 34 pages), sharingModel (literal, 39 pages)
  • 19 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ac6213ee95e49fa62af824b60a062b3f97dfc87fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 93af4788b79249c26c60aaffc638d3c23ca5f257 — the merge of head ad909f3709ea7721cd8fca35c4ee82fec04e7c0a into base ac6213ee95e49fa62af824b60a062b3f97dfc87f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ac6213ee95e49fa62af824b60a062b3f97dfc87f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@baozhoutao
baozhoutao marked this pull request as ready for review September 6, 2026 14:45
@baozhoutao
baozhoutao enabled auto-merge September 6, 2026 14:45
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit a87163c Sep 6, 2026
43 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15922-lint-field-zero-consumers branch September 6, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants