@@ -169,74 +169,114 @@ function distance(a: string, b: string): number {
169169 return prev [ n ] ;
170170}
171171
172+ /**
173+ * object name → declared field names. `null` marks an object with no readable
174+ * field map, so "declared nothing" stays distinguishable from "not in stack".
175+ * Exported alongside `checkSearchableFieldList` so every surface that authors
176+ * a searchable set resolves against the identical index (#4329).
177+ */
178+ export function indexObjectSearchTargets (
179+ stack : Record < string , unknown > ,
180+ ) : Map < string , Set < string > | null > {
181+ const fieldsByObject = new Map < string , Set < string > | null > ( ) ;
182+ if ( ! isRec ( stack ) ) return fieldsByObject ;
183+ for ( const obj of asArray ( stack . objects ) ) {
184+ const name = strName ( obj . name ) ;
185+ if ( name ) fieldsByObject . set ( name , declaredFieldNames ( obj ) ) ;
186+ }
187+ return fieldsByObject ;
188+ }
189+
190+ /**
191+ * Check one `searchableFields` array against the field map `fieldsByObject`
192+ * holds for `objectName` — the shared core behind every surface that authors a
193+ * searchable set: the object/list-view metadata walked by
194+ * `validateSearchableFields` below, and the react page surface
195+ * (`<ListView searchableFields={…}>`, `validate-react-page-props`), which
196+ * reuses it so the two surfaces agree on what counts as a field — same three
197+ * skips, same dotted-path strictness (#4329).
198+ *
199+ * `subject` names the declaration for the message, since an object's own set
200+ * and a view's narrowing of it are fixed differently; the entry index is
201+ * appended to `path` so the author can go straight to the stale name.
202+ */
203+ export function checkSearchableFieldList (
204+ declared : unknown ,
205+ objectName : string | undefined ,
206+ fieldsByObject : ReadonlyMap < string , Set < string > | null > ,
207+ where : string ,
208+ path : string ,
209+ subject : string ,
210+ ) : SearchableFieldFinding [ ] {
211+ const findings : SearchableFieldFinding [ ] = [ ] ;
212+ if ( ! Array . isArray ( declared ) || declared . length === 0 ) return findings ;
213+ if ( ! objectName ) return findings ; // nothing to resolve against
214+ if ( ! fieldsByObject . has ( objectName ) ) return findings ; // ① object from another package
215+ const known = fieldsByObject . get ( objectName ) ;
216+ if ( ! known ) return findings ; // ② external / introspected — no authored field map
217+
218+ for ( let i = 0 ; i < declared . length ; i ++ ) {
219+ const entry = declared [ i ] ;
220+ // Pre-parse input may carry junk here; a non-string is a SHAPE error the
221+ // schema owns, not a dangling reference.
222+ const name = strName ( entry ) ;
223+ if ( ! name ) continue ;
224+ if ( known . has ( name ) || SYSTEM_FIELDS . has ( name ) ) continue ; // ③ system column
225+
226+ const dotted = name . includes ( '.' ) ;
227+ findings . push ( {
228+ severity : 'error' ,
229+ rule : SEARCHABLE_FIELD_UNKNOWN ,
230+ where,
231+ path : `${ path } [${ i } ]` ,
232+ message :
233+ `${ subject } entry "${ name } " is not a field on object "${ objectName } ". ` +
234+ `The declaration is stale: searching it can never match, and the engine ` +
235+ `silently drops it — leaving a narrower search than declared, or the ` +
236+ `auto-default set once every entry is dropped.` +
237+ ( dotted ? '' : suggest ( name , known ) ) ,
238+ hint :
239+ ( dotted
240+ ? `'search' scans this object's own columns, so a related record's ` +
241+ `column cannot be a search target — expand the relation and search ` +
242+ `the related object, or copy the value onto a formula field here. `
243+ : `Fix the name, or add "${ name } " to ${ objectName } .fields. ` ) +
244+ `Clients echo this declaration verbatim as the '$searchFields' ` +
245+ `override, so a stale entry becomes a 400 INVALID_FIELD on list ` +
246+ `search (#4254), not just a quietly narrowed one.` +
247+ ( known . size > 0 ? ` Object fields: ${ [ ...known ] . sort ( ) . join ( ', ' ) } .` : '' ) ,
248+ } ) ;
249+ }
250+ return findings ;
251+ }
252+
172253/**
173254 * Validate every `searchableFields` declaration in the stack — the object's own
174255 * (the canonical set, ADR-0061) and the list views that narrow it. Returns
175256 * findings (empty = clean).
257+ *
258+ * The react page surface (`<ListView searchableFields={…}>`) is deliberately
259+ * NOT walked here: its declaration lives inside JSX source, and
260+ * `validate-react-page-props` — the gate that already parses that source —
261+ * runs the same `checkSearchableFieldList` core on it (#4329).
176262 */
177263export function validateSearchableFields ( stack : AnyRec ) : SearchableFieldFinding [ ] {
178264 const findings : SearchableFieldFinding [ ] = [ ] ;
179265 if ( ! isRec ( stack ) ) return findings ;
180266
181- // object name → declared field names. `null` marks an object with no readable
182- // field map, so "declared nothing" stays distinguishable from "not in stack".
183267 const objects = asArray ( stack . objects ) ;
184- const fieldsByObject = new Map < string , Set < string > | null > ( ) ;
185- for ( const obj of objects ) {
186- const name = strName ( obj . name ) ;
187- if ( name ) fieldsByObject . set ( name , declaredFieldNames ( obj ) ) ;
188- }
268+ const fieldsByObject = indexObjectSearchTargets ( stack ) ;
189269
190- /**
191- * Check one `searchableFields` array against `objectName`'s field map.
192- * `subject` names the declaration for the message, since an object's own
193- * set and a view's narrowing of it are fixed differently.
194- */
195270 const check = (
196271 declared : unknown ,
197272 objectName : string | undefined ,
198273 where : string ,
199274 path : string ,
200275 subject : string ,
201276 ) => {
202- if ( ! Array . isArray ( declared ) || declared . length === 0 ) return ;
203- if ( ! objectName ) return ; // nothing to resolve against
204- if ( ! fieldsByObject . has ( objectName ) ) return ; // ① object from another package
205- const known = fieldsByObject . get ( objectName ) ;
206- if ( ! known ) return ; // ② external / introspected — no authored field map
207-
208- for ( let i = 0 ; i < declared . length ; i ++ ) {
209- const entry = declared [ i ] ;
210- // Pre-parse input may carry junk here; a non-string is a SHAPE error the
211- // schema owns, not a dangling reference.
212- const name = strName ( entry ) ;
213- if ( ! name ) continue ;
214- if ( known . has ( name ) || SYSTEM_FIELDS . has ( name ) ) continue ; // ③ system column
215-
216- const dotted = name . includes ( '.' ) ;
217- findings . push ( {
218- severity : 'error' ,
219- rule : SEARCHABLE_FIELD_UNKNOWN ,
220- where,
221- path : `${ path } [${ i } ]` ,
222- message :
223- `${ subject } entry "${ name } " is not a field on object "${ objectName } ". ` +
224- `The declaration is stale: searching it can never match, and the engine ` +
225- `silently drops it — leaving a narrower search than declared, or the ` +
226- `auto-default set once every entry is dropped.` +
227- ( dotted ? '' : suggest ( name , known ) ) ,
228- hint :
229- ( dotted
230- ? `'search' scans this object's own columns, so a related record's ` +
231- `column cannot be a search target — expand the relation and search ` +
232- `the related object, or copy the value onto a formula field here. `
233- : `Fix the name, or add "${ name } " to ${ objectName } .fields. ` ) +
234- `Clients echo this declaration verbatim as the '$searchFields' ` +
235- `override, so a stale entry becomes a 400 INVALID_FIELD on list ` +
236- `search (#4254), not just a quietly narrowed one.` +
237- ( known . size > 0 ? ` Object fields: ${ [ ...known ] . sort ( ) . join ( ', ' ) } .` : '' ) ,
238- } ) ;
239- }
277+ findings . push (
278+ ...checkSearchableFieldList ( declared , objectName , fieldsByObject , where , path , subject ) ,
279+ ) ;
240280 } ;
241281
242282 // ── The object's own canonical set, and its built-in named list views ──
0 commit comments