diff --git a/.changeset/searchable-fields-stale-declaration.md b/.changeset/searchable-fields-stale-declaration.md new file mode 100644 index 0000000000..8b58d9cedf --- /dev/null +++ b/.changeset/searchable-fields-stale-declaration.md @@ -0,0 +1,62 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): a `searchableFields` entry naming no field is caught at authoring +time, not at request time + +`searchableFields` is `z.array(z.string())` in both `object.zod.ts` and the +list-view schema, so nothing ever checked that an entry resolves to anything. +Rename a field and the old name stays behind — Zod-valid, shipped, pointing at +a column that no longer exists. + +The engine tolerates it, which is exactly what kept the drift invisible: +`resolveSearchFields` filters the declaration down to fields that exist +(`searchableFields?.filter((f) => all[f])`) and says nothing. The tolerance +fails in the direction nobody expects: + +- **some entries stale** → `$search` scans a NARROWER set than the object + declares. Records that should match do not, and the response is + indistinguishable from "no such record"; +- **every entry stale** → the filtered set is empty, so resolution falls + through to the AUTO-DEFAULT (name/title + short-text fields). A declaration + whose whole purpose is to CHOOSE the searchable set ends up selecting one the + author never wrote — the "asked narrower, answered wider" inversion #4226 + closed on the projection axis. + +It also stops being quiet downstream. Clients echo the declaration verbatim as +the `$searchFields` override (objectui's list search sends +`schema.searchableFields`), so once the REST read path validates that override +against the object (#4254), a stale entry the engine had been silently skipping +becomes a `400 INVALID_FIELD` on every list search for that object — a +request-time break whose cause is an authoring typo made long before. + +**New rule — `searchable-field-unknown` (gating).** Wired into +`REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and +`os compile` with no CLI edit. It covers the object's own ADR-0061 declaration +and the list views that narrow it (`objects[].listViews`, a `defineView` +default `list`, and named `listViews`), resolving each entry against the bound +object's declared fields. + +`error`, not the advisory level the other field-existence rules use +(`page-field-unknown`, `form-field-unknown`, `semantic-role-field-unknown` are +all warnings). Those describe a consumer that SKIPS an unknown name and renders +the rest; this describes a declaration that either selects the wrong set or +refuses the request outright — the same call `validate-flow-template-paths` +makes for a filter-position token, where the miss widens the query instead of +shrinking the page. + +Existence only: a field that exists but is an odd search target (a `json` +column) is NOT flagged — an explicit `searchableFields` is authoritative, so +declaring one is a choice, not drift. Three skips keep false positives near zero +(ADR-0072 D1): an object this stack does not define, an object with no authored +field map (external / datasource-introspected), and registry-injected system +columns — the last derived from the spec's own `FIELD_GROUP_SYSTEM_FIELDS` and +`SystemFieldName` rather than hand-copied, since this package already carries +five slightly-different copies of that list. + +Dotted paths are the one place this rule is stricter than its siblings. They +skip `owner_id.name` because the query engine resolves the traversal; search +does not — `resolveSearchFields` matches the field map by exact string, so a +dotted entry is dropped exactly like a typo, and it is the spelling most likely +borrowed from `select`/`sort`. It is flagged, with its own fix hint. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 1d0685ec8c..fe12f293c6 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -194,6 +194,15 @@ export { } from './validate-object-references.js'; export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js'; +export { + validateSearchableFields, + SEARCHABLE_FIELD_UNKNOWN, +} from './validate-searchable-fields.js'; +export type { + SearchableFieldFinding, + SearchableFieldSeverity, +} from './validate-searchable-fields.js'; + export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js'; export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js'; diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 8939c703a5..fe7abd4fab 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -16,6 +16,7 @@ describe('reference-integrity suite — membership', () => { it('holds exactly the reference-resolution rules, in report order', () => { expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toEqual([ 'validateObjectReferences', + 'validateSearchableFields', 'validateActionNameRefs', 'validatePageFieldBindings', 'validateChartBindings', @@ -48,6 +49,10 @@ describe('reference-integrity suite — every member actually runs', () => { { name: 'crm_lead', fields: { name: { type: 'text', label: 'Name' } }, + // validateSearchableFields: `budget` is not a field on crm_lead, so the + // ADR-0061 declaration is stale — the engine drops it and searches a + // narrower set than the object declares. + searchableFields: ['name', 'budget'], permissions: {}, }, ], @@ -144,6 +149,7 @@ describe('reference-integrity suite — every member actually runs', () => { const rules = new Set(findings.map((f) => f.rule)); expect(rules).toContain('object-reference-unknown'); + expect(rules).toContain('searchable-field-unknown'); expect(rules).toContain('action-name-undefined'); expect(rules).toContain('page-field-unknown'); expect(rules).toContain('chart-measure-unknown'); diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 79840b9aad..2a2aa89779 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -32,6 +32,15 @@ * severities (see that module: a filter-position miss gates, every other * position advises), which is why the suite's contract is severity-agnostic. * + * `validateSearchableFields` is a member on the same reading, one layer in: an + * ADR-0061 `searchableFields` entry is a field name written in metadata, + * resolved against the object's own declared fields. It gates (`error`) because + * the engine's tolerance for a stale entry — silently filtering it out — either + * narrows the searched set below what the object declares or, once every entry + * is stale, falls through to the auto-default and searches a set the author + * never wrote. See that module for why the other field-existence rules stay + * advisory and this one does not. + * * Rules that check SHAPE rather than reference (view containers, responsive * styles, seed replay safety, seed state machines, seed/security posture) stay * out — they answer a different question and have their own call sites. @@ -46,6 +55,7 @@ */ import { validateObjectReferences } from './validate-object-references.js'; +import { validateSearchableFields } from './validate-searchable-fields.js'; import { validateActionNameRefs } from './validate-action-name-refs.js'; import { validatePageFieldBindings } from './validate-page-field-bindings.js'; import { validateChartBindings } from './validate-chart-bindings.js'; @@ -91,6 +101,7 @@ export interface ReferenceIntegrityRule { */ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateObjectReferences', run: validateObjectReferences }, + { name: 'validateSearchableFields', run: validateSearchableFields }, { name: 'validateActionNameRefs', run: validateActionNameRefs }, { name: 'validatePageFieldBindings', run: validatePageFieldBindings }, { name: 'validateChartBindings', run: validateChartBindings }, diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts new file mode 100644 index 0000000000..e8618621d0 --- /dev/null +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -0,0 +1,309 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateSearchableFields, + SEARCHABLE_FIELD_UNKNOWN, +} from './validate-searchable-fields.js'; + +/** + * The drift this rule exists for: `email` was renamed to `billing_email` and + * the old name stayed behind in `searchableFields`. Zod-valid, shipped, and + * pointing at a column that no longer exists. + */ +const staleStack = { + objects: [ + { + name: 'crm_account', + fields: { + name: { type: 'text', label: 'Name' }, + billing_email: { type: 'email', label: 'Billing Email' }, + status: { type: 'select', label: 'Status' }, + }, + searchableFields: ['name', 'email', 'status'], + }, + ], +}; + +/** The same object after the rename was carried through. */ +const cleanStack = { + objects: [ + { + name: 'crm_account', + fields: { + name: { type: 'text', label: 'Name' }, + billing_email: { type: 'email', label: 'Billing Email' }, + status: { type: 'select', label: 'Status' }, + }, + searchableFields: ['name', 'billing_email', 'status'], + }, + ], +}; + +describe('validateSearchableFields — object declaration', () => { + it('flags an entry that names no field on the object', () => { + const findings = validateSearchableFields(staleStack); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN); + expect(findings[0].where).toBe('object "crm_account"'); + // The index is part of the path so the author can go straight to the entry. + expect(findings[0].path).toBe('objects[0].searchableFields[1]'); + expect(findings[0].message).toContain('"email"'); + expect(findings[0].message).toContain('crm_account'); + }); + + it('gates the build (error), unlike the advisory field-existence rules', () => { + // A stale entry either narrows the searched set below the declaration or — + // once every entry is stale — falls through to the auto-default and + // searches a set nobody wrote. Neither is something a yellow line should + // ship. Pinned because a downgrade to `warning` would be invisible: `os + // validate` only exits non-zero on `error`. + expect(validateSearchableFields(staleStack)[0].severity).toBe('error'); + }); + + it('explains the fix and names the fields that do exist', () => { + const [finding] = validateSearchableFields(staleStack); + + expect(finding.hint).toContain('billing_email'); + // The declaration is echoed verbatim by clients as `$searchFields`, so the + // hint must say why this is not merely a quietly narrowed search. + expect(finding.hint).toContain('400 INVALID_FIELD'); + }); + + it('suggests the renamed field when the stale name is close to a real one', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { billing_email: { type: 'email' } }, + searchableFields: ['biling_email'], + }, + ], + }); + + expect(findings[0].message).toContain('Did you mean "billing_email"?'); + }); + + it('reports every stale entry, not just the first', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' } }, + searchableFields: ['name', 'email', 'phone'], + }, + ], + }); + + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].searchableFields[1]', + 'objects[0].searchableFields[2]', + ]); + }); + + it('passes a declaration whose every entry resolves', () => { + expect(validateSearchableFields(cleanStack)).toEqual([]); + }); + + it('reads the legacy array field map as well as the name-keyed one', () => { + const asArrayFields = { + objects: [ + { + name: 'crm_account', + fields: [ + { name: 'name', type: 'text' }, + { name: 'billing_email', type: 'email' }, + ], + searchableFields: ['billing_email'], + }, + ], + }; + + expect(validateSearchableFields(asArrayFields)).toEqual([]); + }); +}); + +describe('validateSearchableFields — what it deliberately does not flag', () => { + it('accepts registry-injected system columns absent from authored fields', () => { + // `created_at` / `owner_id` are injected by the objectql registry, so they + // are searchable at runtime while never appearing in `fields`. Flagging + // them would be the false positive that makes authors stop reading lint. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' } }, + searchableFields: ['name', 'created_at', 'owner_id'], + }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('leaves an object with no authored field map alone', () => { + // External objects and datasource-introspected schemas resolve their + // columns at runtime; there is nothing here to judge against. + const findings = validateSearchableFields({ + objects: [ + { name: 'external_invoice', external: { datasource: 'erp' }, searchableFields: ['doc_no'] }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('does not flag a field that exists but is an odd search target', () => { + // An explicit `searchableFields` is authoritative — the engine scans + // exactly what it names — so declaring a json column is a choice, not + // drift. This rule answers existence only. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, payload: { type: 'json' } }, + searchableFields: ['name', 'payload'], + }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('ignores a non-string entry — that is a shape error the schema owns', () => { + const findings = validateSearchableFields({ + objects: [ + { name: 'crm_account', fields: { name: { type: 'text' } }, searchableFields: [null, 42] }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('returns nothing for an empty stack, a missing declaration, or an empty one', () => { + expect(validateSearchableFields({})).toEqual([]); + expect(validateSearchableFields({ objects: [{ name: 'a', fields: { n: { type: 'text' } } }] })).toEqual([]); + expect( + validateSearchableFields({ + objects: [{ name: 'a', fields: { n: { type: 'text' } }, searchableFields: [] }], + }), + ).toEqual([]); + }); +}); + +describe('validateSearchableFields — dotted paths', () => { + it('flags a related-record path, which search cannot resolve', () => { + // Every sibling rule skips `owner_id.name` because the query engine + // resolves the traversal. Search does not: `resolveSearchFields` matches + // the field map by exact string, so a dotted entry is dropped exactly like + // a typo — and it is the spelling most likely borrowed from `select`. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, owner_id: { type: 'lookup', reference: 'sys_user' } }, + searchableFields: ['name', 'owner_id.name'], + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].searchableFields[1]'); + expect(findings[0].hint).toContain("scans this object's own columns"); + }); +}); + +describe('validateSearchableFields — list views that narrow the set', () => { + const objectWithFields = { + name: 'crm_account', + fields: { name: { type: 'text' }, billing_email: { type: 'email' } }, + }; + + it("flags a stale entry on an object's built-in named list view", () => { + const findings = validateSearchableFields({ + objects: [ + { ...objectWithFields, listViews: { all: { type: 'grid', searchableFields: ['email'] } } }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[0]'); + expect(findings[0].where).toBe('object "crm_account" › listViews.all'); + }); + + it('flags a stale entry on a defineView default list, bound via data.object', () => { + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + name: 'account_views', + list: { + type: 'grid', + data: { provider: 'object', object: 'crm_account' }, + searchableFields: ['name', 'email'], + }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].list.searchableFields[1]'); + expect(findings[0].where).toBe('view "account_views" › list'); + }); + + it('flags a stale entry on a named listViews entry', () => { + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + objectName: 'crm_account', + listViews: { active: { type: 'grid', searchableFields: ['email'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].listViews.active.searchableFields[0]'); + }); + + it('passes list views whose narrowing resolves', () => { + const findings = validateSearchableFields({ + objects: [ + { + ...objectWithFields, + listViews: { all: { type: 'grid', searchableFields: ['billing_email'] } }, + }, + ], + views: [ + { + objectName: 'crm_account', + list: { type: 'grid', searchableFields: ['name'] }, + listViews: { active: { type: 'grid', searchableFields: ['name', 'billing_email'] } }, + }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('skips a view bound to an object this stack does not define', () => { + // The object may come from another package; a field map we cannot see + // cannot be judged — the same skip the page/flow/widget rules take. + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + name: 'foreign', + list: { + type: 'grid', + data: { provider: 'object', object: 'pkg_contract' }, + searchableFields: ['no_such_field'], + }, + }, + ], + }); + + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts new file mode 100644 index 0000000000..e5197ed9e9 --- /dev/null +++ b/packages/lint/src/validate-searchable-fields.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0061 — searchable set] `searchableFields` entries must name a field the + * object actually has. + * + * `searchableFields` is `z.array(z.string())` in both `object.zod.ts` and the + * list-view schema, so nothing checks that an entry resolves to anything. Rename + * a field and the old name stays behind: Zod-valid, shipped, and pointing at a + * column that no longer exists. + * + * ── Why a stale entry is not merely inert ──────────────────────────────── + * + * The engine tolerates it. `resolveSearchFields` (`@objectstack/objectql`) + * filters the declaration down to fields that exist — + * `searchableFields?.filter((f) => all[f])` — so a stale name is dropped + * without a word. That tolerance is what makes the drift invisible, and it + * fails in the direction nobody expects: + * + * - Some entries stale → `$search` quietly scans a NARROWER set than the + * object declares. Records that should match do not, and the response is + * indistinguishable from "no such record". + * - EVERY entry stale → the filtered set is empty, so resolution falls + * through to the AUTO-DEFAULT (name/title + short-text fields). A + * declaration whose entire purpose is to CHOOSE the searchable set ends up + * selecting a set the author never wrote — the same "asked narrower, + * answered wider" inversion #4226 closed on the projection axis. + * + * And it does not stay quiet downstream. objectui's list search echoes + * `schema.searchableFields` verbatim as the `$searchFields` override, so once + * the REST read path validates that override against the object (#4254), a + * stale declaration the engine had been silently skipping becomes a `400 + * INVALID_FIELD` on every list search for that object — a request-time break + * whose cause is an authoring typo made long before. + * + * Hence `error`, not the advisory level the other field-existence rules use + * (`page-field-unknown`, `form-field-unknown`, `semantic-role-field-unknown` + * are all warnings). Those describe a consumer that SKIPS an unknown name and + * renders the rest; this one describes a declaration that either selects the + * wrong set or refuses the request outright. It is the same call + * `validate-flow-template-paths` makes for a filter-position token: gating when + * the miss widens the query rather than shrinking the page. + * + * ── What is checked, and what is deliberately not ──────────────────────── + * + * Existence only. A field that exists but is an odd search target (a `json` + * column, say) is NOT flagged: an explicit `searchableFields` is authoritative + * — the engine scans exactly what it names — so declaring one is a choice, not + * a mistake. Only a name resolving to no field at all is drift. + * + * Three skips keep false positives near zero (ADR-0072 D1 — one dead finding + * and authors stop trusting the linter): + * + * 1. An object this stack does not define. It may come from another package, + * and a field map we cannot see cannot be judged (the same skip the + * page/flow/widget rules take). + * 2. An object that declares no field map at all — external objects and + * datasource-introspected schemas whose columns are resolved at runtime. + * 3. Registry-injected system columns, which are searchable at runtime but + * never appear in authored `fields`. Derived from the spec's own + * declarations rather than hand-copied: this package already carries five + * slightly-different copies of that list (#3786's lesson, unlearned). + * + * Dotted paths are NOT skipped here, unlike every sibling rule. Elsewhere + * `owner_id.name` is left alone because the query engine resolves the traversal; + * search does not — `resolveSearchFields` matches the field map by exact string, + * so a dotted entry is dropped exactly like a typo. Skipping it would exempt the + * one wrong spelling most likely to be borrowed from `select`/`sort`. + */ + +import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data'; +import { SystemFieldName } from '@objectstack/spec/system'; + +export const SEARCHABLE_FIELD_UNKNOWN = 'searchable-field-unknown'; + +export type SearchableFieldSeverity = 'error' | 'warning'; + +export interface SearchableFieldFinding { + /** Always `error` — a stale entry narrows, widens or refuses the search (see module note). */ + severity: SearchableFieldSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `object "crm_lead"`. */ + where: string; + /** Config path, e.g. `objects[0].searchableFields[2]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** + * Registry-injected columns that are addressable at runtime without being + * authored in `fields`. DERIVED from the spec's two declarations — + * `FIELD_GROUP_SYSTEM_FIELDS` (audit provenance + tenant + soft-delete) and + * `SystemFieldName` (the protocol-level ids) — so it cannot drift from them the + * way five hand-copied variants in this package already have. + */ +const SYSTEM_FIELDS: ReadonlySet = new Set([ + ...FIELD_GROUP_SYSTEM_FIELDS, + ...Object.values(SystemFieldName), +]); + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +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; +} + +/** + * The declared field NAMES of an object, or `null` when the object declares no + * readable field map (external / introspected — nothing to judge against). + * Reads both shapes: the name-keyed map and the legacy array of `{ name }`. + */ +function declaredFieldNames(obj: AnyRec): Set | null { + const fields = obj.fields; + if (!fields || typeof fields !== 'object') return null; + const names = new Set(); + for (const f of asArray(fields)) { + const n = strName(f.name); + if (n) names.add(n); + } + return names.size > 0 ? names : null; +} + +/** Levenshtein-bounded "did you mean?" over the object's own field names. */ +function suggest(target: string, known: Iterable): string { + let best: string | undefined; + let bestScore = Infinity; + for (const candidate of known) { + const d = distance(target, candidate); + if (d < bestScore) { + bestScore = d; + best = candidate; + } + } + const limit = Math.max(2, Math.floor(target.length / 3)); + return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; +} + +function distance(a: string, b: string): number { + const m = a.length; + const n = b.length; + if (m === 0) return n; + if (n === 0) return m; + let prev = Array.from({ length: n + 1 }, (_, j) => j); + for (let i = 1; i <= m; i++) { + const curr = [i, ...new Array(n).fill(0)]; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); + } + prev = curr; + } + return prev[n]; +} + +/** + * Validate every `searchableFields` declaration in the stack — the object's own + * (the canonical set, ADR-0061) and the list views that narrow it. Returns + * findings (empty = clean). + */ +export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[] { + const findings: SearchableFieldFinding[] = []; + if (!isRec(stack)) return findings; + + // object name → declared field names. `null` marks an object with no readable + // field map, so "declared nothing" stays distinguishable from "not in stack". + const objects = asArray(stack.objects); + const fieldsByObject = new Map | null>(); + for (const obj of objects) { + const name = strName(obj.name); + if (name) fieldsByObject.set(name, declaredFieldNames(obj)); + } + + /** + * Check one `searchableFields` array against `objectName`'s field map. + * `subject` names the declaration for the message, since an object's own + * set and a view's narrowing of it are fixed differently. + */ + const check = ( + declared: unknown, + objectName: string | undefined, + where: string, + path: string, + subject: string, + ) => { + if (!Array.isArray(declared) || declared.length === 0) return; + if (!objectName) return; // nothing to resolve against + if (!fieldsByObject.has(objectName)) return; // ① object from another package + const known = fieldsByObject.get(objectName); + if (!known) return; // ② external / introspected — no authored field map + + for (let i = 0; i < declared.length; i++) { + const entry = declared[i]; + // Pre-parse input may carry junk here; a non-string is a SHAPE error the + // schema owns, not a dangling reference. + const name = strName(entry); + if (!name) continue; + if (known.has(name) || SYSTEM_FIELDS.has(name)) continue; // ③ system column + + const dotted = name.includes('.'); + findings.push({ + severity: 'error', + rule: SEARCHABLE_FIELD_UNKNOWN, + where, + path: `${path}[${i}]`, + message: + `${subject} entry "${name}" is not a field on object "${objectName}". ` + + `The declaration is stale: searching it can never match, and the engine ` + + `silently drops it — leaving a narrower search than declared, or the ` + + `auto-default set once every entry is dropped.` + + (dotted ? '' : suggest(name, known)), + hint: + (dotted + ? `'search' scans this object's own columns, so a related record's ` + + `column cannot be a search target — expand the relation and search ` + + `the related object, or copy the value onto a formula field here. ` + : `Fix the name, or add "${name}" to ${objectName}.fields. `) + + `Clients echo this declaration verbatim as the '$searchFields' ` + + `override, so a stale entry becomes a 400 INVALID_FIELD on list ` + + `search (#4254), not just a quietly narrowed one.` + + (known.size > 0 ? ` Object fields: ${[...known].sort().join(', ')}.` : ''), + }); + } + }; + + // ── The object's own canonical set, and its built-in named list views ── + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!isRec(obj)) continue; + const objName = strName(obj.name); + const label = objName ? `object "${objName}"` : `objects[${oi}]`; + + check( + obj.searchableFields, + objName, + label, + `objects[${oi}].searchableFields`, + 'searchableFields', + ); + + if (isRec(obj.listViews)) { + for (const [key, lv] of Object.entries(obj.listViews)) { + if (!isRec(lv)) continue; + check( + lv.searchableFields, + // A built-in list view belongs to its object; an inline `data.object` + // may still retarget it (ADR-0047 allows the explicit binding). + listViewObject(lv) ?? objName, + `${label} › listViews.${key}`, + `objects[${oi}].listViews.${key}.searchableFields`, + 'list-view searchableFields', + ); + } + } + } + + // ── `defineView` aggregates: the default `list` + named `listViews` ── + const views = asArray(stack.views); + for (let vi = 0; vi < views.length; vi++) { + const view = views[vi]; + if (!isRec(view)) continue; + const viewLabel = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; + // The aggregate's own binding is the fallback for a list view that declares + // none — the same resolution order `validate-list-view-mode` reads. + const viewObject = strName(view.objectName) ?? strName(view.object); + + if (isRec(view.list)) { + check( + view.list.searchableFields, + listViewObject(view.list) ?? viewObject, + `view "${viewLabel}" › list`, + `views[${vi}].list.searchableFields`, + 'list-view searchableFields', + ); + } + + if (isRec(view.listViews)) { + for (const [key, lv] of Object.entries(view.listViews)) { + if (!isRec(lv)) continue; + check( + lv.searchableFields, + listViewObject(lv) ?? viewObject, + `view "${viewLabel}" › listViews.${key}`, + `views[${vi}].listViews.${key}.searchableFields`, + 'list-view searchableFields', + ); + } + } + } + + return findings; +} + +/** A list view's own object binding: `data: { provider: 'object', object }`. */ +function listViewObject(listView: AnyRec): string | undefined { + const data = listView.data; + return isRec(data) ? strName(data.object) : undefined; +}