From c1c954b08675a3575f61c85d313b9be87a5d8b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Mon, 3 Aug 2026 05:43:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(lint):=20=E8=A7=86=E5=9B=BE=20searchableFi?= =?UTF-8?q?elds=20=E6=8C=89=E8=BF=90=E8=A1=8C=E6=97=B6=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E5=A5=97=E5=88=A4=E5=AE=9A=E5=81=9A=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20=E2=80=94=E2=80=94=20lookup=20=E7=AC=94?= =?UTF-8?q?=E8=AF=AF=E5=9C=A8=20validate=20=E6=9C=9F=E5=B0=B1=E6=8A=A5?= =?UTF-8?q?=E9=94=99,=E8=80=8C=E9=9D=9E=E8=BF=90=E8=A1=8C=E6=97=B6=20400?= =?UTF-8?q?=20(#4830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视图级 searchableFields 会被客户端逐字回显为 $searchFields,#4254 入口闸按 resolveSearchFieldResolution 判定后整条查询 400 INVALID_FIELD;此前 compile/validate 只查存在性,类型笔误全绿放行。 新规则 searchable-field-unsearchable(error):对象内建 listViews、defineView 的 list/listViews、react 四个 narrowing 面,直接消费运行时同一个 resolveSearchFieldResolution(@objectstack/spec/data),不复制类型清单, declared = enforced。对象自身 canonical 集合维持只查存在性(运行时按存在过滤、 不按类型过滤,构建期拒绝会误伤运行时接受的元数据,ADR-0072 D1)。 Co-Authored-By: Claude Fable 5 --- .../lint-searchable-fields-type-validation.md | 31 ++ packages/lint/src/index.ts | 2 + .../src/validate-react-page-props.test.ts | 22 +- .../lint/src/validate-react-page-props.ts | 13 +- .../src/validate-searchable-fields.test.ts | 168 +++++++++++ .../lint/src/validate-searchable-fields.ts | 285 +++++++++++++++--- 6 files changed, 466 insertions(+), 55 deletions(-) create mode 100644 .changeset/lint-searchable-fields-type-validation.md diff --git a/.changeset/lint-searchable-fields-type-validation.md b/.changeset/lint-searchable-fields-type-validation.md new file mode 100644 index 0000000000..150feb3552 --- /dev/null +++ b/.changeset/lint-searchable-fields-type-validation.md @@ -0,0 +1,31 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830) + +视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而 +REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定 +该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400 +(`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查 +字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。 + +新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级 +narrowing(对象内建 `listViews`、`defineView` 的 `list`/`listViews`、react 页面的 +``)按**运行时同一个函数**(`resolveSearchFieldResolution`, +非复制的类型清单,杜绝再度漂移)判定 declared = enforced: + +- 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等 + auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本 + 对象 text/formula 字段」的处方; +- 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、 + 不能放宽,ADR-0061); +- 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按 + 存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据 + (ADR-0072 D1); +- 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报 + 不可误报)。 + +内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包 +barrel)签名有变:索引值从 `Set | null` 变为 `ObjectSearchTarget | null`,并新增 +可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。 diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d9ab277512..1ce022c37d 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -225,10 +225,12 @@ export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-t export { validateSearchableFields, SEARCHABLE_FIELD_UNKNOWN, + SEARCHABLE_FIELD_UNSEARCHABLE, } from './validate-searchable-fields.js'; export type { SearchableFieldFinding, SearchableFieldSeverity, + SearchableFieldRole, } from './validate-searchable-fields.js'; export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js'; diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 31ba8fa31a..8efcca9f90 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -8,7 +8,10 @@ import { REACT_BLOCK_NEEDS_RECORD_CONTEXT, type ReactPropFinding as PropFinding, } from './validate-react-page-props.js'; -import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; +import { + SEARCHABLE_FIELD_UNKNOWN, + SEARCHABLE_FIELD_UNSEARCHABLE, +} from './validate-searchable-fields.js'; import { PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] }); @@ -303,11 +306,26 @@ describe('validateReactPageProps — searchableFields (#4329)', () => it('accepts registry-injected system columns absent from authored fields', () => { const f = validateReactPageProps( - listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at', 'owner_id']}`)), + listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at']}`)), ); expect(f).toEqual([]); }); + it('flags an authored lookup — the runtime refuses the echoed override (#4830)', () => { + // is echoed verbatim as `$searchFields`, and + // the #4254 ingress gate rejects a lookup with 400 INVALID_FIELD — the + // whole toolbar search, for every role. Same judgment as the metadata + // list-view surface, by the shared core. + const f = validateReactPageProps( + listPage(list(`objectName="crm_account" searchableFields={['name', 'owner_id']}`)), + ); + expect(f).toHaveLength(1); + expect(f[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(f[0].severity).toBe('error'); + expect(f[0].path).toBe('pages[0].source › searchableFields[1]'); + expect(f[0].message).toContain('400 INVALID_FIELD'); + }); + it('flags a dotted path — search cannot resolve the traversal', () => { const f = validateReactPageProps( listPage(list(`objectName="crm_account" searchableFields={['owner_id.name']}`)), diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 0489e63c72..602d34e328 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -869,11 +869,14 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { checkObjectChart({ values, where, path }, objectFields, findings); } // names fields on the bound object — the - // react-surface twin of `searchable-field-unknown` (#4329). It runs - // the metadata rule's own core, so the skips (cross-package object, - // no authored field map, system columns) and the dotted-path - // strictness match by construction. A non-static value — either - // attribute — bails inside the checker: unresolvable is not wrong. + // react-surface twin of `searchable-field-unknown` (#4329) and, as a + // view-level narrowing echoed to the runtime as `$searchFields`, of + // `searchable-field-unsearchable` too (#4830, the checker's default + // role). It runs the metadata rule's own core, so the skips + // (cross-package object, no authored field map, system columns) and + // the dotted-path strictness match by construction. A non-static + // value — either attribute — bails inside the checker: unresolvable + // is not wrong. if (tag === 'ListView' && !hasSpread) { findings.push( ...checkSearchableFieldList( diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts index e8618621d0..3ad01ad155 100644 --- a/packages/lint/src/validate-searchable-fields.test.ts +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { validateSearchableFields, SEARCHABLE_FIELD_UNKNOWN, + SEARCHABLE_FIELD_UNSEARCHABLE, } from './validate-searchable-fields.js'; /** @@ -287,6 +288,173 @@ describe('validateSearchableFields — list views that narrow the set', () => { expect(findings).toEqual([]); }); + it('flags a lookup entry the runtime would refuse — the #4830 defect', () => { + // The issue's repro verbatim: `searchableFields: ['name', '']` on a + // view, validate all green, first keystroke in the toolbar search → the + // whole query 400s (INVALID_FIELD) for every role. The runtime judgment + // is `resolveSearchFieldResolution` (@objectstack/spec/data); this rule + // consults the same function, so declared = enforced. + const findings = validateSearchableFields({ + objects: [ + { + name: 'ehr_task', + fields: { + name: { type: 'text' }, + project_id: { type: 'lookup', reference: 'ehr_project' }, + }, + listViews: { all: { type: 'grid', searchableFields: ['name', 'project_id'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[1]'); + expect(findings[0].message).toContain("type 'lookup'"); + expect(findings[0].message).toContain('400 INVALID_FIELD'); + // The lookup-specific prescription: search cannot cross objects, so the + // related record's title must be mirrored onto a local text/formula field. + expect(findings[0].hint).toContain('mirror'); + }); + + it('flags a real field outside the object\'s declared searchableFields', () => { + // Runtime parity, declared branch: the object declares the canonical set, + // and the #4254 gate refuses a `$searchFields` entry outside it even when + // the field exists and is text-like. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { + name: { type: 'text' }, + billing_email: { type: 'email' }, + notes: { type: 'textarea' }, + }, + searchableFields: ['name', 'billing_email'], + listViews: { all: { type: 'grid', searchableFields: ['notes'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].message).toContain('name, billing_email'); + expect(findings[0].hint).toContain('crm_account.searchableFields'); + }); + + it('passes a view entry of odd type once the object declares it searchable', () => { + // The runtime's declared branch filters by EXISTENCE, never by type: a + // json/lookup column declared on the OBJECT is honored by the engine and + // admitted by the gate, so the view echoing it must stay green — flagging + // it would reject metadata the runtime accepts. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, payload: { type: 'json' } }, + searchableFields: ['name', 'payload'], + listViews: { all: { type: 'grid', searchableFields: ['payload'] } }, + }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('flags a hidden field in a view narrowing (auto-default excludes it)', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, secret_note: { type: 'text', hidden: true } }, + listViews: { all: { type: 'grid', searchableFields: ['secret_note'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].message).toContain('hidden'); + }); + + it('checks defineView list and named listViews the same way', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } }, + }, + ], + views: [ + { + objectName: 'crm_account', + list: { type: 'grid', searchableFields: ['owner_ref'] }, + listViews: { active: { type: 'grid', searchableFields: ['owner_ref'] } }, + }, + ], + }); + + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].list.searchableFields[0]'], + [SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].listViews.active.searchableFields[0]'], + ]); + }); + + it('keeps runtime parity when the object declares system columns searchable', () => { + // The runtime resolves the declared branch against the REGISTRY map, so + // `searchableFields: ['created_at']` is a non-empty declared set there — + // NOT a fall-through to the auto-default. A view entry outside that set + // must be flagged the way the gate refuses it, even though `created_at` + // is invisible to the authored field map. + const findings = validateSearchableFields({ + objects: [ + { + name: 'audit_log', + fields: { name: { type: 'text' }, detail: { type: 'textarea' } }, + searchableFields: ['created_at'], + listViews: { all: { type: 'grid', searchableFields: ['detail'] } }, + }, + ], + }); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].message).toContain('created_at'); + }); + + it('leaves a system column in a view narrowing alone (registry meta invisible)', () => { + // `created_at` in a narrowing would be refused by the runtime, but its + // registry-side metadata is not visible to the linter — a judgment here + // risks the false positive ADR-0072 D1 forbids, so it is a documented + // missed finding instead. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' } }, + listViews: { all: { type: 'grid', searchableFields: ['name', 'created_at'] } }, + }, + ], + }); + + expect(findings).toEqual([]); + }); + + it('does not type-check the object\'s own canonical set (runtime honors it)', () => { + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } }, + searchableFields: ['name', 'owner_ref'], + }, + ], + }); + + 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. diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index 13f7d4a928..32afd47a66 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -2,7 +2,7 @@ /** * [ADR-0061 — searchable set] `searchableFields` entries must name a field the - * object actually has. + * object actually has — and, on a list view, one the runtime will agree to scan. * * `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 @@ -41,25 +41,48 @@ * `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 ──────────────────────── + * ── What is checked ────────────────────────────────────────────────────── * - * 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. + * 1. EXISTENCE, on every surface (`searchable-field-unknown`): an entry that + * resolves to no field at all is drift, whatever declared it. * - * Three skips keep false positives near zero (ADR-0072 D1 — one dead finding - * and authors stop trusting the linter): + * 2. RUNTIME ADMISSIBILITY, on view-level narrowings only + * (`searchable-field-unsearchable`, #4830): a list view's + * `searchableFields` is echoed verbatim by clients as the `$searchFields` + * override, and the #4254 ingress gate + * (`assertSearchFieldsAreSearchable`, `@objectstack/metadata-protocol`) + * refuses any entry outside the object's server-resolved allowed set — so + * ONE lookup-typed entry 400s EVERY toolbar search on that list, for every + * role, and until #4830 `compile`/`validate` passed it in silence. The + * allowed set here is computed by the very function the runtime gate and + * the engine consult — {@link resolveSearchFieldResolution} + * (`@objectstack/spec/data`) — never by a second copy of its type list, so + * linter, gate and engine cannot drift apart (the same one-source move + * #4254 made between gate and engine). + * + * The OBJECT's own `searchableFields` stays existence-only: the runtime's + * declared branch filters by existence, never by type, so a json or lookup + * column declared THERE is a choice the engine executes (a `$contains` over + * the raw column), not a 400. Flagging it would reject metadata the runtime + * accepts — the false finding that makes authors stop trusting the linter + * (ADR-0072 D1). + * + * Three skips keep false positives near zero (ADR-0072 D1): * * 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` — the package-shared `SYSTEM_FIELDS` + * 3. Registry-injected system columns, which exist at runtime but never + * appear in authored `fields` — the package-shared `SYSTEM_FIELDS` * (`system-fields.ts`), derived from the spec's own declarations rather - * than hand-copied (#4330). + * than hand-copied (#4330). The admissibility check also skips them: + * their runtime field metadata (type, hidden) is registry-owned and not + * visible here, and judging a column we cannot see risks the false + * positive this list exists to avoid. (Cost asymmetry: a system column + * the runtime would refuse — `created_by` in a view's narrowing — is a + * missed finding, not a wrong one.) * * Dotted paths are NOT skipped here, unlike every sibling rule. Elsewhere * `owner_id.name` is left alone because the query engine resolves the traversal; @@ -68,9 +91,17 @@ * one wrong spelling most likely to be borrowed from `select`/`sort`. */ +import { + resolveSearchFieldResolution, + SEARCHABLE_TEXTUAL_TYPES, + SEARCHABLE_ENUM_TYPES, + SEARCH_AUTO_EXCLUDED_FIELDS, + type SearchFieldMeta, +} from '@objectstack/spec/data'; import { SYSTEM_FIELDS } from './system-fields.js'; export const SEARCHABLE_FIELD_UNKNOWN = 'searchable-field-unknown'; +export const SEARCHABLE_FIELD_UNSEARCHABLE = 'searchable-field-unsearchable'; export type SearchableFieldSeverity = 'error' | 'warning'; @@ -89,6 +120,19 @@ export interface SearchableFieldFinding { hint: string; } +/** + * Which runtime judgment applies to the declaration being checked: + * + * - `'canonical'` — the object's own `searchableFields`. The runtime honors + * any entry that exists (existence-filtered, never type-filtered), so only + * existence is checked. + * - `'narrowing'` — a list view's `searchableFields` (metadata or react + * surface). Clients echo it as the `$searchFields` override, which the + * #4254 ingress gate intersects with the object's allowed set — entries the + * runtime would refuse are flagged (#4830). + */ +export type SearchableFieldRole = 'canonical' | 'narrowing'; + type AnyRec = Record; /** Coerce a collection (array or name-keyed map) to an array of records. */ @@ -109,19 +153,86 @@ function strName(v: unknown): string | 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 }`. + * The slice of an object the search checks resolve against: the authored + * field map (existence + the `type`/`hidden` meta search resolution reads), + * plus the object's own declarations the runtime's resolution consumes. + * `null` when the object declares no readable field map (external / + * introspected — nothing to judge against). */ -function declaredFieldNames(obj: AnyRec): Set | null { +export interface ObjectSearchTarget { + /** Authored field names, for the existence check. */ + names: Set; + /** name → the metadata slice `resolveSearchFieldResolution` reads. */ + fields: Record; + /** The object's own `searchableFields`, when declared as an array. */ + searchableFields?: string[]; + /** `nameField` / `displayNameField` — ordering only, passed for parity. */ + displayField?: string; +} + +/** + * Read both field-map shapes (name-keyed map, legacy array of `{ name }`) into + * the target slice, or `null` when the object declares no readable field map. + */ +function declaredFieldTarget(obj: AnyRec): ObjectSearchTarget | null { const fields = obj.fields; if (!fields || typeof fields !== 'object') return null; const names = new Set(); + const metas: Record = {}; for (const f of asArray(fields)) { const n = strName(f.name); - if (n) names.add(n); + if (!n) continue; + names.add(n); + metas[n] = { + type: typeof f.type === 'string' ? f.type : undefined, + hidden: f.hidden === true, + }; + } + if (names.size === 0) return null; + const searchableFields = Array.isArray(obj.searchableFields) + ? obj.searchableFields.filter((e): e is string => typeof e === 'string') + : undefined; + return { + names, + fields: metas, + searchableFields, + displayField: strName(obj.nameField) ?? strName(obj.displayNameField), + }; +} + +/** + * The object's ALLOWED search-field set, judged by the SAME function the + * runtime ingress gate and the engine consult (`resolveSearchFieldResolution`, + * `@objectstack/spec/data`) — the one-source-of-truth requirement of #4830. + * + * One seam papered over deliberately: the runtime resolves the declared branch + * against the REGISTRY field map (authored + injected system columns), while + * this rule only sees authored `fields`. A declared entry naming a system + * column (`searchableFields: ['name', 'created_at']`) must therefore survive + * the resolution's existence filter exactly as it does at runtime, so such + * entries get a stub meta ({}). The stub cannot leak into the auto-default: + * `autoDefaultFields` requires a readable searchable `type`, which a stub + * never has. + */ +function resolveAllowedSet(target: ObjectSearchTarget): { + allowed: Set; + source: 'declared' | 'auto'; + declaredList: string[]; +} { + let fields = target.fields; + const systemDeclared = (target.searchableFields ?? []).filter( + (f) => !target.names.has(f) && SYSTEM_FIELDS.has(f), + ); + if (systemDeclared.length > 0) { + fields = { ...fields }; + for (const f of systemDeclared) fields[f] = {}; } - return names.size > 0 ? names : null; + const { allowed, source } = resolveSearchFieldResolution({ + fields, + searchableFields: target.searchableFields, + displayField: target.displayField, + }); + return { allowed: new Set(allowed), source, declaredList: allowed }; } /** Levenshtein-bounded "did you mean?" over the object's own field names. */ @@ -157,50 +268,57 @@ function distance(a: string, b: string): number { } /** - * object name → declared field names. `null` marks an object with no readable - * field map, so "declared nothing" stays distinguishable from "not in stack". - * Exported alongside `checkSearchableFieldList` so every surface that authors - * a searchable set resolves against the identical index (#4329). + * object name → the search-target slice. `null` marks an object with no + * readable field map, so "declared nothing" stays distinguishable from "not in + * stack". Exported alongside `checkSearchableFieldList` so every surface that + * authors a searchable set resolves against the identical index (#4329). */ export function indexObjectSearchTargets( stack: Record, -): Map | null> { - const fieldsByObject = new Map | null>(); +): Map { + const fieldsByObject = new Map(); if (!isRec(stack)) return fieldsByObject; for (const obj of asArray(stack.objects)) { const name = strName(obj.name); - if (name) fieldsByObject.set(name, declaredFieldNames(obj)); + if (name) fieldsByObject.set(name, declaredFieldTarget(obj)); } return fieldsByObject; } /** - * Check one `searchableFields` array against the field map `fieldsByObject` + * Check one `searchableFields` array against the target `fieldsByObject` * holds for `objectName` — the shared core behind every surface that authors a * searchable set: the object/list-view metadata walked by * `validateSearchableFields` below, and the react page surface * (``, `validate-react-page-props`), which * reuses it so the two surfaces agree on what counts as a field — same three - * skips, same dotted-path strictness (#4329). + * skips, same dotted-path strictness (#4329), same runtime-admissibility + * judgment for a view-level narrowing (#4830). * * `subject` names the declaration for the message, since an object's own set * and a view's narrowing of it are fixed differently; the entry index is - * appended to `path` so the author can go straight to the stale name. + * appended to `path` so the author can go straight to the stale name. `role` + * picks the runtime judgment to mirror — see {@link SearchableFieldRole}; + * every list-view surface is a `'narrowing'`, which is the default. */ export function checkSearchableFieldList( declared: unknown, objectName: string | undefined, - fieldsByObject: ReadonlyMap | null>, + fieldsByObject: ReadonlyMap, where: string, path: string, subject: string, + role: SearchableFieldRole = 'narrowing', ): SearchableFieldFinding[] { const findings: SearchableFieldFinding[] = []; if (!Array.isArray(declared) || declared.length === 0) return findings; if (!objectName) return findings; // nothing to resolve against if (!fieldsByObject.has(objectName)) return findings; // ① object from another package - const known = fieldsByObject.get(objectName); - if (!known) return findings; // ② external / introspected — no authored field map + const target = fieldsByObject.get(objectName); + if (!target) return findings; // ② external / introspected — no authored field map + + const known = target.names; + const resolution = role === 'narrowing' ? resolveAllowedSet(target) : undefined; for (let i = 0; i < declared.length; i++) { const entry = declared[i]; @@ -208,30 +326,96 @@ export function checkSearchableFieldList( // 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('.'); + if (!known.has(name) && !SYSTEM_FIELDS.has(name)) { + 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(', ')}.` : ''), + }); + continue; + } + + // ── Runtime admissibility (#4830) — view-level narrowings only ── + if (!resolution || resolution.allowed.has(name)) continue; + // ③ System column outside the allowed set: its runtime metadata is + // registry-owned and invisible here — skip rather than risk the false + // positive (module note). + if (!known.has(name)) continue; + const meta = target.fields[name]; + + if (resolution.source === 'declared') { + findings.push({ + severity: 'error', + rule: SEARCHABLE_FIELD_UNSEARCHABLE, + where, + path: `${path}[${i}]`, + message: + `${subject} entry "${name}" is outside object "${objectName}"'s declared ` + + `searchableFields (${resolution.declaredList.join(', ')}) — the set 'search' ` + + `scans. Clients echo this declaration verbatim as the '$searchFields' ` + + `override, and the runtime refuses an entry outside the allowed set: every ` + + `toolbar search on this list returns 400 INVALID_FIELD (#4254).`, + hint: + `Add "${name}" to ${objectName}.searchableFields, or drop it from this ` + + `view — a view narrows the object's searchable set, never widens it ` + + `(ADR-0061).`, + }); + continue; + } + + // Auto-default source. Mirror the gate's own "why" — excluded name, then + // hidden, then type; an unreadable type is unresolvable, not wrong. + const isReference = meta?.type === 'lookup' || meta?.type === 'master_detail'; + let why: string; + if (SEARCH_AUTO_EXCLUDED_FIELDS.has(name)) { + why = 'a system/audit column, which the auto-default set never includes'; + } else if (meta?.hidden) { + why = 'hidden'; + } else if (typeof meta?.type === 'string') { + why = `of type '${meta.type}', which 'search' cannot scan`; + } else { + continue; // no readable type — unresolvable, not wrong (ADR-0072 D1) + } findings.push({ severity: 'error', - rule: SEARCHABLE_FIELD_UNKNOWN, + rule: SEARCHABLE_FIELD_UNSEARCHABLE, 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)), + `${subject} entry "${name}" on object "${objectName}" is ${why}. With no ` + + `'searchableFields' declared on the object, 'search' scans its text-like ` + + `columns (${[...SEARCHABLE_TEXTUAL_TYPES, ...SEARCHABLE_ENUM_TYPES].join(' / ')}). ` + + `Clients echo this declaration verbatim as the '$searchFields' override, and ` + + `the runtime refuses it: every toolbar search on this list returns ` + + `400 INVALID_FIELD (#4254).`, 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(', ')}.` : ''), + (isReference + ? `A ${meta?.type} column stores only the referenced record's id, so it ` + + `cannot be a keyword target — drop "${name}" from this view and, to ` + + `search by the related record's title, mirror it onto a text/formula ` + + `field here and declare that instead. ` + : `Drop "${name}" from this view, or target a text-like field instead. `) + + `Declaring 'searchableFields' on object "${objectName}" chooses the ` + + `searchable set explicitly.`, }); } return findings; @@ -260,9 +444,10 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ where: string, path: string, subject: string, + role: SearchableFieldRole, ) => { findings.push( - ...checkSearchableFieldList(declared, objectName, fieldsByObject, where, path, subject), + ...checkSearchableFieldList(declared, objectName, fieldsByObject, where, path, subject, role), ); }; @@ -279,6 +464,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ label, `objects[${oi}].searchableFields`, 'searchableFields', + 'canonical', ); if (isRec(obj.listViews)) { @@ -292,6 +478,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ `${label} › listViews.${key}`, `objects[${oi}].listViews.${key}.searchableFields`, 'list-view searchableFields', + 'narrowing', ); } } @@ -314,6 +501,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ `view "${viewLabel}" › list`, `views[${vi}].list.searchableFields`, 'list-view searchableFields', + 'narrowing', ); } @@ -326,6 +514,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ `view "${viewLabel}" › listViews.${key}`, `views[${vi}].listViews.${key}.searchableFields`, 'list-view searchableFields', + 'narrowing', ); } }