From fd7f2b9d695b055929f124caed9865d0e6c73cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:42:09 +0000 Subject: [PATCH 1/2] chore(test): delete the field-consumer scanner and its guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletion leg only — the rosters that name these files are reconciled in the next commit, so this commit is the ablation that proves they are enforced. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- package.json | 1 - scripts/scan-field-consumers.ts | 573 ------------------------------- test/field-consumer-scan.test.ts | 318 ----------------- 3 files changed, 892 deletions(-) delete mode 100644 scripts/scan-field-consumers.ts delete mode 100644 test/field-consumer-scan.test.ts diff --git a/package.json b/package.json index 589bf952..523039ed 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "test:e2e": "playwright test", "hygiene": "node scripts/check-source-hygiene.mjs", "hygiene:tokens": "node scripts/check-source-token-ratchet.mjs", - "scan:fields": "tsx scripts/scan-field-consumers.ts", "reconcile:analytics": "tsx scripts/analytics-reconcile/run.ts", "verify": "pnpm validate && pnpm typecheck && pnpm lint && pnpm lint:i18n-gate && pnpm hygiene && pnpm hygiene:tokens && pnpm build && pnpm test", "changeset": "changeset", diff --git a/scripts/scan-field-consumers.ts b/scripts/scan-field-consumers.ts deleted file mode 100644 index 83cc27cc..00000000 --- a/scripts/scan-field-consumers.ts +++ /dev/null @@ -1,573 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Object-aware field consumer ledger (#1193). - * - * pnpm scan:fields # the ledger a human reads - * pnpm scan:fields --all # every field, with its verdict - * pnpm scan:fields --sites . # where one field is read - * pnpm scan:fields --json # the same measurement, machine-readable - * - * ## Why this exists — the false negative that cannot show up in a grep - * - * #1182 adjudicated ten declared-but-inert fields. Its row set came from a scan - * that called a field consumed when its NAME appeared in a `src/**\/*.ts` file - * outside `views/` and `translations/`. That scan is line-oriented and - * **object-blind**, and one field paid for it: `crm_product.tax_rate` read as - * consumed because `crm_quote_line_item.tax_rate` — a different object's field, - * with its own formula reading its own rate — spells the same token. The - * product's rate reached no card. `is_taxable`, two declarations away in the - * same field group and inert for exactly the same reason, did reach it, because - * its name happens to be unique. - * - * The failure mode is what makes it worth a tool rather than a fix. A false - * NEGATIVE is invisible by construction: the scan's own output cannot show what - * it filtered out, so re-running it — however carefully — re-derives the same - * blind spot, and every field whose name is shared across objects keeps the same - * immunity. There is no amount of care with a grep that closes this. - * - * ## What "object-aware" means here - * - * A hit is resolved to **the object whose declaration it sits inside**, not to - * the file it appears in. The whole consumer surface of this app is the - * registered stack, so the scan walks `objectstack.config.ts` itself and carries - * an OBJECT CONTEXT down the tree: `object` / `objectName` / `targetObject` / - * `data.object` / `list.data.object`, and `dataset` resolved through the - * dataset's own object. A view column, a flow node config, a hook body and a - * validation predicate each answer "which object?" from the declaration that - * encloses them. - * - * Text blobs — hook handlers, action bodies, CEL sources, skill instructions — - * are scanned with the same rule applied WITHIN the blob: an object named in the - * text (`api.object('crm_product')`, a bare `crm_product`) sets the context for - * everything after it. Two candidates are credited for each token, the nearest - * preceding mention and the enclosing declaration's own object, and only when - * that object actually declares the token. Crediting both is a deliberate, - * stated approximation: a handler that reads its own object and one it loads - * genuinely reads both, and under-crediting the declaring object would invent - * inert fields, which is the noisy direction rather than the silent one. - * - * Any token that resolves to NO object is counted and reported (`unresolved`), - * never dropped. A scan that silently discards what it cannot place is how the - * previous one granted immunity. - * - * ## Consumption is not one thing — the three buckets - * - * #1182 removed fields that a view column displayed (`quantity_on_hand` had a - * whole list view), and kept one that a roll-up computed. So a boolean - * "consumed" would have argued against that card's own verdicts. Every site is - * therefore bucketed, and the verdict reads off the buckets: - * - * - **behaviour** — the field makes something happen: a formula or summary - * expression, a validation predicate, a view FILTER / sort / grouping (they - * decide which records you see), a flow node, a hook or action body, a - * dataset dimension or measure, a dashboard or report config, a sharing-rule - * condition, a skill's instructions. - * - **display** — the field is only drawn: a view column, a form or page - * field, `highlightFields`, `searchableFields`, an index. - * - **carrier** — the field is merely carried along: locale bundles, seed - * values, import-mapping columns, and prose (`label` / `description` / - * `message` …). These are what a REMOVAL must clean up; none of them is - * evidence that anything reads the field. - * - * `live` = at least one behaviour site. - * `display-only` = drawn somewhere, but nothing reads it. - * `inert` = neither. This is the ledger #1182 adjudicated row by row. - * - * ## Report-only, deliberately — this is not a `pnpm verify` gate - * - * Argued in the PR for #1193 and recorded here because the next reader will ask. - * The maintainer ruling of 2026-08-17 was 「逐个 enforce-or-remove(推荐)」 — - * per-field adjudication, not a blanket rule. A gate that failed on any inert - * field would encode the blanket rule the ruling declined to make, and would go - * red the moment someone lands a field one PR before its consumer, which is - * ordinary in a metadata app. What this card actually needed fixed was not - * inertness but INVISIBILITY, and a tool that prints a ledger fixes that. - * - * The capability is guarded instead of the count: `test/field-consumer-scan.test.ts` - * pins that the resolver stays object-aware — a field name shared across two - * objects must resolve to the object that declares the reader, not to both — - * and re-derives #1182's own verdicts from this scan. A gate on the number - * would have been cheaper and would have guarded the wrong thing. - */ - -import stack from '../objectstack.config'; -import { isMainModule } from './lib/main-module.mjs'; - -type AnyRec = Record; - -/** Where one field name was read, and what kind of surface read it. */ -export type Site = { - object: string; - field: string; - /** Top-level `objectstack.config.ts` key the site was found under. */ - root: string; - /** Dotted path from that key, so a ledger row can be checked by hand. */ - path: string; - bucket: Bucket; -}; - -export type Bucket = 'behaviour' | 'display' | 'carrier'; -export type Verdict = 'live' | 'display-only' | 'inert'; - -/** - * Stack roots whose contents carry a field without reading it. - * - * `translations` is the reason the previous scan excluded it too: a locale row - * is a label for a field, not a consumer of one. `data` seeds VALUES — every - * one of #1182's removed fields was seeded, `is_taxable` on all 13 catalog - * products — and a seeded value that nothing reads is precisely the shape being - * hunted. `mappings` offers an import column, which is a customer-facing - * surface a removal must clean, not evidence of a reader. - */ -const CARRIER_ROOTS = new Set(['translations', 'data', 'mappings']); - -/** - * Leaf keys whose value is prose for a human, not a reference. - * - * A field name occurring inside a sentence is not a read. Bucketed as `carrier` - * rather than dropped, so `--sites` still shows the sentence: prose that names a - * field is exactly what has to be rewritten when the field goes. - */ -const PROSE_KEYS = new Set([ - 'label', 'pluralLabel', 'description', 'message', 'successMessage', 'errorMessage', - 'title', 'placeholder', 'helpText', 'emptyText', 'tooltip', 'subtitle', -]); - -/** - * Path segments that make a site presentational. - * - * `views` and `pages` are display by default and earn `behaviour` back through - * BEHAVIOUR_SEGMENTS below — a column draws a field, a filter decides which rows - * exist at all, and #1182 turned on exactly that distinction. - */ -const DISPLAY_ROOTS = new Set(['views', 'pages', 'apps']); -const DISPLAY_SEGMENTS = ['highlightFields', 'searchableFields', 'indexes']; - -/** Inside a display root, these segments still change behaviour. */ -const BEHAVIOUR_SEGMENTS = [ - 'filter', 'filters', 'runtimeFilter', 'where', 'sort', 'grouping', 'groupBy', - 'groupField', 'startField', 'endField', 'dateField', 'coverField', 'titleField', - 'colorField', 'latitudeField', 'longitudeField', 'addressField', 'kanban', - 'calendar', 'gantt', 'timeline', 'map', 'rowTint', 'conditionalFormatting', -]; - -/** Keys whose object VALUE is a predicate map — `{ is_active: true }`. */ -const PREDICATE_KEYS = new Set([ - 'filter', 'filters', 'runtimeFilter', 'where', 'criteria', 'defaultFilter', 'conditions', -]); - -/** - * Keys whose object VALUE spells fields as keys it WRITES — a flow's - * `fields: { added_date: '{NOW()}' }`. - * - * Recorded as `carrier`, not as a read, and that is the whole point of naming - * them separately: a value that automation stamps and nothing ever reads is - * exactly the shape #1182 removed nine fields for (`is_taxable` was seeded on - * all 13 catalog products). Recording it keeps `--sites` honest — an adjudicator - * needs to see that a flow writes the field before deciding to delete it — while - * keeping the verdict on whether anything READS it. - */ -const WRITE_KEYS = new Set(['fields', 'values', 'set', 'record', 'data', 'input']); - -/** - * Keys whose value is a literal from some other vocabulary, never a field name. - * - * Without this list the scan reads `type: 'summary'` on a roll-up field as a - * reference to `crm_case.summary`, and `accept: ['image/png']` as a reference to - * `crm_account.image`. Both are accidents of two vocabularies sharing a word, - * which is the same class of mistake this whole script exists to stop making — - * one level down, on field TYPES instead of object names. Skipping them cut the - * unplaceable-token count from 4,374 to a set small enough to read. - */ -const LITERAL_KEYS = new Set([ - 'type', 'reference', 'reference_to', 'accept', 'provider', 'dialect', 'operator', - 'aggregate', 'mode', 'severity', 'language', 'surface', 'format', 'icon', 'variant', - 'colorVariant', 'align', 'order', 'defaultValue', 'value', 'sourceFormat', 'transform', - 'name', 'id', 'events', 'locations', 'version', 'width', 'cardSize', 'coverFit', - 'env', 'pinned', 'summary', 'chartType', 'dateGranularity', -]); - -/** - * The shapes a field name takes when it is actually being REFERENCED. - * - * A bare word inside a sentence is not a read; `record.tax_rate`, - * `fields: ['list_price']`, `{product_code}` and `input.unit_price` are. Applied - * to every blob alongside the whole-string rule below, so short declarative - * values (`field: 'annual_revenue'`) and code bodies are read by one rule set. - */ -const REFERENCE_SHAPES = [ - /\.([A-Za-z_][A-Za-z0-9_]*)\b/g, // record.x · input.x · l.x - /['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]/g, // 'x' · "x" · `x` - /\{([A-Za-z_][A-Za-z0-9_]*)\}/g, // {x} in a template string - /\b([A-Za-z_][A-Za-z0-9_]*)\s*:/g, // { x: … } object-literal key -]; - -const S = stack as unknown as AnyRec; -const OBJECTS = (S.objects ?? []) as AnyRec[]; - -/** Declared field names, per object. Read from the stack, never hand-listed. */ -export const fieldsByObject = new Map>( - OBJECTS.map((o) => [String(o.name), new Set(Object.keys((o.fields ?? {}) as AnyRec))]), -); - -/** Field name → every object declaring it. The masking analysis reads this. */ -export const objectsByField = ((): Map => { - const out = new Map(); - for (const [object, fields] of fieldsByObject) { - for (const field of fields) out.set(field, [...(out.get(field) ?? []), object]); - } - return out; -})(); - -/** Dataset name → the object it reads, so a report/widget inherits a context. */ -const datasetObject = new Map( - ((S.datasets ?? []) as AnyRec[]).map((d) => [String(d.name), String(d.object)]), -); - -/** Every `crm_*` token in a blob, with the index its mention ENDS at. */ -const mentionsIn = (text: string): { end: number; object: string }[] => { - const out: { end: number; object: string }[] = []; - for (const m of text.matchAll(/\bcrm_[a-z0-9_]+\b/g)) { - if (fieldsByObject.has(m[0])) out.push({ end: (m.index ?? 0) + m[0].length, object: m[0] }); - } - return out; -}; - -const sites: Site[] = []; -/** Tokens that looked like a field but resolved to no object. Never dropped. */ -export const unresolved: { token: string; root: string; path: string }[] = []; - -/** Every declared field name anywhere — the vocabulary the text scan matches. */ -const ALL_FIELD_NAMES = new Set(objectsByField.keys()); - -const bucketFor = (root: string, path: string, leafKey: string): Bucket => { - if (CARRIER_ROOTS.has(root)) return 'carrier'; - if (PROSE_KEYS.has(leafKey)) return 'carrier'; - const segments = path.split('.'); - if (BEHAVIOUR_SEGMENTS.some((s) => segments.includes(s))) return 'behaviour'; - if (DISPLAY_ROOTS.has(root)) return 'display'; - if (DISPLAY_SEGMENTS.some((s) => segments.includes(s))) return 'display'; - return 'behaviour'; -}; - -const record = (object: string, field: string, root: string, path: string, leafKey: string): void => { - sites.push({ object, field, root, path, bucket: bucketFor(root, path, leafKey) }); -}; - -/** - * Scan one text blob (a string value, or a stringified handler) for field names. - * - * The nearest preceding `crm_*` mention and the enclosing declaration's object - * are both candidates; a token is credited to each candidate that DECLARES it. - * See the header for why both rather than one. - */ -const scanText = (text: string, ctx: string | undefined, root: string, path: string, leafKey: string): void => { - if (text.length === 0 || text.length > 200_000) return; - if (LITERAL_KEYS.has(leafKey)) return; - const mentions = mentionsIn(text); - /** Every reference-shaped occurrence: the captured token and where it began. */ - const hits: { token: string; at: number }[] = []; - const trimmed = text.trim(); - if (ALL_FIELD_NAMES.has(trimmed)) hits.push({ token: trimmed, at: 0 }); - for (const shape of REFERENCE_SHAPES) { - for (const m of text.matchAll(shape)) { - hits.push({ token: m[1], at: (m.index ?? 0) + m[0].indexOf(m[1]) }); - } - } - for (const m of hits) { - const token = m.token; - if (!ALL_FIELD_NAMES.has(token)) continue; - const at = m.at; - let nearest: string | undefined; - for (const mention of mentions) { - if (mention.end <= at) nearest = mention.object; - else break; - } - const candidates = [...new Set([nearest, ctx])].filter((c): c is string => c !== undefined); - const credited = candidates.filter((c) => fieldsByObject.get(c)?.has(token)); - if (credited.length === 0) { - unresolved.push({ token, root, path }); - continue; - } - for (const object of credited) record(object, token, root, path, leafKey); - } -}; - -/** The object context a node establishes for its own subtree, if any. */ -const contextOf = (node: AnyRec, ctx: string | undefined): string | undefined => { - const named = (v: unknown): string | undefined => - typeof v === 'string' && fieldsByObject.has(v) ? v : undefined; - const nested = (v: unknown, key: string): string | undefined => - v && typeof v === 'object' ? named((v as AnyRec)[key]) : undefined; - return ( - named(node.object) ?? - named(node.objectName) ?? - named(node.targetObject) ?? - nested(node.data, 'object') ?? - nested(node.config, 'objectName') ?? - nested(node.config, 'object') ?? - // A `views` element is `{ list, form, listViews }` with the object named - // only inside `list.data` — without this hoist the FORM section's fields - // would resolve to nothing, which is the same silent drop being fixed. - (node.list && typeof node.list === 'object' - ? nested((node.list as AnyRec).data, 'object') - : undefined) ?? - named(node.name) ?? - (typeof node.dataset === 'string' ? datasetObject.get(node.dataset) : undefined) ?? - // A flow names its object on the TRIGGER node, and its later nodes read - // `{record.x}` with no object of their own — `billing-handoff`'s http body - // reads eleven contract fields that way. Without this hoist those eleven - // resolve to nothing and eleven live fields read as inert, which is the - // loud direction of the same mistake rather than the silent one, but still - // wrong. Per-node `objectName` still wins inside its own subtree. - (Array.isArray(node.nodes) - ? (node.nodes as AnyRec[]) - .map((n) => nested(n.config, 'objectName') ?? nested(n.config, 'object')) - .find((o) => o !== undefined) - : undefined) ?? - ctx - ); -}; - -const walk = (node: unknown, ctx: string | undefined, root: string, path: string, leafKey: string): void => { - if (node === null || node === undefined) return; - if (typeof node === 'function') { - scanText(Function.prototype.toString.call(node), ctx, root, path, leafKey); - return; - } - if (typeof node === 'string') { - scanText(node, ctx, root, path, leafKey); - return; - } - if (typeof node !== 'object') return; - if (Array.isArray(node)) { - node.forEach((item, i) => walk(item, ctx, root, `${path}[${i}]`, leafKey)); - return; - } - const rec = node as AnyRec; - const inner = contextOf(rec, ctx); - const isFieldDeclarationMap = root === 'objects' && /^\[\d+\]\.fields$/.test(path); - for (const [key, value] of Object.entries(rec)) { - const childPath = `${path}.${key}`; - // A predicate map spells the field as its KEY: `{ is_active: true }`. - // Nowhere else are keys treated as references — `type`, `name` and `label` - // are ubiquitous schema keys AND plausible field names, and crediting them - // wholesale would hand out exactly the immunity this scan removes. - const keyIsReference = - !isFieldDeclarationMap && - ALL_FIELD_NAMES.has(key) && - (PREDICATE_KEYS.has(leafKey) || WRITE_KEYS.has(leafKey)); - if (keyIsReference) { - const owner = inner && fieldsByObject.get(inner)?.has(key) ? inner : undefined; - if (!owner) unresolved.push({ token: key, root, path }); - else if (WRITE_KEYS.has(leafKey)) sites.push({ object: owner, field: key, root, path, bucket: 'carrier' }); - else record(owner, key, root, path, leafKey); - } - // A map KEYED by object name — `translations[].en.objects.crm_campaign`, - // `permissions[].objects.crm_lead` — names its object in a position no - // `object:` lookup reaches. Without this the locale rows for every field - // resolve to nothing, and the ledger cannot show an adjudicator the four - // bundles a removal has to clean. - walk(value, fieldsByObject.has(key) ? key : inner, root, childPath, key); - } -}; - -for (const [key, value] of Object.entries(S)) { - if (key === 'manifest' || key === 'i18n' || key === 'requires') continue; - walk(value, undefined, key, '', key); -} - -// ─────────────────────────────────────────────────────────── the ledger ── - -export type Row = { - object: string; - field: string; - verdict: Verdict; - behaviour: number; - display: number; - carrier: number; - /** Other objects declaring the same field name — the masking set. */ - sharedWith: string[]; -}; - -const byField = new Map(); -for (const s of sites) byField.set(`${s.object}.${s.field}`, [...(byField.get(`${s.object}.${s.field}`) ?? []), s]); - -export const rows: Row[] = [...fieldsByObject].flatMap(([object, fields]) => - [...fields].map((field) => { - const found = byField.get(`${object}.${field}`) ?? []; - const count = (b: Bucket): number => found.filter((s) => s.bucket === b).length; - const behaviour = count('behaviour'); - const display = count('display'); - return { - object, - field, - verdict: (behaviour > 0 ? 'live' : display > 0 ? 'display-only' : 'inert') as Verdict, - behaviour, - display, - carrier: count('carrier'), - sharedWith: (objectsByField.get(field) ?? []).filter((o) => o !== object), - }; - }), -); - -export const sitesOf = (object: string, field: string): Site[] => byField.get(`${object}.${field}`) ?? []; - -/** - * Why `--sites` validates its argument, and why no other path needs to (#1255). - * - * `--json` and the default ledger both ENUMERATE `fieldsByObject`, so neither - * can name a field that does not exist. `--sites` is the only path that takes a - * field name from **argv**, and it used to hand whatever it was given straight - * to `sitesOf`, which answers `[]` for a misspelling exactly as it does for a - * field nothing reads. Both then printed the same sentence and exited 0. - * - * That sentence — `(none — this field is inert)` — is the one quoted into an - * enforce-or-remove decision; #1198 and #1199 are both adjudications driven by - * this reading. A typo producing it verbatim with a green exit is silent AND - * self-confirming: re-running the same misspelled command re-derives the same - * confident answer, forever. A tool that answers questions about fields that do - * not exist manufactures evidence, so an unresolvable name is now a refusal. - * - * Measured while fixing this: **no declared field currently has zero sites** — - * every one has at least a locale row — so on today's stack the inert sentence - * was reachable ONLY through a name that does not exist. The zero-site branch - * is kept regardless (a field can lose its last carrier, and then the sentence - * is the true answer); what changed is that a typo no longer reaches it. - * - * This is a lookup, not new machinery: `fieldsByObject` already holds the - * declared set the ledger itself is built from, and `objectsByField` already - * answers "this field exists — on which object?". Near-misses are named from - * those two maps and nothing else; there is deliberately **no fuzzy matching**, - * so the correction offered is always a fact rather than a guess. - * - * @returns the refusal lines, or `null` when `target` names a declared field. - */ -export const refuseSitesTarget = (target: string): string[] | null => { - const objectList = ` registered objects: ${[...fieldsByObject.keys()].sort().join(', ')}`; - const dot = target.lastIndexOf('.'); - if (dot <= 0 || dot === target.length - 1) { - return [ - target.length === 0 - ? '✗ --sites needs an . argument; none was given.' - : `✗ --sites needs ., not '${target}'.`, - objectList, - ]; - } - const [object, field] = [target.slice(0, dot), target.slice(dot + 1)]; - /** Pure lookup: the objects that really do declare this name, if any. */ - const elsewhere = objectsByField.get(field) ?? []; - const alsoOn = - elsewhere.length > 0 - ? ` '${field}' is declared on ${elsewhere.join(', ')}.` - : ` no registered object declares a field named '${field}'.`; - const declared = fieldsByObject.get(object); - if (declared === undefined) { - return [`✗ no object named '${object}' is registered in this stack.`, alsoOn, objectList]; - } - if (!declared.has(field)) { - return [ - `✗ '${object}' declares no field named '${field}'.`, - alsoOn, - ` 'pnpm scan:fields --json' lists every declared field with its verdict.`, - ]; - } - return null; -}; - -// ───────────────────────────────────────────────────────────── reporting ── - -const argv = process.argv.slice(2); -const has = (flag: string): boolean => argv.includes(flag); - -const inert = rows.filter((r) => r.verdict === 'inert'); -const displayOnly = rows.filter((r) => r.verdict === 'display-only'); -/** - * The card's headline set: inert AND sharing its name with another object's - * field, so the old token grep read it as consumed and it could never have - * reached a sweep. `crm_product.tax_rate` is the row that produced #1193. - */ -const masked = inert.filter((r) => r.sharedWith.length > 0); - -const main = (): void => { - if (has('--json')) { - console.log(JSON.stringify({ rows, unresolved: unresolved.length }, null, 2)); - return; - } - - const sitesFlag = argv.indexOf('--sites'); - if (sitesFlag !== -1) { - const target = argv[sitesFlag + 1] ?? ''; - // Refuse before reporting: `sitesOf` cannot tell a misspelling from a field - // nothing reads, so the check has to happen here. See `refuseSitesTarget`. - const refusal = refuseSitesTarget(target); - if (refusal !== null) { - for (const line of refusal) console.error(line); - process.exitCode = 1; - return; - } - const dot = target.lastIndexOf('.'); - const [object, field] = [target.slice(0, dot), target.slice(dot + 1)]; - const found = sitesOf(object, field); - console.log(`${target} — ${found.length} site(s)\n`); - for (const s of found) console.log(` ${s.bucket.padEnd(9)} ${s.root}${s.path}`); - if (found.length === 0) console.log(' (none — this field is inert)'); - return; - } - - console.log( - `Field consumer ledger — ${rows.length} declared fields across ${fieldsByObject.size} objects\n` + - ' resolved against the registered stack, per OBJECT rather than per file\n', - ); - console.log( - ` live ${rows.filter((r) => r.verdict === 'live').length}` + - ` · display-only ${displayOnly.length}` + - ` · inert ${inert.length}` + - ` · unresolved tokens ${unresolved.length}\n`, - ); - - const table = (label: string, list: Row[]): void => { - if (list.length === 0) { - console.log(` ${label}: none\n`); - return; - } - console.log(` ${label} (${list.length}):`); - for (const r of [...list].sort((a, b) => `${a.object}.${a.field}`.localeCompare(`${b.object}.${b.field}`))) { - const shared = r.sharedWith.length ? ` ← name also on ${r.sharedWith.join(', ')}` : ''; - console.log(` ${`${r.object}.${r.field}`.padEnd(46)}${shared}`); - } - console.log(''); - }; - - table('INERT — read by nothing, drawn nowhere', inert); - table('MASKED — inert, and invisible to a name-only grep', masked); - if (has('--all')) { - table('DISPLAY-ONLY — drawn, but nothing reads it', displayOnly); - } else { - console.log(` display-only: ${displayOnly.length} (re-run with --all to list them)\n`); - } - - // A scan that finds nothing to place has stopped working; say so rather than - // printing a confident empty ledger. - if (sites.length === 0) { - console.error('✗ no field reference resolved anywhere — the stack shape moved; fix this scan.'); - process.exitCode = 1; - return; - } - console.log( - ' This is a ledger, not a gate: each inert row is a separate enforce-or-remove\n' + - ' decision (maintainer ruling 2026-08-17, 「逐个 enforce-or-remove(推荐)」),\n' + - ' and removing a published field is the maintainer’s call, not the sweep’s.\n', - ); -}; - -// Run only when invoked directly; `rows`, `sitesOf` and friends stay importable -// for `test/field-consumer-scan.test.ts`, so importing must not run the scan. -// -// The comparison lives in `scripts/lib/main-module.mjs` and is never hand-rolled -// here: this line used to read `process.argv[1].includes('scan-field-consumers')`, -// which survives symlinks by accident but silently stops matching the day this -// file is renamed — `pnpm scan:fields` would then print nothing and exit 0, -// which is indistinguishable from a clean ledger (#1252). -if (isMainModule(import.meta.url)) main(); diff --git a/test/field-consumer-scan.test.ts b/test/field-consumer-scan.test.ts deleted file mode 100644 index 71837b01..00000000 --- a/test/field-consumer-scan.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { - rows, - sitesOf, - fieldsByObject, - objectsByField, - refuseSitesTarget, - type Row, -} from '../scripts/scan-field-consumers'; -import { REPO_ROOT } from './helpers/repo-root'; - -/** - * The consumer scan stays OBJECT-AWARE (#1193). - * - * `scripts/scan-field-consumers.ts` exists because the sweep that produced - * #1182's row set was object-blind: it called a field consumed when its NAME - * appeared in a `src/**\/*.ts` file outside `views/` and `translations/`. - * `crm_product.tax_rate` therefore read as consumed — the token also spells - * `crm_quote_line_item.tax_rate`, a different object's field whose own formula - * reads its own rate — and the product's rate reached no card. `is_taxable`, - * two declarations away in the same field group and inert for exactly the same - * reason, did reach it, because its name is unique. - * - * ## What this file guards, and what it deliberately does NOT - * - * It does not guard the NUMBER of inert fields. That was considered and - * rejected: the maintainer ruling of 2026-08-17 was 「逐个 enforce-or-remove - * (推荐)」 — a verdict per field — so a gate on the count would encode the - * blanket rule the ruling declined to make, and would go red the day someone - * lands a field one PR before its consumer. The scan is a ledger a human - * adjudicates, and `pnpm verify` does not run it. - * - * What must not regress is the scan's ABILITY TO SEE, because that failure is - * invisible: a false negative cannot appear in the scan's own output, so a - * resolver that quietly stopped distinguishing two objects would keep printing - * a confident, shorter ledger and nobody would notice. So the assertions below - * are about resolution, not about counts: - * - * 1. the two same-named `tax_rate` fields get DIFFERENT verdicts — the exact - * discrimination the old grep could not make; - * 2. every site attributed to a field belongs to an object that DECLARES it; - * 3. #1182's own verdicts are re-derivable from this scan (positive and - * negative controls, so the rule is not agreeing with everything); - * 4. the scan is not vacuous — it resolves thousands of sites, and the - * shared-name population it exists for is non-empty. - */ - -const verdictOf = (object: string, field: string): Row['verdict'] | 'absent' => - rows.find((r) => r.object === object && r.field === field)?.verdict ?? 'absent'; - -describe('the scan resolves a shared field name per object, not per file (#1193)', () => { - /** - * The card in one assertion. Both objects declare `tax_rate`; one is read by - * a formula on its own object and one is read by nothing. A name-only grep - * reports a single answer for both — and reported the wrong one. - */ - it('crm_quote_line_item.tax_rate is live and crm_product.tax_rate is not', () => { - expect(fieldsByObject.get('crm_product')?.has('tax_rate')).toBe(true); - expect(fieldsByObject.get('crm_quote_line_item')?.has('tax_rate')).toBe(true); - expect(verdictOf('crm_quote_line_item', 'tax_rate')).toBe('live'); - expect(verdictOf('crm_product', 'tax_rate')).toBe('inert'); - }); - - it('the live one is read by the total_price formula on its own object', () => { - const sites = sitesOf('crm_quote_line_item', 'tax_rate'); - expect(sites.some((s) => s.root === 'objects' && s.bucket === 'behaviour')).toBe(true); - }); - - /** - * The inert one has only CARRIER sites — the four locale bundles. That is the - * shape a removal has to clean, and the shape that proves nothing reads it. - * Asserted as "no behaviour and no display site" rather than "zero sites", - * because a locale row is not evidence of a reader and must not be mistaken - * for one in either direction. - */ - it('the inert one has locale rows and nothing else', () => { - const sites = sitesOf('crm_product', 'tax_rate'); - expect(sites.every((s) => s.bucket === 'carrier')).toBe(true); - expect(sites.map((s) => s.root)).toContain('translations'); - }); - - it('no site is ever attributed to an object that does not declare the field', () => { - const bogus = rows.flatMap((r) => - sitesOf(r.object, r.field) - .filter(() => !fieldsByObject.get(r.object)?.has(r.field)) - .map((s) => `${r.object}.${r.field} @ ${s.root}${s.path}`), - ); - expect(bogus, `sites attributed to objects that do not declare the field:\n ${bogus.join('\n ')}`).toEqual([]); - }); -}); - -describe('#1182 verdicts are re-derivable from this scan (#1193)', () => { - /** - * The reverse verification, pinned. - * - * Measured on the pre-removal tree (`c83aa744`, the commit before #1195 - * landed): of the twelve fields #1182 deleted, two read `inert` and ten read - * `display-only` — and NONE read `live`. On this tree they are gone, so what - * can be re-checked here is the other half of that card: the one row it KEPT - * and enforced. `crm_account.parent_account` read `display-only` before the - * roll-up existed and reads `live` now, because `child_account_revenue` - * names it as its `relationshipField`. A resolver that stopped reading - * summary operations would flip it back, silently. - */ - it('the row #1182 enforced now reads as live', () => { - expect(verdictOf('crm_account', 'parent_account')).toBe('live'); - const behavioural = sitesOf('crm_account', 'parent_account').filter((s) => s.bucket === 'behaviour'); - expect(behavioural.length).toBeGreaterThan(0); - }); - - it('the fields #1182 removed are gone, so the ledger cannot re-report them', () => { - for (const [object, field] of [ - ['crm_product', 'is_taxable'], ['crm_product', 'quantity_on_hand'], - ['crm_product', 'billing_type'], ['crm_case', 'parent_case'], - ['crm_contact', 'birthdate'], ['crm_campaign', 'parent_campaign'], - ] as const) { - expect(verdictOf(object, field), `${object}.${field}`).toBe('absent'); - } - }); - - /** - * Negative controls. A resolver that credited every token to every object - * would call everything live; one that credited nothing would call everything - * inert. These two rows are heavily read and must stay live, and their site - * counts are asserted as ">= 1 behaviour" rather than as a number, so - * ordinary work does not have to update a magic figure. - */ - it('heavily-read fields stay live', () => { - expect(verdictOf('crm_product', 'list_price')).toBe('live'); - expect(verdictOf('crm_opportunity', 'amount')).toBe('live'); - }); -}); - -describe('the scan is not vacuous (#1193)', () => { - it('resolves a real population of fields and sites', () => { - // A stack shape that moved would leave every row at zero sites and the - // ledger would report the whole app as inert — loud, but for the wrong - // reason. Fail here, where the message is true. - expect(rows.length).toBeGreaterThan(300); - expect(rows.filter((r) => r.verdict === 'live').length).toBeGreaterThan(200); - }); - - it('the shared-name population this scan exists for is non-empty', () => { - // If no field name were ever shared across objects, the object-aware - // resolution would be guarding nothing and this whole file would be - // agreeing with everything. - const shared = [...objectsByField].filter(([, objects]) => objects.length > 1); - expect(shared.length).toBeGreaterThan(10); - }); -}); - -/** - * `--sites` refuses a name that does not exist, and the refusal is an EXIT CODE - * (#1255). - * - * ## Why the exit status is the assertion and the wording is not - * - * The regression is not "the message is wrong", it is **"exits 0 on a name that - * does not exist"**. `--sites` is the only path that takes a field name from - * argv (`--json` and the default ledger both enumerate `fieldsByObject`, so - * neither can name a field that does not exist), and it used to pass whatever it - * was handed to `sitesOf`, which returns `[]` for a misspelling exactly as it - * does for a field nothing reads. Both then printed `(none — this field is - * inert)` and exited 0. - * - * That sentence is what gets quoted into an enforce-or-remove decision — #1198 - * and #1199 are live adjudications driven by this reading — so a typo answered - * with a green exit is silent AND self-confirming: the same misspelled command - * re-derives the same confident answer every time it is run. A test that only - * read stdout would have passed on the old behaviour the moment the wording - * happened to match, which is why every case below asserts `status`. - * - * ## The third case is the one that makes the first two mean something - * - * A "fix" that rejected every input would satisfy the two refusal cases - * perfectly. `crm_product.tax_rate` is the control: declared, genuinely inert - * (#1193's headline row), and it must still be ACCEPTED and reported with exit - * 0. Refusal is about the name not existing, never about the verdict. - * - * ## A measurement worth recording, because it dates - * - * On this tree **no declared field has zero sites** — every one carries at least - * a locale row — so the inert sentence is currently reachable only through a - * name that does not exist. The control below therefore pins a declared field - * that reports its four carrier sites, not one that prints the sentence. If a - * field ever loses its last carrier the sentence becomes reachable honestly, - * and `scan-field-consumers.ts` still prints it; nothing here forbids that. - */ -describe('--sites refuses a field name that does not exist (#1255)', () => { - const TSX = join(REPO_ROOT, 'node_modules/.bin/tsx'); - const SCRIPT = join(REPO_ROOT, 'scripts/scan-field-consumers.ts'); - - /** - * Every case below spawns the real script, and that spawn is not cheap: `tsx` - * compiles the file and the script imports `objectstack.config`, i.e. the whole - * registered metadata stack, on each run. Measured here, one spawn per case: - * 1271–1313ms. - * - * Vitest's default budget is 5000ms, and a first version of this suite put - * THREE spawns in one case (`it` over an array of malformed targets). That - * measured 3802ms locally — inside the default, so it passed here — and timed - * out in CI, where the same import work measured ~1.7x slower. Splitting it - * into one case per target restored one spawn per case; the budget below is - * then stated rather than defaulted so the remaining margin does not depend on - * how loaded the runner is. - * - * Deliberately far above the real cost: this timeout exists to catch a spawn - * that HANGS, not to police how fast the script starts. A startup regression - * should be argued on its own evidence, never discovered as a flaky timeout in - * an argv test. - */ - const SPAWN_TIMEOUT_MS = 30_000; - - /** - * Spawns the real script, never throwing, so the exit status can be read. - * - * `stdio` is pinned so the child's stderr is CAPTURED rather than echoed into - * the parent's log (#1302) — `error.stderr` below is populated either way, so - * every assertion on the failure text still reads exactly what it read - * before. See test/verify-log-decoy-pin.test.ts. - */ - const run = (...args: string[]): { status: number; output: string } => { - try { - return { - status: 0, - output: execFileSync(TSX, [SCRIPT, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }), - }; - } catch (error) { - const failure = error as { status?: number; stdout?: string; stderr?: string }; - return { status: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` }; - } - }; - - it('an UNKNOWN object exits non-zero instead of calling it inert', () => { - const { status, output } = run('--sites', 'no_such_object.no_such_field'); - expect(status).not.toBe(0); - expect(output).not.toContain('this field is inert'); - expect(output).toContain("no object named 'no_such_object'"); - }, SPAWN_TIMEOUT_MS); - - it('a known object with an UNDECLARED field exits non-zero', () => { - // The card's own reproduction, verbatim. - const { status, output } = run('--sites', 'crm_account.no_such_field'); - expect(status).not.toBe(0); - expect(output).not.toContain('this field is inert'); - expect(output).toContain("declares no field named 'no_such_field'"); - }, SPAWN_TIMEOUT_MS); - - it('a DECLARED but genuinely inert field is still reported, still exit 0', () => { - // The control. Without it, a change that rejected everything would pass. - expect(fieldsByObject.get('crm_product')?.has('tax_rate')).toBe(true); - expect(verdictOf('crm_product', 'tax_rate')).toBe('inert'); - const { status, output } = run('--sites', 'crm_product.tax_rate'); - expect(status).toBe(0); - expect(output).toContain('crm_product.tax_rate — 4 site(s)'); - expect(output).toContain('translations[0].en.objects.crm_product.fields'); - }, SPAWN_TIMEOUT_MS); - - /** - * One case per target rather than a loop over three, for two reasons: each - * spawn then owns its own timeout budget, and a failure names the TARGET that - * broke instead of reporting only that a loop timed out. - * - * `crm_account` is the original of the three: with no dot, the old - * `lastIndexOf('.')` split it into object `crm_accoun` / field `crm_account` - * and called that inert with exit 0. The other two are the boundaries of the - * same split — a dot at either end leaves one half empty. - * - * These must stay SPAWNS. The garbled split was `main()`-path behaviour, so a - * direct `refuseSitesTarget()` call would not have caught the original defect; - * only the real script's exit status pins it. - */ - it.each(['crm_account', 'crm_account.', '.tax_rate'])( - 'a malformed target (%s) exits non-zero rather than scanning a garbled name', - (target) => { - const { status, output } = run('--sites', target); - expect(status, target).not.toBe(0); - expect(output, target).toContain('--sites needs'); - }, - SPAWN_TIMEOUT_MS, - ); - - it('--sites with no argument at all exits non-zero', () => { - const { status, output } = run('--sites'); - expect(status).not.toBe(0); - expect(output).toContain('none was given'); - }, SPAWN_TIMEOUT_MS); - - /** - * The near-miss correction, which is a LOOKUP and not fuzzy matching: the - * scan already knows every declared field, so when the misspelled half is the - * object name it can name the objects that really do declare the field. - */ - it('names the objects that do declare the field, when any do', () => { - const lines = refuseSitesTarget('crm_accont.tax_rate'); - expect(lines).not.toBeNull(); - expect(lines!.join('\n')).toContain("'tax_rate' is declared on crm_product, crm_quote_line_item"); - }); - - it('says so plainly when no object declares the name at all', () => { - const lines = refuseSitesTarget('crm_account.no_such_field'); - expect(lines!.join('\n')).toContain("no registered object declares a field named 'no_such_field'"); - }); - - it('accepts every declared field in the ledger — the refusal is not a blanket', () => { - // The broadest form of the control above: refusal must key on the NAME not - // existing, so nothing the ledger itself enumerates may ever be refused. - const refused = rows.filter((r) => refuseSitesTarget(`${r.object}.${r.field}`) !== null); - expect(refused.map((r) => `${r.object}.${r.field}`)).toEqual([]); - }); -}); From 93044cfe7440c2a8e61917f6b5019a024dd46822 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:44:07 +0000 Subject: [PATCH 2/2] chore(test): reconcile the two rosters and the helper's declaration comment test/script-main-guard.test.ts drops its scan-field-consumers.ts entry and test/verify-log-decoy-pin.test.ts drops the deleted suite from KNOWN_SPAWNING_FILES; the dated decoy-line table keeps its measured figures and gains a tombstone. scripts/lib/main-module.d.mts stops citing the retired script as its reason and states the one measured with tsc --listFiles. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- .../retire-the-field-consumer-scanner.md | 28 +++++++++++++++++++ scripts/lib/main-module.d.mts | 10 +++++-- test/script-main-guard.test.ts | 24 ++++------------ test/verify-log-decoy-pin.test.ts | 3 +- 4 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 .changeset/retire-the-field-consumer-scanner.md diff --git a/.changeset/retire-the-field-consumer-scanner.md b/.changeset/retire-the-field-consumer-scanner.md new file mode 100644 index 00000000..baeccd6e --- /dev/null +++ b/.changeset/retire-the-field-consumer-scanner.md @@ -0,0 +1,28 @@ +--- +--- + +Repo tooling only — this PR releases nothing to HotCRM users, so the frontmatter +above is deliberately empty (the sanctioned "releases nothing" declaration that +`.github/workflows/changeset-check.yml` documents, on par with the +`skip-changeset` label). No metadata changes: `crm_product.tax_rate` and every +other field are untouched here. + +The field-consumer scanner is retired outright — `scripts/scan-field-consumers.ts`, +`test/field-consumer-scan.test.ts` and the `pnpm scan:fields` script. "Does a +declared field have any consumer" is a property of every metadata app, not a +HotCRM business fact, and `AGENTS.md` § "Scope — a pure metadata application" +rule 3 puts lint, validation, gates and diagnostics on the platform: *"A +drift-class or validation-class gap you find is a platform problem and goes +upstream … ⛔ Do not grow a gate farm."* The scanner (2026-08-17) predates that +2026-08-31 ruling and was never reconciled with it. Maintainer ruling of +2026-09-05 on #1543, option F. The detection moves upstream as objectstack#15922. + +Two rosters name the deleted files and are reconciled with them: +`test/script-main-guard.test.ts` drops its `scan-field-consumers.ts` entry (its +coverage assertion compares `GUARDED` against the guarded scripts found on disk, +so the entry cannot outlive the file), and `test/verify-log-decoy-pin.test.ts` +drops `test/field-consumer-scan.test.ts` from `KNOWN_SPAWNING_FILES`. Both +measured: with the files deleted and the rosters untouched the two suites go red +in four places, which is what makes this an edit and not a formality. The dated +`✗`-line table in the decoy pin keeps its measured figures — it records a run on +`ec4c5ac6` and its total still reconciles — and gains a tombstone instead. diff --git a/scripts/lib/main-module.d.mts b/scripts/lib/main-module.d.mts index 4d04f007..004d4049 100644 --- a/scripts/lib/main-module.d.mts +++ b/scripts/lib/main-module.d.mts @@ -5,9 +5,13 @@ * * The implementation is `.mjs` because `scripts/*.mjs` gates run under bare * `node` with no build step — CI invokes them as `node scripts/.mjs`. - * `scripts/scan-field-consumers.ts` runs under `tsx` and imports the same - * helper, and `tsconfig.json` typechecks `scripts/**\/*.ts`, so the helper needs - * a declaration. One shared guard beats two spellings of the same comparison. + * A declaration is still needed because first-party TypeScript reaches this + * helper THROUGH those gates: `test/source-token-ratchet.test.ts` imports + * `scripts/check-source-token-ratchet.mjs` under `allowJs`, and tsc resolves + * that gate's own `./lib/main-module.mjs` import to this file — measured with + * `tsc --noEmit --listFiles`, which lists it. The last `.ts` script to import + * the helper directly was `scripts/scan-field-consumers.ts`, retired in #1543. + * One shared guard beats two spellings of the same comparison. */ /** diff --git a/test/script-main-guard.test.ts b/test/script-main-guard.test.ts index 93cb6b90..8deb2f88 100644 --- a/test/script-main-guard.test.ts +++ b/test/script-main-guard.test.ts @@ -49,6 +49,11 @@ import { REPO_ROOT } from './helpers/repo-root'; * `pnpm scan:fields` prints nothing and exits 0, which is indistinguishable * from a clean ledger. Same failure mode, different trigger. * + * That third file was retired outright in #1543 — "does a declared field have a + * consumer" is a platform diagnostic, not a HotCRM business fact (objectstack#15922 + * is the successor) — so two of the three spellings still have a file. The record + * stays: it is why the shared helper exists. + * * ## How this file closes it rather than fixing three files * * Two assertions, one structural and one behavioural: @@ -208,25 +213,6 @@ const GUARDED: Guarded[] = [ green: { args: [], status: 0, says: '0 `i18n/missing-*` issues' }, red: { args: [], status: 1, says: '✗ i18n lint gate' }, }, - { - script: 'scripts/scan-field-consumers.ts', - runner: join(REPO_ROOT, 'node_modules/.bin/tsx'), - green: { args: ['--json'], status: 0, says: '"field"' }, - // This script is a ledger rather than a gate, and it has two non-zero - // exits. `✗ no field reference resolved anywhere` fires only when the - // registered stack resolves nothing at all, so staging it means standing up - // a broken copy of `objectstack.config` — a fixture about the stack, not - // about the entry-point guard this file is holding, which is why this entry - // carried `red: null` until #1255 landed. The second one needs no fixture: - // `--sites` refuses a name that does not exist, on stderr and with exit 1. - // - // What this leg proves is the GUARD, not the argument check. The refusal - // lives inside `main()`, so with the entry-point guard broken the spawn - // reaches neither: it prints zero bytes and exits 0, and both assertions - // below fail. Measured, not assumed — with `isMainModule()` forced to - // return `false` this leg fails as `expected 0 to be 1` with empty output. - red: { args: ['--sites', 'no_such_object.no_such_field'], status: 1, says: 'no object named' }, - }, ]; /** diff --git a/test/verify-log-decoy-pin.test.ts b/test/verify-log-decoy-pin.test.ts index bdd7a86e..6ff9946a 100644 --- a/test/verify-log-decoy-pin.test.ts +++ b/test/verify-log-decoy-pin.test.ts @@ -19,7 +19,7 @@ import { join, relative } from 'node:path'; * | 29 | `test/source-hygiene-scan-surface.test.ts` | * | 16 | `test/source-token-ratchet.test.ts` | * | 7 | `test/source-hygiene-header-position.test.ts` | - * | 6 | `test/field-consumer-scan.test.ts` | + * | 6 | `test/field-consumer-scan.test.ts` — retired in #1543 | * | 3 | `test/lint-i18n-gate.test.ts` | * | 3 | `test/script-main-guard.test.ts` — fixtures are copies of the real gates | * | 0 | `test/docs-readme-token-figures.test.ts` — spawns a gate, but only its green leg | @@ -83,7 +83,6 @@ const DECOY_LINES_BEFORE = 64; /** Files known to spawn a gate. The scanner must keep finding all of them. */ const KNOWN_SPAWNING_FILES = [ 'test/docs-readme-token-figures.test.ts', - 'test/field-consumer-scan.test.ts', 'test/lint-i18n-gate.test.ts', 'test/script-main-guard.test.ts', 'test/source-hygiene-header-position.test.ts',