@@ -15,6 +15,7 @@ import { validateReferenceIntegrity } from './reference-integrity-suite.js';
1515import {
1616 validateListViewFieldRefs ,
1717 LIST_VIEW_FIELD_UNKNOWN ,
18+ LIST_VIEW_FIELD_DOTTED ,
1819 type ListViewFieldRefFinding ,
1920} from './validate-list-view-field-refs.js' ;
2021import { SORT_FIELD_UNKNOWN } from './validate-sortable-fields.js' ;
@@ -39,6 +40,12 @@ const OBJECTS = [
3940 { name : 'cover' , type : 'image' , label : 'Cover' } ,
4041 { name : 'parent' , type : 'lookup' , reference : 'duly_task' , label : 'Parent' } ,
4142 { name : 'owner' , type : 'lookup' , reference : 'duly_person' , label : 'Owner' } ,
43+ // [#14282] The three head shapes the FILTER door treats differently.
44+ // `payload` is the ruled carve-out (`STRUCTURED_JSON_TYPES`, live on
45+ // memory and mongodb); `score` is virtual; `tags` is array-valued.
46+ { name : 'payload' , type : 'json' , label : 'Payload' } ,
47+ { name : 'score' , type : 'formula' , label : 'Score' } ,
48+ { name : 'tags' , type : 'text' , multiple : true , label : 'Tags' } ,
4249 ] ,
4350 } ,
4451 {
@@ -297,31 +304,271 @@ describe('#14107 — the "did you mean" comes from the shared seam', () => {
297304/**
298305 * The recorded dotted-path decision (see the rule's module docblock): the HEAD
299306 * segment is judged and relationship hops are NOT walked, because a list view
300- * compiles no joins and all three runtime doors refuse a dotted reference.
301- * Both halves are pinned — the half that reports, and the half that stays
302- * deliberately silent — so a later "improvement" that starts walking hops has
303- * to delete a test that says why.
307+ * compiles no joins and the runtime doors refuse a dotted reference.
308+ *
309+ * ⚠️ This block used to pin BOTH halves — the half that reports, and a half
310+ * that stayed deliberately silent (`owner.name` and `title.x` in `columns`
311+ * passing clean). #14282 is the card that half was recorded for, and it ruled
312+ * the other way: those two now report, as {@link LIST_VIEW_FIELD_DOTTED}. The
313+ * cases were rewritten rather than deleted, so the pair still reads as one
314+ * decision — what changed is which class each lands in, not whether the rule
315+ * has an opinion. The `#14282` block below carries the new half in full.
304316 */
305- describe ( '#14107 — dotted paths' , ( ) => {
317+ describe ( '#14107 — dotted paths, HEAD-segment resolution ' , ( ) => {
306318 it ( 'a dotted path whose HEAD resolves to nothing is reported' , ( ) => {
307319 const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ { field : 'ownr.name' } ] } ) ) ) ;
308320 expect ( findings ) . toHaveLength ( 1 ) ;
321+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_UNKNOWN ) ;
309322 expect ( findings [ 0 ] . message ) . toContain ( '"ownr"' ) ;
310323 // The author reads back what they typed, not only the segment judged.
311324 expect ( findings [ 0 ] . message ) . toContain ( 'ownr.name' ) ;
312325 expect ( findings [ 0 ] . message ) . toContain ( 'compiles' ) ;
313326 } ) ;
314327
315- it ( 'a dotted path whose head resolves is left to the runtime doors' , ( ) => {
328+ it ( 'hops are still NOT walked — a bad LEAF under a good head is not judged as a leaf' , ( ) => {
329+ // `owner` resolves, `duly_person` has no `nope`. Were hops walked, this
330+ // would be a `field-unknown` on `duly_person`. It is not: the finding is
331+ // the #14282 dotted class, which never mentions the leaf at all.
332+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ { field : 'owner.nope' } ] } ) ) ) ;
333+ expect ( findings ) . toHaveLength ( 1 ) ;
334+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
335+ expect ( findings [ 0 ] . message ) . not . toContain ( 'duly_person' ) ;
336+ } ) ;
337+ } ) ;
338+
339+ /**
340+ * [#14282] The SECOND finding class: a dotted reference at a position whose
341+ * name reaches a query door, where that door refuses it by name.
342+ *
343+ * The scoping is by DOOR, not by position — see the rule's module note. So
344+ * this block pins three things and not one: which positions report, which
345+ * deliberately do not (the measured client-side ones, `gantt.quickFilters`
346+ * first among them), and that the FILTER positions ask the same
347+ * `classifyDottedFilterHead` the runtime door asks, rather than refusing what
348+ * the door serves.
349+ */
350+ describe ( '#14282 — a dotted reference the PROJECTION door refuses' , ( ) => {
351+ it ( 'a dotted `columns[].field` whose head resolves is now reported' , ( ) => {
316352 const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ { field : 'owner.name' } ] } ) ) ) ;
317- expect ( findings ) . toEqual ( [ ] ) ;
353+ expect ( findings ) . toHaveLength ( 1 ) ;
354+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
355+ expect ( findings [ 0 ] . severity ) . toBe ( 'error' ) ;
356+ expect ( findings [ 0 ] . path ) . toBe ( 'views[0].list.columns[0].field' ) ;
357+ expect ( findings [ 0 ] . message ) . toContain ( 'owner.name' ) ;
358+ expect ( findings [ 0 ] . message ) . toContain ( 'assertProjectionHasNoDottedPaths' ) ;
359+ expect ( findings [ 0 ] . hint ) . toContain ( '"owner"' ) ;
360+ } ) ;
361+
362+ it ( 'the bare-string `columns[]` spelling is judged too' , ( ) => {
363+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ 'owner.name' ] } ) ) ) ;
364+ expect ( findings ) . toHaveLength ( 1 ) ;
365+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
366+ expect ( findings [ 0 ] . path ) . toBe ( 'views[0].list.columns[0]' ) ;
318367 } ) ;
319368
320- it ( 'a dotted path through a non-relationship head is also left alone' , ( ) => {
321- // `title` is a text field; `title.x` is refused at query time, not here.
369+ it ( 'the projection door has NO head carve-out, so a scalar head reports too' , ( ) => {
370+ // `title` is a text field. `assertProjectionHasNoDottedPaths` filters on
371+ // `f.includes('.')` alone — the head's type never enters that door.
322372 const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ { field : 'title.x' } ] } ) ) ) ;
373+ expect ( findings ) . toHaveLength ( 1 ) ;
374+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
375+ } ) ;
376+
377+ it ( 'a structured/JSON head is reported at a COLUMN even though the filter door serves it' , ( ) => {
378+ // The #8371 carve-out is the FILTER door's, not the projection door's.
379+ // Getting this wrong in either direction is the whole point of scoping the
380+ // class by door rather than by "a list view compiles no joins".
381+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( { columns : [ { field : 'payload.theme' } ] } ) ) ) ;
382+ expect ( findings ) . toHaveLength ( 1 ) ;
383+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
384+ } ) ;
385+
386+ it ( 'an undotted column is untouched by the new class' , ( ) => {
387+ expect ( validateListViewFieldRefs ( stackWith ( FULL_LIST_VIEW ) ) ) . toEqual ( [ ] ) ;
388+ } ) ;
389+ } ) ;
390+
391+ describe ( '#14282 — a dotted key the FILTER door refuses, and the ones it serves' , ( ) => {
392+ const filterOn = ( field : string ) : AnyRec => ( {
393+ filter : [ { field, operator : 'equals' , value : 'x' } ] ,
394+ } ) ;
395+
396+ it ( 'a relation head is refused — it stores an id, not an embedded document' , ( ) => {
397+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'owner.name' ) ) ) ) ;
398+ expect ( findings ) . toHaveLength ( 1 ) ;
399+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_DOTTED ) ;
400+ expect ( findings [ 0 ] . severity ) . toBe ( 'error' ) ;
401+ expect ( findings [ 0 ] . path ) . toBe ( 'views[0].list.filter[0].field' ) ;
402+ expect ( findings [ 0 ] . message ) . toContain ( 'lookup' ) ;
403+ expect ( findings [ 0 ] . message ) . toContain ( 'can only match zero records' ) ;
404+ } ) ;
405+
406+ it ( 'a virtual head is refused — nothing materialises a column to reach into' , ( ) => {
407+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'score.x' ) ) ) ) ;
408+ expect ( findings ) . toHaveLength ( 1 ) ;
409+ expect ( findings [ 0 ] . message ) . toContain ( 'computed' ) ;
410+ } ) ;
411+
412+ it ( 'a plain scalar head is refused — there is nothing beneath it' , ( ) => {
413+ const findings = validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'title.x' ) ) ) ) ;
414+ expect ( findings ) . toHaveLength ( 1 ) ;
415+ expect ( findings [ 0 ] . message ) . toContain ( 'single scalar value' ) ;
416+ } ) ;
417+
418+ it ( '⛔ a structured/JSON head is NOT refused — the #8371 ruling\'s carve-out' , ( ) => {
419+ // Live on driver-memory and driver-mongodb (2 rows in the #8371
420+ // measurement table). Refusing it at author time would delete a working
421+ // capability on two of three backends — the exact fail-closed drift the
422+ // shared classifier exists to prevent.
423+ expect ( validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'payload.theme' ) ) ) ) ) . toEqual ( [ ] ) ;
424+ } ) ;
425+
426+ it ( '⛔ an array-valued head is NOT refused — a numeric-index path reaches it' , ( ) => {
427+ expect ( validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'tags.0' ) ) ) ) ) . toEqual ( [ ] ) ;
428+ } ) ;
429+
430+ it ( 'a registry-injected head is NOT refused at a filter — its type is invisible here' , ( ) => {
431+ // `created_at` resolves through skip 3 with no readable type, and
432+ // `classifyDottedFilterHead` answers `null` for an unreadable head.
433+ expect ( validateListViewFieldRefs ( stackWith ( mutate ( filterOn ( 'created_at.x' ) ) ) ) ) . toEqual ( [ ] ) ;
434+ } ) ;
435+
436+ it ( 'the tab and user-filter tab presets are judged on the same axis' , ( ) => {
437+ const findings = validateListViewFieldRefs (
438+ stackWith (
439+ mutate ( {
440+ tabs : [ { name : 'mine' , filter : [ { field : 'owner.name' , operator : 'equals' , value : 'x' } ] } ] ,
441+ userFilters : {
442+ fields : [ { field : 'status' } ] ,
443+ tabs : [ { name : 'open' , filter : [ { field : 'parent.title' , operator : 'equals' , value : 'x' } ] } ] ,
444+ } ,
445+ } ) ,
446+ ) ,
447+ ) ;
448+ expect ( idsOf ( findings ) . sort ( ) ) . toEqual ( [
449+ 'views[0].list.tabs[0].filter[0].field' ,
450+ 'views[0].list.userFilters.tabs[0].filter[0].field' ,
451+ ] ) ;
452+ expect ( findings . every ( ( f ) => f . rule === LIST_VIEW_FIELD_DOTTED ) ) . toBe ( true ) ;
453+ } ) ;
454+
455+ it ( 'the two positions that DECLARE end-user filterable names are judged' , ( ) => {
456+ // objectui folds the resulting conditions into the fetched query
457+ // (`buildEffectiveFilter`), so these names become filter keys.
458+ const findings = validateListViewFieldRefs (
459+ stackWith (
460+ mutate ( {
461+ filterableFields : [ 'owner.name' ] ,
462+ userFilters : { fields : [ { field : 'parent.title' } ] } ,
463+ } ) ,
464+ ) ,
465+ ) ;
466+ expect ( idsOf ( findings ) . sort ( ) ) . toEqual ( [
467+ 'views[0].list.filterableFields[0]' ,
468+ 'views[0].list.userFilters.fields[0].field' ,
469+ ] ) ;
470+ expect ( findings . every ( ( f ) => f . rule === LIST_VIEW_FIELD_DOTTED ) ) . toBe ( true ) ;
471+ } ) ;
472+ } ) ;
473+
474+ describe ( '#14282 — the measured exclusions: positions read CLIENT-SIDE' , ( ) => {
475+ it ( '⛔ `gantt.quickFilters[].field` accepts a dot-path — the card\'s named exception' , ( ) => {
476+ // Measured, and it went the other way round from the rest of the card.
477+ // The spec describes the position as "Record field / dot-path", and
478+ // objectui's `ObjectGantt.tsx` applies these filters IN MEMORY over the
479+ // already-fetched rows, resolving each through a walker that splits on `.`
480+ // and steps through the record object (`resolveFilterKey`). No query door
481+ // is involved, so nothing refuses it.
482+ const findings = validateListViewFieldRefs (
483+ stackWith ( mutate ( { gantt : { quickFilters : [ { field : 'owner.name' } ] } } ) ) ,
484+ ) ;
323485 expect ( findings ) . toEqual ( [ ] ) ;
324486 } ) ;
487+
488+ it ( 'the head of a gantt quick filter is STILL judged for existence (#14107 is untouched)' , ( ) => {
489+ const findings = validateListViewFieldRefs (
490+ stackWith ( mutate ( { gantt : { quickFilters : [ { field : 'ownr.name' } ] } } ) ) ,
491+ ) ;
492+ expect ( findings ) . toHaveLength ( 1 ) ;
493+ expect ( findings [ 0 ] . rule ) . toBe ( LIST_VIEW_FIELD_UNKNOWN ) ;
494+ } ) ;
495+
496+ it ( '⛔ `gantt.tooltipFields[]` accepts a dot-path — read through `resolvePath`' , ( ) => {
497+ const findings = validateListViewFieldRefs (
498+ stackWith ( mutate ( { gantt : { tooltipFields : [ 'owner.name' , { field : 'parent.title' } ] } } ) ) ,
499+ ) ;
500+ expect ( findings ) . toEqual ( [ ] ) ;
501+ } ) ;
502+
503+ it ( '⛔ renderer bindings reach no door this card measured, so they stay unjudged' , ( ) => {
504+ // Very likely still wrong (the gantt scalars read `record[field]` flat),
505+ // but "likely wrong" is not a verdict a gate may invent — and the failure
506+ // would be the SILENT class, not this loud one. Recorded as a follow-up.
507+ const findings = validateListViewFieldRefs (
508+ stackWith (
509+ mutate ( {
510+ rowColor : { field : 'owner.name' } ,
511+ kanban : { groupByField : 'owner.name' } ,
512+ calendar : { titleField : 'owner.name' } ,
513+ gallery : { coverField : 'owner.name' } ,
514+ tree : { parentField : 'owner.name' } ,
515+ grouping : { fields : [ { field : 'owner.name' } ] } ,
516+ hiddenFields : [ 'owner.name' ] ,
517+ fieldOrder : [ 'owner.name' ] ,
518+ } ) ,
519+ ) ,
520+ ) ;
521+ expect ( findings ) . toEqual ( [ ] ) ;
522+ } ) ;
523+
524+ it ( 'a `columns[]` entry\'s nested summary/prefix are unjudged for dotted paths too' , ( ) => {
525+ const findings = validateListViewFieldRefs (
526+ stackWith (
527+ mutate ( {
528+ columns : [ { field : 'title' , summary : { field : 'owner.name' } , prefix : { field : 'parent.title' } } ] ,
529+ } ) ,
530+ ) ,
531+ ) ;
532+ expect ( findings ) . toEqual ( [ ] ) ;
533+ } ) ;
534+ } ) ;
535+
536+ describe ( '#14282 — the class does not disturb its neighbours' , ( ) => {
537+ it ( 'the skips still win over the dotted verdict' , ( ) => {
538+ // An object this stack does not define: no graph, no verdict of any kind.
539+ const stack = stackWith (
540+ mutate ( { data : { provider : 'object' , object : 'sys_elsewhere' } , columns : [ { field : 'owner.name' } ] } ) ,
541+ ) ;
542+ expect ( validateListViewFieldRefs ( stack ) ) . toEqual ( [ ] ) ;
543+ } ) ;
544+
545+ it ( '`sort[]` keeps its owner — no dotted finding is minted for it here' , ( ) => {
546+ const findings = validateListViewFieldRefs (
547+ stackWith ( mutate ( { sort : [ { field : 'owner.name' , order : 'asc' } ] } ) ) ,
548+ ) ;
549+ expect ( findings . filter ( ( f ) => f . rule === LIST_VIEW_FIELD_DOTTED ) ) . toEqual ( [ ] ) ;
550+ } ) ;
551+
552+ it ( 'the two classes carry DIFFERENT rule ids, so one can be suppressed alone' , ( ) => {
553+ const findings = validateListViewFieldRefs (
554+ stackWith ( mutate ( { columns : [ { field : 'ownr.name' } , { field : 'owner.name' } ] } ) ) ,
555+ ) ;
556+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ LIST_VIEW_FIELD_UNKNOWN , LIST_VIEW_FIELD_DOTTED ] ) ;
557+ } ) ;
558+
559+ it ( 'the dotted class gates `validate` and `build`, like the rest of the error tier' , ( ) => {
560+ const stack = stackWith ( mutate ( { columns : [ { field : 'owner.name' } ] } ) ) ;
561+ for ( const command of [ 'validate' , 'build' ] as const ) {
562+ const { errors } = splitBySeverity ( runAuthoringRules ( command , { normalized : stack } ) ) ;
563+ expect ( errors . map ( ( e ) => e . rule ) ) . toContain ( LIST_VIEW_FIELD_DOTTED ) ;
564+ }
565+ } ) ;
566+
567+ it ( 'the reference-integrity suite carries the new class too' , ( ) => {
568+ const stack = stackWith ( mutate ( { columns : [ { field : 'owner.name' } ] } ) ) ;
569+ const findings = validateReferenceIntegrity ( stack ) ;
570+ expect ( findings . some ( ( f ) => f . rule === LIST_VIEW_FIELD_DOTTED ) ) . toBe ( true ) ;
571+ } ) ;
325572} ) ;
326573
327574describe ( '#14107 — the skips' , ( ) => {
0 commit comments