From 70ba97c081a00035202ffad393d937002517a24e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 6 Sep 2026 13:48:56 +0000 Subject: [PATCH 1/2] feat(lint): report a declared field with zero consumers across the registered 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 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .changeset/lint-field-no-consumers.md | 11 + packages/lint/src/authoring-rules.ts | 26 + packages/lint/src/index.ts | 14 + .../lint/src/validate-field-consumers.test.ts | 357 ++++++++++ packages/lint/src/validate-field-consumers.ts | 624 ++++++++++++++++++ 5 files changed, 1032 insertions(+) create mode 100644 .changeset/lint-field-no-consumers.md create mode 100644 packages/lint/src/validate-field-consumers.test.ts create mode 100644 packages/lint/src/validate-field-consumers.ts diff --git a/.changeset/lint-field-no-consumers.md b/.changeset/lint-field-no-consumers.md new file mode 100644 index 0000000000..b50a7bf6c1 --- /dev/null +++ b/.changeset/lint-field-no-consumers.md @@ -0,0 +1,11 @@ +--- +"@objectstack/lint": minor +--- + +New advisory rule `field-no-consumers` (`validateFieldConsumers`): a field declared on an object that nothing in the stack reads or displays is reported as a `warning` by `os validate`, `os build` and `os lint`. + +Until now such a field was schema-valid and passed every platform check — the declaration was inert and nothing in the toolchain said so. The rule is object-aware (the same field name on two objects gets two verdicts, resolved against the object whose declaration encloses each reference), and it distinguishes consumers from carriers: a view column, form section, page binding, flow node, dataset dimension, widget filter, formula, validation, hook or action is a consumer; a translation label, a seed value, an import-mapping column, a field-level permission grant or a flow that only writes the field is a carrier and never counts. The finding carries the verdict (`carrier-only` with the carrier paths a removal must clean, or `inert`), the roots scanned, and — when the name is also declared elsewhere — the other objects, so a per-object verdict is never mistaken for a name-level one. + +Exempt, each derived from the spec rather than listed by hand: the registry-injected system columns an author re-declared, the record's title field (ADR-0079 `nameField` ladder), and `master_detail` fields (ADR-0035 — cascade delete, `controlled_by_parent` sharing and roll-ups read the relationship by declaration). A stack that declares no consumer root at all (objects only, or objects plus carriers) is not judged: its consumers live in another package. Test fixtures are never scanned. + +Public surface: `validateFieldConsumers`, `FIELD_NO_CONSUMERS`, `FIELD_CONSUMER_ROOTS`, `FIELD_CARRIER_ROOTS`, and the `FieldConsumerFinding` / `FieldConsumerVerdict` / `FieldConsumerSeverity` types. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7944703d0f..126e471db6 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -118,6 +118,7 @@ import { validateCapabilityReferences } from './validate-capability-references.j import { validateFlowTriggerReadiness } from './validate-flow-trigger-readiness.js'; import { validateApprovalApprovers } from './validate-approval-approvers.js'; import { validateRecordTitle } from './validate-record-title.js'; +import { validateFieldConsumers } from './validate-field-consumers.js'; import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; @@ -928,6 +929,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, run: (stack) => validateRecordTitle(stack), }, + // [#15922] A declared field that nothing in the stack reads or displays — + // the field-level remainder of #4698's "declared but never read" class, + // landed here under the hotcrm#1543 ruling. Object-aware (the same name on + // two objects gets two verdicts) and advisory: a consumer can live outside + // the stack (an API client, another package's hook, a Studio-authored view), + // so the ceiling for a static check is a warning — a refusal would narrow + // the authorable surface and is the maintainer's call. `normalized` because + // it needs no parsed stack (it resolves names, not shapes), so `os lint` + // runs it too. + // + // CLI-only for the FULL-SNAPSHOT reason, and it is the sharpest instance of + // that reason in the table: the rule's whole 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. + { + name: 'validateFieldConsumers', + tier: 'advisory', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-field-consumers.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, + run: (stack) => validateFieldConsumers(stack), + }, // ADR-0085 — `stageField` / `highlightFields` / `Field.group` are pointers // into the object's field map; a dangling one is Zod-valid and silently inert // at render. Advisory: every consumer degrades gracefully. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6a673ce439..5f125bb4bc 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -628,6 +628,20 @@ export type { export { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js'; export type { NavAccessFinding, NavAccessSeverity } from './validate-nav-access.js'; +// [#15922] A declared field with zero consumers across the registered +// metadata roots — advisory, object-aware, one rule id with the verdict +// carried on the finding (see the module note for the taxonomy decision). +export { + validateFieldConsumers, + FIELD_NO_CONSUMERS, + CONSUMER_ROOTS as FIELD_CONSUMER_ROOTS, + CARRIER_ROOTS as FIELD_CARRIER_ROOTS, +} from './validate-field-consumers.js'; +export type { + FieldConsumerFinding, + FieldConsumerSeverity, + FieldConsumerVerdict, +} from './validate-field-consumers.js'; export { validateTranslationReferences, diff --git a/packages/lint/src/validate-field-consumers.test.ts b/packages/lint/src/validate-field-consumers.test.ts new file mode 100644 index 0000000000..1b1a338655 --- /dev/null +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + CARRIER_ROOTS, + CONSUMER_ROOTS, + FIELD_NO_CONSUMERS, + validateFieldConsumers, +} from './validate-field-consumers.js'; +import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; + +type AnyRec = Record; + +/** + * The HotCRM shape, reduced: two objects sharing a field NAME (`tax_rate`), + * consumed on one and merely carried on the other, plus one field of every + * verdict the rule distinguishes and one of every exemption it derives. + */ +function corpus(): AnyRec { + return { + objects: [ + { + name: 'inv_product', + label: 'Product', + fields: { + name: { type: 'text', label: 'Name' }, // title field → exempt + sku: { type: 'text', label: 'SKU' }, // display-only: a view column + list_price: { type: 'currency', label: 'List Price' }, // live: a formula reads it + discount: { type: 'formula', expression: 'record.list_price * 0.1' }, // display-only: drawn + tax_rate: { type: 'percent', label: 'Tax Rate' }, // carrier-only: translation + seed + is_taxable: { type: 'boolean', label: 'Taxable' }, // inert: nothing at all + weight: { type: 'number', label: 'Weight' }, // carrier-only: a flow WRITES it + color: { type: 'text', label: 'Color' }, // carrier-only: a permission grants it + owner_id: { type: 'lookup', reference: 'sys_user' }, // injected column re-declared → exempt + }, + }, + { + name: 'inv_line', + label: 'Line', + fields: { + name: { type: 'text', label: 'Name' }, + product: { type: 'lookup', reference: 'inv_product', displayField: 'sku' }, + qty: { type: 'number', label: 'Qty' }, + tax_rate: { type: 'percent', label: 'Tax Rate' }, // live: its own formula reads it + total: { type: 'formula', expression: 'record.qty * record.tax_rate' }, // display-only: a page draws it + status: { type: 'select', label: 'Status' }, // live: a flow filter key + memo: { type: 'text', label: 'Memo' }, // live: a hook handler reads it + stage: { type: 'select', label: 'Stage' }, // live: a widget filter through its dataset + amount: { type: 'currency', label: 'Amount' }, // live: a dataset measure + order: { type: 'master_detail', reference: 'inv_order' }, // relationship → exempt + }, + }, + ], + views: [ + { + list: { + type: 'grid', + data: { provider: 'object', object: 'inv_product' }, + columns: [{ field: 'sku' }, { field: 'discount' }], + }, + }, + ], + pages: [ + { + name: 'line_detail', + object: 'inv_line', + regions: [ + { + name: 'main', + components: [ + { type: 'record:details', properties: { sections: [{ title: 'Main', fields: ['total', 'product'] }] } }, + ], + }, + ], + }, + ], + flows: [ + { + name: 'line_flow', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'inv_line', triggerType: 'record-created' } }, + { id: 'get', type: 'get_record', config: { objectName: 'inv_line', filter: { status: 'open' } } }, + { + id: 'upd', + type: 'update_record', + config: { objectName: 'inv_product', fields: { weight: '{record.qty}' } }, + }, + ], + }, + ], + hooks: [ + { + name: 'line_memo', + object: 'inv_line', + events: ['beforeInsert'], + // Object-aware text scan: `tax_rate` here belongs to inv_line, and must + // NOT rescue inv_product.tax_rate. + handler: (ctx: { input: AnyRec }) => { + ctx.input.memo = `rate ${ctx.input.tax_rate}`; + }, + }, + ], + datasets: [ + { + name: 'line_metrics', + object: 'inv_line', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }], + }, + ], + dashboards: [ + { + name: 'board', + widgets: [{ id: 'w', type: 'metric', dataset: 'line_metrics', filter: { stage: 'won' }, values: ['sum_amount'] }], + }, + ], + translations: [ + { en: { objects: { inv_product: { fields: { tax_rate: { label: 'Tax Rate' }, is_active: { label: 'x' } } } } } }, + ], + data: [{ object: 'inv_product', records: [{ name: 'Widget', tax_rate: 0.2 }] }], + permissions: [{ name: 'ps', objects: { inv_product: { allowRead: true, fields: { color: 'read' } } } }], + }; +} + +const byPath = (findings: ReturnType) => + Object.fromEntries(findings.map((f) => [f.path, f])); + +describe('validateFieldConsumers (#15922)', () => { + it('reports exactly the carrier-only and inert fields, by rule id and declaration path', () => { + const findings = validateFieldConsumers(corpus()); + expect(findings.map((f) => f.rule)).toEqual(Array(findings.length).fill(FIELD_NO_CONSUMERS)); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].fields.tax_rate', + 'objects[0].fields.is_taxable', + 'objects[0].fields.weight', + 'objects[0].fields.color', + ]); + }); + + it('is object-aware: the same name is live on one object and carrier-only on the other', () => { + const f = byPath(validateFieldConsumers(corpus())); + const product = f['objects[0].fields.tax_rate']; + expect(product).toBeDefined(); + expect(product.object).toBe('inv_product'); + expect(product.field).toBe('tax_rate'); + expect(product.verdict).toBe('carrier-only'); + expect(product.message).toContain('"inv_line"'); + expect(product.message).toContain('verdicts are per object'); + expect(f['objects[1].fields.tax_rate']).toBeUndefined(); + }); + + it('lists the carrier sites a removal must clean, with their config paths', () => { + const f = byPath(validateFieldConsumers(corpus())); + expect(f['objects[0].fields.tax_rate'].carriers).toEqual([ + 'translations[0].en.objects.inv_product.fields.tax_rate', + 'data[0].records[0].tax_rate', + ]); + // A flow that only WRITES the field carries it. + expect(f['objects[0].fields.weight'].verdict).toBe('carrier-only'); + expect(f['objects[0].fields.weight'].carriers).toEqual(['flows[0].nodes[2].config.fields.weight']); + // A field-level permission grant carries it. + expect(f['objects[0].fields.color'].verdict).toBe('carrier-only'); + expect(f['objects[0].fields.color'].carriers).toEqual(['permissions[0].objects.inv_product.fields.color']); + // Nothing at all. + const inert = f['objects[0].fields.is_taxable']; + expect(inert.verdict).toBe('inert'); + expect(inert.carriers).toEqual([]); + expect(inert.message).toContain('Verdict: inert'); + expect(inert.message).not.toContain('The same name'); + }); + + it('names the roots it scanned on every finding and in the hint', () => { + const [first] = validateFieldConsumers(corpus()); + expect(first.rootsScanned).toEqual([...CONSUMER_ROOTS, ...CARRIER_ROOTS]); + expect(first.hint).toContain('test fixtures are never scanned'); + for (const root of ['views', 'pages', 'flows', 'translations', 'data', 'mappings']) { + expect(first.hint).toContain(root); + } + }); + + it('carries the positional path on the array-shaped field map', () => { + const findings = validateFieldConsumers({ + objects: [{ name: 'o', fields: [{ name: 'name', type: 'text' }, { name: 'orphan', type: 'text' }] }], + views: [{ list: { data: { object: 'o' }, columns: [{ field: 'name' }] } }], + }); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields[1]']); + }); + + it('negative control: a stack whose every field is consumed yields no finding', () => { + const findings = validateFieldConsumers({ + objects: [{ name: 'o', fields: { name: { type: 'text' }, a: { type: 'text' }, b: { type: 'number' } } }], + views: [{ list: { data: { object: 'o' }, columns: [{ field: 'a' }], filter: { b: 1 } } }], + }); + expect(findings).toEqual([]); + }); + + describe('skip gate — consumers declared elsewhere', () => { + it('does not judge a stack with no consumer root', () => { + expect(validateFieldConsumers({ objects: [{ name: 'o', fields: { x: { type: 'text' } } }] })).toEqual([]); + expect( + validateFieldConsumers({ + objects: [{ name: 'o', fields: { x: { type: 'text' } } }], + translations: [{ en: { objects: { o: { fields: { x: { label: 'X' } } } } } }], + data: [{ object: 'o', records: [{ x: 1 }] }], + }), + ).toEqual([]); + }); + + it('does judge once any consumer root is present, even an unrelated one', () => { + const findings = validateFieldConsumers({ + objects: [{ name: 'o', fields: { name: { type: 'text' }, x: { type: 'text' } } }], + apps: [{ name: 'app', navigation: [] }], + }); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.x']); + }); + + it('is silent on an empty or non-record input', () => { + expect(validateFieldConsumers({})).toEqual([]); + expect(validateFieldConsumers(null as unknown as AnyRec)).toEqual([]); + expect(validateFieldConsumers({ objects: [null, { name: 'o' }], views: [{}] })).toEqual([]); + }); + }); + + describe('exemptions, each derived from the spec', () => { + const withView = (fields: AnyRec, extra: AnyRec = {}): AnyRec => ({ + objects: [{ name: 'o', fields, ...extra }], + views: [{ list: { data: { object: 'o' }, columns: [] } }], + }); + + it('the derived title field (ADR-0079 ladder) and an explicit nameField', () => { + expect(validateFieldConsumers(withView({ title: { type: 'text' } }))).toEqual([]); + expect(validateFieldConsumers(withView({ code: { type: 'text' } }, { nameField: 'code' }))).toEqual([]); + // A non-title field on the same object is still judged. + expect(validateFieldConsumers(withView({ title: { type: 'text' }, x: { type: 'text' } })).map((f) => f.field)).toEqual(['x']); + }); + + it('a re-declared registry-injected system column, per object', () => { + expect(validateFieldConsumers(withView({ name: { type: 'text' }, created_at: { type: 'datetime' } }))).toEqual([]); + // `ownership: 'none'` injects no owner_id, so a declared one is an ordinary field. + expect( + validateFieldConsumers(withView({ name: { type: 'text' }, owner_id: { type: 'lookup' } }, { ownership: 'none' })).map((f) => f.field), + ).toEqual(['owner_id']); + }); + + it('a master_detail relationship (ADR-0035 readers), never a plain lookup', () => { + expect(validateFieldConsumers(withView({ name: { type: 'text' }, parent: { type: 'master_detail', reference: 'p' } }))).toEqual([]); + expect( + validateFieldConsumers(withView({ name: { type: 'text' }, parent: { type: 'lookup', reference: 'p' } })).map((f) => f.field), + ).toEqual(['parent']); + }); + }); + + describe('what credits a consumer, per root', () => { + const one = (extra: AnyRec, field = 'x', type = 'text'): string[] => + validateFieldConsumers({ + objects: [{ name: 'o', fields: { name: { type: 'text' }, [field]: { type } } }, { name: 'other', fields: { name: { type: 'text' }, [field]: { type } } }], + views: [{ list: { data: { object: 'other' }, columns: [{ field }] } }], + ...extra, + }).map((f) => `${f.object}.${f.field}`); + + it('a form section field on the view bound to the object', () => { + expect(one({ views: [{ object: 'o', form: { data: { object: 'o' }, sections: [{ fields: ['x'] }] } }, { list: { data: { object: 'other' }, columns: [{ field: 'x' }] } }] })).toEqual([]); + }); + + it('a page component binding through the page object', () => { + expect(one({ pages: [{ name: 'p', object: 'o', regions: [{ components: [{ type: 'record:highlights', properties: { fields: ['x'] } }] }] }] })).toEqual([]); + }); + + it('a flow template token resolved through the trigger object', () => { + expect(one({ flows: [{ name: 'f', nodes: [{ id: 's', type: 'start', config: { objectName: 'o' } }, { id: 'n', type: 'notify', config: { message: 'value {record.x}' } }] }] })).toEqual([]); + // The same token under a flow bound to the OTHER object credits nothing here. + expect(one({ flows: [{ name: 'f', nodes: [{ id: 's', type: 'start', config: { objectName: 'other' } }, { id: 'n', type: 'notify', config: { message: '{record.x}' } }] }] })).toEqual(['o.x']); + }); + + it('a validation predicate and a formula inside the object itself', () => { + expect( + one({ + objects: [{ name: 'o', fields: { name: { type: 'text' }, x: { type: 'number' }, y: { type: 'formula', expression: 'record.x * 2' } } }, { name: 'other', fields: { name: { type: 'text' } } }], + views: [{ list: { data: { object: 'o' }, columns: [{ field: 'y' }] } }], + }), + ).toEqual([]); + expect(one({ objects: [{ name: 'o', fields: { name: { type: 'text' }, x: { type: 'number' } }, validations: [{ name: 'v', condition: 'record.x > 0' }] }, { name: 'other', fields: { name: { type: 'text' } } }] })).toEqual([]); + }); + + it('a bare identifier inside an expression is a read; inside prose it is not', () => { + // The showcase shape: a flow trigger condition naming the field with no `record.` prefix. + expect(one({ flows: [{ name: 'f', nodes: [{ id: 's', type: 'start', config: { objectName: 'o', condition: 'x >= 5000' } }] }] })).toEqual([]); + expect(one({ flows: [{ name: 'f', nodes: [{ id: 's', type: 'start', config: { objectName: 'o' }, description: 'fires when x is large' }] }] })).toEqual(['o.x']); + }); + + it('a roll-up reads the CHILD object field it aggregates', () => { + const findings = validateFieldConsumers({ + objects: [ + { name: 'parent', fields: { name: { type: 'text' }, total: { type: 'summary', summaryOperations: { object: 'child', field: 'amount', function: 'sum' } } } }, + { name: 'child', fields: { name: { type: 'text' }, amount: { type: 'currency' } } }, + ], + views: [{ list: { data: { object: 'parent' }, columns: [{ field: 'total' }] } }], + }); + expect(findings).toEqual([]); + }); + + it('a hook body scanned as text, credited to the object the hook declares', () => { + expect(one({ hooks: [{ name: 'h', object: 'o', events: ['beforeInsert'], body: "if (ctx.input.x) { ctx.input.x = 'v'; }", language: 'js' }] })).toEqual([]); + expect(one({ hooks: [{ name: 'h', object: 'other', events: ['beforeInsert'], body: 'ctx.input.x' }] })).toEqual(['o.x']); + }); + + it('a text blob that names the object before the token credits that object', () => { + expect(one({ actions: [{ name: 'a', object: 'other', body: "ctx.api.object('o').update({ x: 1 })" }] })).toEqual([]); + }); + + it('a dataset dimension, and a widget filter resolved through the dataset', () => { + expect(one({ datasets: [{ name: 'd', object: 'o', dimensions: [{ name: 'dim', field: 'x' }] }] })).toEqual([]); + expect(one({ datasets: [{ name: 'd', object: 'o' }], dashboards: [{ name: 'b', widgets: [{ id: 'w', dataset: 'd', filter: { x: 'v' } }] }] })).toEqual([]); + }); + + it('a carrier never rescues: translation, seed, mapping, permission grant, flow write', () => { + expect(one({ translations: [{ en: { objects: { o: { fields: { x: { label: 'X' } } } } } }] })).toEqual(['o.x']); + expect(one({ data: [{ object: 'o', records: [{ x: 1 }] }] })).toEqual(['o.x']); + expect(one({ mappings: [{ name: 'm', targetObject: 'o', fieldMapping: [{ source: 'X', target: 'x' }] }] })).toEqual(['o.x']); + expect(one({ permissions: [{ name: 'p', objects: { o: { fields: { x: 'read' } } } }] })).toEqual(['o.x']); + expect(one({ flows: [{ name: 'f', nodes: [{ id: 'u', type: 'update_record', config: { objectName: 'o', fields: { x: '1' } } }] }] })).toEqual(['o.x']); + }); + + it('prose naming the field is a carrier, and a vocabulary literal is nothing', () => { + // `x` inside a description is not a read … + expect(one({ apps: [{ name: 'app', description: 'shows x to everyone', navigation: [{ type: 'object', objectName: 'o' }] }] })).toEqual(['o.x']); + // … and `type: 'summary'` never references a field named `summary`. + expect(one({ apps: [{ name: 'app', navigation: [{ type: 'object', objectName: 'o' }] }] }, 'summary')).toEqual(['o.summary']); + }); + }); + + describe('registry wiring', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateFieldConsumers'); + + it('is registered advisory, on all three commands, CLI-only with the full-snapshot reason', () => { + expect(entry).toBeDefined(); + expect(entry!.tier).toBe('advisory'); + expect(entry!.commands).toEqual(AUTHORING_COMMANDS); + expect(entry!.surfaces).toEqual(['cli']); + expect(entry!.surfaceReason).toContain('per-write snapshot'); + }); + + it('reaches every command through runAuthoringRules', () => { + for (const command of AUTHORING_COMMANDS) { + const found = runAuthoringRules(command, { normalized: corpus() }).filter((f) => f.rule === FIELD_NO_CONSUMERS); + expect(found.map((f) => f.path), command).toEqual([ + 'objects[0].fields.tax_rate', + 'objects[0].fields.is_taxable', + 'objects[0].fields.weight', + 'objects[0].fields.color', + ]); + } + }); + }); +}); diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts new file mode 100644 index 0000000000..3c6d6ca604 --- /dev/null +++ b/packages/lint/src/validate-field-consumers.ts @@ -0,0 +1,624 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15922] A declared field with ZERO consumers across the registered + * metadata roots — the field-level remainder of the #4698 "declared but never + * read" class, landed in the platform under the hotcrm#1543 ruling (F). + * + * ## The gap + * + * An authored field that nothing in the app consumes — no view column, no form + * section, no page block, no flow node, no dataset dimension, no formula, no + * validation, no hook or action — is schema-valid and passes `os validate`, + * `os lint`, `tsc` and the app's test suite. The declaration is inert and + * nothing in the toolchain says so. HotCRM carried its own scanner to answer + * exactly this question; its ledger read fields that had passed every platform + * check. That scanner is retired (lint belongs to the platform, uniformly), and + * this rule is where the capability lives instead. + * + * ## Object-aware, by construction + * + * The same field name on two objects gets two verdicts. HotCRM measured why: a + * name-only grep read `crm_product.tax_rate` as consumed because + * `crm_quote_line_item.tax_rate` — a different object's field, read by its own + * formula — spells the same token, so the product's rate reached no sweep. A + * reference is therefore credited to the object whose declaration ENCLOSES it + * (`object` / `objectName` / `targetObject` / `data.object` / `config.objectName` + * / `list.data.object` / a `dataset` resolved through the dataset's object / a + * map keyed by object name / a flow's trigger object), and only when that + * object actually declares the token. Inside a text blob (a hook handler, an + * action body, a CEL source) the nearest preceding mention of a declared object + * is a second candidate — a handler that loads one object and reads its own + * genuinely reads both, and under-crediting is the noisy direction. + * + * ## Consumption is not one thing — what counts, what does not + * + * Every site is bucketed, and the verdict reads off the buckets: + * + * - **behaviour** — the field makes something happen: a formula or roll-up, + * a validation predicate, a view FILTER / sort / grouping, a flow node, a + * hook or action body, a dataset dimension or measure, a widget filter, a + * sharing-rule condition. + * - **display** — the field is drawn: a view column, a form section, a page + * binding, `highlightFields`, `searchableFields`, an index. + * - **carrier** — the field is merely carried along: a translation label, a + * seed value, an import-mapping column, a field-level permission grant, a + * flow's WRITE of the field, prose that names it. These are what a REMOVAL + * must clean up; none of them is evidence that anything reads the field. + * A seeded value nothing reads is precisely the shape being hunted. + * + * A field with at least one behaviour OR display site is consumed and gets no + * finding — a field that is only drawn is the ordinary state of most fields + * (`phone` on a contact), not a defect; HotCRM's ledger listed `display-only` + * rows only under `--all`. The finding carries the two remaining verdicts as + * data rather than as two rule ids: `carrier-only` (carriers exist, and the + * finding lists them so the author knows what a removal cleans) and `inert` + * (no site of any kind). One id, one fix sentence — an author acts the same + * way on both, and a split would invite reading `carrier-only` as fine. + * + * ## Advisory, deliberately — and the boundaries, stated + * + * A consumer can legitimately live outside this stack: an API client, a hook + * body shipped by another package, a Studio-authored view the config never + * carried. So a zero-consumer field is *suspicious*, never *wrong*, and the + * ceiling for a static check is a warning (the `validate-nav-access` posture). + * + * - **Roots scanned** are the ones {@link CONSUMER_ROOTS} and + * {@link CARRIER_ROOTS} name, on the stack handed to the rule. `test/` + * fixtures are NEVER scanned — the rule reads metadata, not a repository — + * and a field only a test reads is reported. That boundary is what made + * hotcrm#1543 a decision, so it is written here rather than left as lore. + * - **A stack that declares no consumer root at all** (objects only, or + * objects plus carriers) is skipped entirely: its consumers are declared + * elsewhere (a multi-package app's object library), and flagging every + * field there says nothing useful — the "empty collection ⇒ don't judge" + * gate `validate-nav-access` applies to permissions. + * - **Exempt** are fields the platform itself reads without any authored + * consumer, each derived from the spec rather than listed by hand: the + * registry-injected system columns an author re-declared + * ({@link injectedColumnsFor} — `resolveInjectedSystemColumns` in + * `@objectstack/spec/data`), the record's title field + * ({@link resolveDisplayField} — ADR-0079's `nameField` ladder, read for + * every record's display name), and a `master_detail` field (ADR-0035 — + * `packages/objectql/src/master-detail.ts` names cascade delete, + * `controlled_by_parent` sharing, roll-ups and inline grids as its + * readers; the relationship is consumed by being declared). + * - **Object extensions** (`objectExtensions`) are not judged: the fields + * they add belong to objects this stack does not own. + * + * Severity is `warning` and stays so: a refusal would narrow the authorable + * surface (today-valid metadata would start being refused), which is the + * maintainer's call, not this rule's. + */ + +import { resolveDisplayField } from '@objectstack/spec/data'; +import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; +import { collectionEntries } from './collection-entries.js'; +import { recordsOf } from './object-graph.js'; +import { injectedColumnsFor } from './system-fields.js'; + +export const FIELD_NO_CONSUMERS = 'field-no-consumers'; + +export type FieldConsumerSeverity = 'warning'; + +/** Why the field is reported: carriers only, or nothing at all. */ +export type FieldConsumerVerdict = 'inert' | 'carrier-only'; + +export interface FieldConsumerFinding { + /** Always `warning` — a consumer may live outside the stack (see module note). */ + severity: FieldConsumerSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `object "crm_product" · field "tax_rate"`. */ + where: string; + /** Config path of the DECLARATION, e.g. `objects[3].fields.tax_rate` (map shape) or `objects[3].fields[2]` (array shape). */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; + /** The declaring object. */ + object: string; + /** The field name. */ + field: string; + /** `carrier-only` when carrier sites exist, `inert` when no site of any kind names the field. */ + verdict: FieldConsumerVerdict; + /** Config paths of the carrier sites a removal must clean (empty for `inert`). */ + carriers: string[]; + /** The stack roots this verdict was measured over — consumer roots then carrier roots. */ + rootsScanned: readonly string[]; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * Roots whose contents can CONSUME a field, walked with an object context. + * `objects` is here for what an object carries besides its field map — + * formulas, roll-ups, validations, built-in list views, hooks, actions, + * indexes, `highlightFields`, `searchableFields`. Order is report order. + */ +export const CONSUMER_ROOTS: readonly string[] = [ + 'objects', + 'views', + 'pages', + 'apps', + 'flows', + 'dashboards', + 'reports', + 'datasets', + 'actions', + 'hooks', + 'jobs', + 'emailTemplates', + 'agents', + 'tools', + 'skills', + 'apis', + 'webhooks', + 'sharingRules', + 'analyticsCubes', +]; + +/** + * Roots whose contents carry a field without reading it. A locale row is a + * label for a field, not a consumer of one; a seed VALUE nothing reads is the + * shape being hunted; an import column and a field-level permission grant are + * customer-facing surfaces a removal must clean, not evidence of a reader. + */ +export const CARRIER_ROOTS: readonly string[] = ['translations', 'data', 'mappings', 'permissions']; + +/** Roots whose sites are display by default; `BEHAVIOUR_SEGMENTS` earn behaviour back. */ +const DISPLAY_ROOTS: ReadonlySet = new Set(['views', 'pages', 'apps']); + +/** + * Leaf keys whose value is prose for a human, not a reference. A field name + * inside a sentence is not a read; it is recorded as a carrier so the finding + * can list the sentence a removal has to rewrite. + */ +const PROSE_KEYS: ReadonlySet = new Set([ + 'label', 'pluralLabel', 'description', 'message', 'successMessage', 'errorMessage', + 'title', 'placeholder', 'helpText', 'emptyText', 'tooltip', 'subtitle', +]); + +/** Inside a display root (and everywhere else), these path segments make a site behavioural. */ +const BEHAVIOUR_SEGMENTS: ReadonlySet = new Set([ + 'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'where', 'criteria', 'conditions', + 'condition', 'defaultFilter', 'userFilters', 'quickFilters', 'filterableFields', + 'sort', 'sortBy', 'defaultSort', 'grouping', 'groupBy', 'groupByField', 'groupField', + 'startField', 'endField', 'dateField', 'startDateField', 'endDateField', 'coverField', + 'titleField', 'colorField', 'latitudeField', 'longitudeField', 'locationField', + 'addressField', 'parentField', 'statusField', 'kanban', 'calendar', 'gantt', 'timeline', + 'map', 'tree', 'rowColor', 'rowTint', 'conditionalFormatting', + 'expression', 'formula', 'visibleWhen', 'readonlyWhen', 'requiredWhen', 'validations', + 'rules', 'summaryOperations', 'dimensions', 'measures', 'handler', 'body', 'script', + 'nameField', 'displayNameField', 'externalId', 'upsertKey', +]); + +/** Path segments that make a site presentational when the root is not already a display root. */ +const DISPLAY_SEGMENTS: ReadonlySet = new Set([ + 'highlightFields', 'searchableFields', 'indexes', 'columns', 'sections', 'groups', + 'hideFields', 'hiddenFields', 'fieldOrder', 'visibleFields', 'labelField', 'displayField', + 'descriptionField', 'tooltipFields', 'fieldGroups', 'recordTypes', 'listViews', +]); + +/** Keys whose object VALUE is a predicate map — `{ is_active: true }` spells the field as a KEY. */ +const PREDICATE_KEYS: ReadonlySet = new Set([ + 'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'where', 'criteria', + 'defaultFilter', 'conditions', +]); + +/** + * Keys whose object VALUE spells fields as keys it WRITES or CARRIES — a flow's + * `fields: { added_date: '{NOW()}' }`, a seed row, a permission set's + * field-level grants. Recorded as carriers, never as reads: a value that + * automation stamps and nothing ever reads is exactly the inert shape. + */ +const WRITE_KEYS: ReadonlySet = new Set([ + 'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records', +]); + +/** + * Keys whose value is a literal from some other vocabulary, never a field + * name. Without this list `type: 'summary'` on a roll-up reads as a reference + * to a field named `summary`, and `accept: ['image/png']` as one to `image`. + * `source` is deliberately ABSENT: it is the text of a CEL envelope + * (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and + * skipping it read every tagged-template formula as reading nothing. + */ +const LITERAL_KEYS: ReadonlySet = new Set([ + 'type', 'reference', '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', 'kind', 'template', 'status', 'runAs', 'sharingModel', + 'objectName', 'object', 'targetObject', 'dataset', 'outputVariable', 'triggerType', + 'event', 'currency', 'color', 'size', 'layout', 'function', 'direction', 'model', 'role', + 'method', 'path', 'url', 'key', 'locale', 'namespace', 'engine', 'driver', +]); + +/** + * The shapes a field name takes when it is actually being REFERENCED inside a + * text blob: `record.x` / `input.x`, a quoted `'x'`, a `{x}` template token, an + * object-literal key `x:`. A bare word inside a sentence is none of these. + */ +const REFERENCE_SHAPES: readonly RegExp[] = [ + /\.([A-Za-z_][A-Za-z0-9_]*)\b/g, + /['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]/g, + /\{([A-Za-z_][A-Za-z0-9_]*)\}/g, + /\b([A-Za-z_][A-Za-z0-9_]*)\s*:/g, +]; + +/** + * Keys whose string value is an EXPRESSION — a CEL envelope's `source`, a + * trigger `condition`, a formula — where a bare identifier IS a read + * (`total_amount >= 5000` in a flow trigger names the field with no `record.` + * prefix). Under these keys every identifier is a candidate; everywhere else a + * bare word is prose and only the reference shapes above count. + */ +const EXPRESSION_KEYS: ReadonlySet = new Set([ + 'source', 'expression', 'formula', 'condition', 'criteria', 'when', 'visibleWhen', + 'readonlyWhen', 'requiredWhen', 'where', 'predicate', 'script', 'body', 'handler', 'code', +]); + +const IDENTIFIER_SHAPE = /\b([A-Za-z_][A-Za-z0-9_]*)\b/g; + +/** Text blobs above this size are not scanned (a bundled source, not metadata). */ +const MAX_TEXT_LENGTH = 200_000; + +type SiteKind = 'behaviour' | 'display' | 'carrier'; + +interface Site { + root: string; + path: string; + kind: SiteKind; +} + +/** One declared field, with everything the report needs. */ +interface Declared { + object: string; + field: string; + /** Config path of the declaration. */ + path: string; + exempt: boolean; +} + +/** The walk's shared state — built per stack, consulted by every site. */ +class ConsumerLedger { + /** object → declared field names */ + readonly fieldsByObject = new Map>(); + /** field name → objects declaring it */ + readonly objectsByField = new Map>(); + /** dataset name → the object it reads */ + readonly datasetObject = new Map(); + /** `object.field` → sites */ + readonly sites = new Map(); + /** Tokens that looked like a field but resolved to no object — counted, never dropped. */ + unresolved = 0; + private mentionRe: RegExp | undefined; + + declare(object: string, field: string): void { + let fields = this.fieldsByObject.get(object); + if (!fields) this.fieldsByObject.set(object, (fields = new Set())); + fields.add(field); + let owners = this.objectsByField.get(field); + if (!owners) this.objectsByField.set(field, (owners = new Set())); + owners.add(object); + } + + declares(object: string | undefined, field: string): object is string { + return object !== undefined && (this.fieldsByObject.get(object)?.has(field) ?? false); + } + + isObject(v: unknown): v is string { + return typeof v === 'string' && this.fieldsByObject.has(v); + } + + record(object: string, field: string, site: Site): void { + const key = `${object}.${field}`; + const list = this.sites.get(key); + if (list) list.push(site); + else this.sites.set(key, [site]); + } + + /** Every mention of a declared object in a blob, with the index the mention ENDS at. */ + mentionsIn(text: string): { end: number; object: string }[] { + if (!this.mentionRe) { + const names = [...this.fieldsByObject.keys()] + .sort((a, b) => b.length - a.length) + .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + this.mentionRe = names.length > 0 ? new RegExp(`\\b(?:${names.join('|')})\\b`, 'g') : /(?!)/g; + } + const out: { end: number; object: string }[] = []; + for (const m of text.matchAll(this.mentionRe)) { + out.push({ end: (m.index ?? 0) + m[0].length, object: m[0] }); + } + return out; + } +} + +function bucketFor(root: string, segments: readonly string[], leafKey: string): SiteKind { + if (CARRIER_ROOTS.includes(root)) return 'carrier'; + if (PROSE_KEYS.has(leafKey)) return 'carrier'; + if (segments.some((s) => BEHAVIOUR_SEGMENTS.has(s))) return 'behaviour'; + if (DISPLAY_ROOTS.has(root)) return 'display'; + if (segments.some((s) => DISPLAY_SEGMENTS.has(s))) return 'display'; + return 'behaviour'; +} + +/** + * Scan one text blob for field references. The nearest preceding mention of a + * declared object and the enclosing declaration's object are both candidates; + * a token is credited to each candidate that DECLARES it. + */ +function scanText( + ledger: ConsumerLedger, + text: string, + ctx: string | undefined, + root: string, + path: string, + segments: readonly string[], + leafKey: string, +): void { + if (text.length === 0 || text.length > MAX_TEXT_LENGTH) return; + if (LITERAL_KEYS.has(leafKey)) return; + const hits: { token: string; at: number }[] = []; + const trimmed = text.trim(); + if (ledger.objectsByField.has(trimmed)) hits.push({ token: trimmed, at: 0 }); + const shapes = EXPRESSION_KEYS.has(leafKey) ? [...REFERENCE_SHAPES, IDENTIFIER_SHAPE] : REFERENCE_SHAPES; + for (const shape of shapes) { + for (const m of text.matchAll(shape)) { + if (ledger.objectsByField.has(m[1])) { + hits.push({ token: m[1], at: (m.index ?? 0) + m[0].indexOf(m[1]) }); + } + } + } + if (hits.length === 0) return; + const mentions = ledger.mentionsIn(text); + // Prose carries a bare word; an interpolation token inside it (`{record.x}` + // in a notify message) is read at run time and is a consumer like any other. + const templateRanges = [...text.matchAll(/\{[^{}]*\}/g)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]); + const prose = PROSE_KEYS.has(leafKey); + for (const { token, at } of hits) { + const templated = templateRanges.some(([from, to]) => at >= from && at < to); + const kind: SiteKind = prose && !templated ? 'carrier' : bucketFor(root, segments, templated ? '' : leafKey); + let nearest: string | undefined; + for (const mention of mentions) { + if (mention.end <= at) nearest = mention.object; + else break; + } + const candidates = nearest !== undefined && nearest !== ctx ? [nearest, ctx] : [ctx]; + let credited = false; + for (const candidate of candidates) { + if (ledger.declares(candidate, token)) { + ledger.record(candidate, token, { root, path, kind }); + credited = true; + } + } + if (!credited) ledger.unresolved += 1; + } +} + +/** The object context a record establishes for its own subtree, if any. */ +function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined { + const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined); + const nested = (v: unknown, key: string): string | undefined => (isRec(v) ? named(v[key]) : undefined); + const list = isRec(rec.list) ? rec.list : undefined; + const dataset = typeof rec.dataset === 'string' ? ledger.datasetObject.get(rec.dataset) : undefined; + return ( + named(rec.object) ?? + named(rec.objectName) ?? + named(rec.targetObject) ?? + nested(rec.data, 'object') ?? + nested(rec.config, 'objectName') ?? + nested(rec.config, 'object') ?? + // A `views[]` container names its object only inside `list.data` — without + // this hoist the FORM section's fields would resolve to nothing. + (list ? nested(list.data, 'object') : undefined) ?? + named(rec.name) ?? + dataset ?? + // A flow names its object on the TRIGGER node, and its later nodes read + // `{record.x}` with no object of their own. Per-node `objectName` still + // wins inside its own subtree. + (Array.isArray(rec.nodes) + ? (rec.nodes as unknown[]) + .map((n) => (isRec(n) ? (nested(n.config, 'objectName') ?? nested(n.config, 'object')) : undefined)) + .find((o) => o !== undefined) + : undefined) ?? + ctx + ); +} + +/** Walk any value under a root, carrying the object context down the tree. */ +function walk( + ledger: ConsumerLedger, + node: unknown, + ctx: string | undefined, + root: string, + path: string, + segments: readonly string[], + leafKey: string, +): void { + if (node === null || node === undefined) return; + if (typeof node === 'function') { + scanText(ledger, Function.prototype.toString.call(node), ctx, root, path, segments, leafKey); + return; + } + if (typeof node === 'string') { + scanText(ledger, node, ctx, root, path, segments, leafKey); + return; + } + if (typeof node !== 'object') return; + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) walk(ledger, node[i], ctx, root, `${path}[${i}]`, segments, leafKey); + return; + } + const rec = node as AnyRec; + const inner = contextOf(ledger, rec, ctx); + for (const [key, value] of Object.entries(rec)) { + const childPath = `${path}.${key}`; + const childSegments = [...segments, key]; + // A predicate map spells the field as its KEY (`{ is_active: true }`); a + // write/carrier map does too (`fields: { added_date: … }`). Nowhere else is + // a key a reference — `type`, `name` and `label` are ubiquitous schema + // keys AND plausible field names. + if (ledger.objectsByField.has(key) && (PREDICATE_KEYS.has(leafKey) || WRITE_KEYS.has(leafKey))) { + if (ledger.declares(inner, key)) { + const kind: SiteKind = WRITE_KEYS.has(leafKey) ? 'carrier' : bucketFor(root, childSegments, leafKey); + ledger.record(inner, key, { root, path: childPath, kind }); + } else { + ledger.unresolved += 1; + } + } + // A map KEYED by object name — `translations[].en.objects.crm_x`, + // `permissions[].objects.crm_x` — names its object in a position no + // `object:` lookup reaches. + walk(ledger, value, ledger.isObject(key) ? key : inner, root, childPath, childSegments, key); + } +} + +/** The keys of a FIELD declaration that describe the field itself, never a reference to another. */ +const FIELD_SELF_KEYS: ReadonlySet = new Set(['name', 'label', 'type', 'reference']); + +/** + * Walk one object's declaration with the object as context: everything it + * carries besides its field map, then each field's own body (a formula reads + * OTHER fields; a roll-up reads the CHILD object's; a lookup's `displayField` + * names a field on the REFERENCED object). + */ +function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, objPath: string, fieldsPath: string): void { + for (const [key, value] of Object.entries(obj)) { + if (key === 'fields' || key === 'name') continue; + walk(ledger, value, objectName, 'objects', `${objPath}.${key}`, [key], key); + } + for (const { rec: field, path: fieldPath } of collectionEntries(obj.fields, fieldsPath)) { + const reference = strName(field.reference); + const displayField = strName(field.displayField); + if (reference && displayField && ledger.declares(reference, displayField)) { + ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' }); + } + for (const [key, value] of Object.entries(field)) { + if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; + walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key); + } + } +} + +/** Build the display-name meta the spec's ladder reads, whatever shape `fields` was authored in. */ +function displayMetaOf(obj: AnyRec, fields: { rec: AnyRec; path: string }[]): DisplayNameObjectMeta { + const map: Record = {}; + for (const { rec } of fields) { + const n = strName(rec.name); + if (n) map[n] = rec; + } + return { nameField: strName(obj.nameField), displayNameField: strName(obj.displayNameField), fields: map }; +} + +function listPaths(paths: readonly string[]): string { + return paths.join(', '); +} + +/** + * Report every declared field that nothing in the stack reads or displays. + * Returns findings (empty = clean). Pure; safe on pre- or post-parse stacks. + */ +export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { + const findings: FieldConsumerFinding[] = []; + if (!isRec(stack)) return findings; + + // Consumers declared elsewhere ⇒ nothing to judge here. + const hasConsumerRoot = CONSUMER_ROOTS.some((root) => root !== 'objects' && recordsOf(stack[root]).length > 0); + if (!hasConsumerRoot) return findings; + + const ledger = new ConsumerLedger(); + const declared: Declared[] = []; + + const objectEntries = collectionEntries(stack.objects, 'objects'); + for (const { rec: obj, path: objPath } of objectEntries) { + const objectName = strName(obj.name); + if (!objectName || !obj.fields || typeof obj.fields !== 'object') continue; + const fields = collectionEntries(obj.fields, `${objPath}.fields`); + const injected = injectedColumnsFor(obj); + const titleField = resolveDisplayField(displayMetaOf(obj, fields)); + for (const { rec: field, path: fieldPath } of fields) { + const fieldName = strName(field.name); + if (!fieldName) continue; + ledger.declare(objectName, fieldName); + const exempt = injected.has(fieldName) || fieldName === titleField || field.type === 'master_detail'; + declared.push({ object: objectName, field: fieldName, path: fieldPath, exempt }); + } + } + if (declared.length === 0) return findings; + + for (const ds of recordsOf(stack.datasets)) { + const name = strName(ds.name); + const object = strName(ds.object); + if (name && object) ledger.datasetObject.set(name, object); + } + + for (const { rec: obj, path: objPath } of objectEntries) { + const objectName = strName(obj.name); + if (!objectName || !ledger.fieldsByObject.has(objectName)) continue; + walkObject(ledger, obj, objectName, objPath, `${objPath}.fields`); + } + for (const root of [...CONSUMER_ROOTS, ...CARRIER_ROOTS]) { + if (root === 'objects') continue; + walk(ledger, stack[root], undefined, root, root, [], root); + } + + const rootsScanned: readonly string[] = [...CONSUMER_ROOTS, ...CARRIER_ROOTS]; + + for (const { object, field, path, exempt } of declared) { + if (exempt) continue; + const sites = ledger.sites.get(`${object}.${field}`) ?? []; + if (sites.some((s) => s.kind !== 'carrier')) continue; + + const carriers = sites.map((s) => s.path); + const verdict: FieldConsumerVerdict = carriers.length > 0 ? 'carrier-only' : 'inert'; + const sharedWith = [...(ledger.objectsByField.get(field) ?? [])].filter((o) => o !== object); + + const verdictClause = + verdict === 'carrier-only' + ? `Verdict: carrier-only — ${carriers.length} carrier site(s) name it without reading it, and a removal ` + + `must clean each: ${listPaths(carriers)}.` + : `Verdict: inert — no site of any kind names it.`; + const sharedClause = + sharedWith.length > 0 + ? ` The same name is declared on ${sharedWith.map((o) => `"${o}"`).join(', ')}; verdicts are per ` + + `object, so a consumer there does not cover this declaration.` + : ''; + + findings.push({ + severity: 'warning', + rule: FIELD_NO_CONSUMERS, + where: `object "${object}" · field "${field}"`, + path, + message: + `field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` + + `it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` + + `hook or action names it. A translation label, a seed value, an import mapping, a permission grant ` + + `or a flow that only WRITES it is a carrier, not a consumer. ${verdictClause}${sharedClause}`, + hint: + `Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` + + `validation, a flow node, a dataset dimension — or remove the declaration` + + (carriers.length > 0 ? ` together with its ${carriers.length} carrier site(s) listed above` : '') + + `. Ignore this if the field is read only by an API client, by a hook or package this stack does not ` + + `carry, or by a Studio-authored view. Roots scanned: ${CONSUMER_ROOTS.join(', ')} (consumers) · ` + + `${CARRIER_ROOTS.join(', ')} (carriers); test fixtures are never scanned.`, + object, + field, + verdict, + carriers, + rootsScanned, + }); + } + + return findings; +} From ad909f3709ea7721cd8fca35c4ee82fec04e7c0a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 6 Sep 2026 14:14:07 +0000 Subject: [PATCH 2/2] docs: the CLI transcripts print 43 author-time rules now that field-no-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 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f30eb087ed..45bc3dbab9 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -477,7 +477,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (42)... + → Running author-time rules (43)... → Checking capability providers (#3366)... → Collecting package docs (ADR-0046)... → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 1201a2db4c..4fbf080795 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -555,7 +555,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (42)... + → Running author-time rules (43)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 2545a6119c..12f27dbf07 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -263,7 +263,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (42)... + → Running author-time rules (43)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 42e6d7ace0..62f2f99017 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -380,7 +380,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (42)... + → Running author-time rules (43)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)...