11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33/**
4- * Tests for the #7010 predicate PATH-resolution gate.
4+ * Tests for the #7010 predicate PATH-resolution gate, and for #7659's sibling
5+ * rule about the SHAPE of a `==` / `!=` right-hand side.
56 *
67 * The load-bearing block is `#6254 corpus` at the bottom. Everything above it is
78 * unit coverage over a hand-built schema; that block runs the rule over the
@@ -20,6 +21,7 @@ import {
2021 validatePredicatePathRefs ,
2122 PREDICATE_PATH_UNRESOLVED ,
2223 PREDICATE_PATH_UNROOTED ,
24+ PREDICATE_RHS_PATH_SHAPED ,
2325} from './validate-predicate-path-refs.js' ;
2426import { AUTHORING_RULES } from './authoring-rules.js' ;
2527
@@ -261,6 +263,120 @@ describe('validatePredicatePathRefs — traversal reach', () => {
261263 } ) ;
262264} ) ;
263265
266+ // ────────────────────────────────────────────────────────────────────────────
267+ // #7659 — the RIGHT-hand side of `==` / `!=`
268+ // ────────────────────────────────────────────────────────────────────────────
269+ //
270+ // A different question from everything above, and the reason it needed its own
271+ // rule rather than a widening of #7214: `data.a == data.b` RESOLVES on both
272+ // sides, so both rules above are silent on it by construction. The first two
273+ // tests pin that pair — the silence, and a control proving the walk that went
274+ // silent can still see.
275+ describe ( 'validatePredicatePathRefs — path-shaped right-hand side (#7659)' , ( ) => {
276+ const rhs = ( source : string ) =>
277+ run ( form ( [ { label : 'S' , fields : [ { field : 'name' , visibleWhen : source } ] } ] ) ) ;
278+
279+ it ( 'reports a path on the RIGHT of `==` — the case #7214 is silent on' , ( ) => {
280+ // Both sides resolve against DemoSchema (`name`, `type`), so neither
281+ // resolution limb has anything to say. Asserted as an EQUALITY on the rule
282+ // set rather than a `toContain`: if a resolution limb ever started firing
283+ // here, this card's premise would be gone and the test must say so.
284+ const findings = rhs ( 'data.name == data.type' ) ;
285+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ PREDICATE_RHS_PATH_SHAPED ] ) ;
286+ expect ( findings [ 0 ] . severity ) . toBe ( 'error' ) ;
287+ expect ( findings [ 0 ] . path ) . toBe ( 'views[0].sections[0].fields[0].visibleWhen' ) ;
288+ expect ( findings [ 0 ] . message ) . toContain ( '`data.type`' ) ;
289+ expect ( findings [ 0 ] . message ) . toMatch ( / l i t e r a l s t r i n g " d a t a \. t y p e " / ) ;
290+ expect ( findings [ 0 ] . hint ) . toMatch ( / q u o t e i t / ) ;
291+ } ) ;
292+
293+ it ( 'proves that silence is #7214 unable to SEE this, not a walk that reports nothing' , ( ) => {
294+ // Same predicate, one segment misspelled: the resolution limb must wake up.
295+ // Without this, the assertion above could be measuring a dead walk.
296+ expect ( rhs ( 'data.name == data.tpye' ) . map ( ( f ) => f . rule ) . sort ( ) )
297+ . toEqual ( [ PREDICATE_PATH_UNRESOLVED , PREDICATE_RHS_PATH_SHAPED ] ) ;
298+ } ) ;
299+
300+ it ( 'reports `!=` the same way' , ( ) => {
301+ const findings = rhs ( 'data.name != data.type' ) ;
302+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ PREDICATE_RHS_PATH_SHAPED ] ) ;
303+ expect ( findings [ 0 ] . message ) . toContain ( '`!=`' ) ;
304+ } ) ;
305+
306+ it ( 'reports a path on the right even when the LITERAL is on the left' , ( ) => {
307+ // Sides swapped: the renderer resolves the left and parses the right, so
308+ // this is the same defect and the fix is to swap them back.
309+ expect ( rhs ( "'grid' == data.type" ) . map ( ( f ) => f . rule ) ) . toEqual ( [ PREDICATE_RHS_PATH_SHAPED ] ) ;
310+ } ) ;
311+
312+ it ( 'reports a BARE unquoted word, at `warning` — it works today by accident' , ( ) => {
313+ // `... == active` compares against the literal string "active", which is
314+ // very likely what the author meant; objectui#4049's ruling preserved that
315+ // deliberately. Reported, because it is outside the declared subset and dies
316+ // when CEL lands; not gated, because refusing a `view` write over metadata
317+ // that renders correctly is a false build error.
318+ const findings = rhs ( 'data.name == active' ) ;
319+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ PREDICATE_RHS_PATH_SHAPED ] ) ;
320+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
321+ expect ( findings [ 0 ] . message ) . toContain ( '`active`' ) ;
322+ } ) ;
323+
324+ it ( 'reports every comparison in a compound predicate' , ( ) => {
325+ const findings = rhs ( "data.name == data.type && data.type == 'formula' || data.name != data.type" ) ;
326+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [
327+ PREDICATE_RHS_PATH_SHAPED ,
328+ PREDICATE_RHS_PATH_SHAPED ,
329+ ] ) ;
330+ } ) ;
331+
332+ // ── Negative controls. Each is either a spelling the rule's own hint
333+ // recommends, or a literal form `parseLiteral` returns from BEFORE its
334+ // path-shaped tail — so the renderer is silent on it too.
335+ it . each ( [
336+ [ 'a quoted literal RHS' , "data.type == 'formula'" ] ,
337+ [ 'a double-quoted literal RHS' , 'data.type == "formula"' ] ,
338+ [ 'a numeric RHS' , 'data.name == 3' ] ,
339+ [ 'a negative numeric RHS' , 'data.name != -3' ] ,
340+ [ 'a boolean RHS' , 'data.enable.search == true' ] ,
341+ [ 'a boolean RHS, negated' , 'data.enable.search != false' ] ,
342+ [ 'a null RHS' , 'data.type == null' ] ,
343+ // The restructuring the hint recommends — PATH on the left, literal on the
344+ // right. If the message tells authors to write this, writing it must not be
345+ // reported, or the rule sends them in a circle.
346+ [ 'the sanctioned restructuring (path LEFT, literal right)' , "data.type != 'formula'" ] ,
347+ [ 'a bare truthy check with no comparison at all' , 'data.enable.search' ] ,
348+ [ 'an `in` membership test (objectui#4266, deliberately not folded in)' , "data.type in ['a','b']" ] ,
349+ // Reachable through `parseLiteral`'s tail in the renderer, but NOT
350+ // path-shaped under the shared grammar — the consumer stays silent on both.
351+ [ 'an indexed RHS' , 'data.type == data.rows[0]' ] ,
352+ [ 'a call-result RHS' , 'data.type == size(data.tags)' ] ,
353+ ] ) ( 'is silent on %s' , ( _label , source ) => {
354+ expect ( rhs ( source ) ) . toEqual ( [ ] ) ;
355+ } ) ;
356+
357+ it ( 'leaves a comprehension macro BODY alone, and still walks its receiver' , ( ) => {
358+ // The interim evaluator supports no macros at all, so a comparison inside
359+ // one is not a statement about this subset. The receiver is still walked —
360+ // the second case carries a real finding in the same predicate.
361+ expect ( rhs ( 'data.tags.all(t, t == data.type)' ) ) . toEqual ( [ ] ) ;
362+ expect ( rhs ( 'data.name == data.type && data.tags.all(t, t == data.type)' ) . map ( ( f ) => f . rule ) )
363+ . toEqual ( [ PREDICATE_RHS_PATH_SHAPED ] ) ;
364+ } ) ;
365+
366+ it ( 'gives no verdict on a source the canonical front end refuses' , ( ) => {
367+ // `$` is in the shared grammar and NOT in CEL's identifier syntax, so this
368+ // never parses. One broken predicate, one finding — and that one is
369+ // `visibility-predicate-syntax`'s (#6253), from the sibling file.
370+ expect ( rhs ( 'data.name == $b' ) ) . toEqual ( [ ] ) ;
371+ } ) ;
372+
373+ it ( 'emits the id the published barrel exports' , async ( ) => {
374+ const barrel = await import ( './index.js' ) ;
375+ expect ( barrel . PREDICATE_RHS_PATH_SHAPED ) . toBe ( 'predicate-rhs-path-shaped' ) ;
376+ expect ( rhs ( 'data.name == data.type' ) [ 0 ] . rule ) . toBe ( barrel . PREDICATE_RHS_PATH_SHAPED ) ;
377+ } ) ;
378+ } ) ;
379+
264380describe ( 'registry wiring' , ( ) => {
265381 it ( 'is registered in AUTHORING_RULES as a gating rule on all three commands' , ( ) => {
266382 const entry = AUTHORING_RULES . find ( ( r ) => r . name === 'validatePredicatePathRefs' ) ;
@@ -354,6 +470,66 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
354470 ) . toEqual ( [ ] ) ;
355471 } ) ;
356472
473+ // #7659's own corpus measurement, on the same population and through the same
474+ // production entry point. The rule above is asserted at zero for its two ids;
475+ // this one asserts zero for the third at BOTH severities, which is what a new
476+ // `error` finding costs before it may land. Measured on `origin/main@5823d59`:
477+ // 0 and 0. A non-zero `error` count would have been a STOP.
478+ it ( 'reports NOTHING on the RHS rule over the shipped forms, at either severity' , ( ) => {
479+ const rhsFindings = validatePredicatePathRefs ( shippedStack )
480+ . filter ( ( f ) => f . rule === PREDICATE_RHS_PATH_SHAPED ) ;
481+ expect ( rhsFindings . filter ( ( f ) => f . severity === 'error' ) . map ( ( f ) => f . path ) ) . toEqual ( [ ] ) ;
482+ expect ( rhsFindings . filter ( ( f ) => f . severity === 'warning' ) . map ( ( f ) => f . path ) ) . toEqual ( [ ] ) ;
483+ } ) ;
484+
485+ it ( 'sees the shipped corpus (reverse verification for the RHS rule)' , ( ) => {
486+ // The anti-vacuity direction, and the reason it is written against the
487+ // SHIPPED predicates rather than a fixture: rewriting each real
488+ // `== 'literal'` comparison into `== data.__rhs__` turns it into exactly the
489+ // defect, so the assertion is an EQUALITY on the number of rewritten
490+ // comparisons rather than a floor any subset would satisfy. Zero here would
491+ // mean the measurement above was a green gate over nothing (#4984).
492+ //
493+ // The rewrite is anchored on the OPERATOR, not on "any quoted string": a
494+ // literal inside `data.type in ['a','b']` belongs to `in`, whose array parse
495+ // is objectui#4266 and deliberately not this rule's, so rewriting those too
496+ // would have inflated the expected count past what this rule answers for.
497+ const corrupted = structuredClone ( shippedStack ) as { views : unknown [ ] } ;
498+ let comparisons = 0 ;
499+ const rewrite = ( node : unknown ) : void => {
500+ if ( Array . isArray ( node ) ) {
501+ for ( const child of node ) rewrite ( child ) ;
502+ return ;
503+ }
504+ if ( ! node || typeof node !== 'object' ) return ;
505+ const rec = node as Record < string , unknown > ;
506+ for ( const key of [ 'visibleWhen' , 'visibleOn' ] ) {
507+ const value = rec [ key ] ;
508+ const source = typeof value === 'string' ? value
509+ : value && typeof value === 'object'
510+ && typeof ( value as Record < string , unknown > ) . source === 'string'
511+ ? ( ( value as Record < string , unknown > ) . source as string )
512+ : undefined ;
513+ if ( source === undefined ) continue ;
514+ const swapped = source . replace ( / ( = = | ! = ) ( \s * ) ' [ ^ ' ] * ' / g, ( _m , op , gap ) => {
515+ comparisons ++ ;
516+ return `${ op } ${ gap } data.__rhs__` ;
517+ } ) ;
518+ if ( swapped === source ) continue ;
519+ if ( typeof value === 'string' ) rec [ key ] = swapped ;
520+ else ( value as Record < string , unknown > ) . source = swapped ;
521+ }
522+ for ( const value of Object . values ( rec ) ) rewrite ( value ) ;
523+ } ;
524+ rewrite ( corrupted . views ) ;
525+ expect ( comparisons , 'no shipped predicate carries an `==`/`!=` literal comparison' ) . toBe ( 45 ) ;
526+
527+ const rhsFindings = validatePredicatePathRefs ( corrupted )
528+ . filter ( ( f ) => f . rule === PREDICATE_RHS_PATH_SHAPED ) ;
529+ expect ( rhsFindings ) . toHaveLength ( comparisons ) ;
530+ expect ( new Set ( rhsFindings . map ( ( f ) => f . severity ) ) ) . toEqual ( new Set ( [ 'error' ] ) ) ;
531+ } ) ;
532+
357533 it ( 'catches the pre-#6254 bare spellings when they are restored (reverse verification)' , ( ) => {
358534 // The reverse direction is RED-on-restore: #6254 rewrote 16 predicates in
359535 // `object.form.ts` from `type ...` to `data.type ...`. Restoring the bare
0 commit comments