@@ -10,7 +10,12 @@ import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
1010import { FieldSchema , ObjectSchema , SelectOptionSchema } from '@objectstack/spec/data' ;
1111import { SharingRuleSchema } from '@objectstack/spec/security' ;
1212
13- import { validateStackExpressions , FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js' ;
13+ import {
14+ validateStackExpressions ,
15+ FIELD_RULE_BOUND_ROOTS ,
16+ FIELD_RULE_AMBIENT_ROOTS ,
17+ FIELD_RULE_JUDGED_ROOTS ,
18+ } from './validate-expressions.js' ;
1419import type { ExprIssue } from './validate-expressions.js' ;
1520// [#8405] Cross-site pin only — see the describe block at the bottom of this
1621// file. Not otherwise used here; validate-semantic-roles.test.ts owns the
@@ -956,15 +961,19 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
956961 * resolves in the strict env, so the bare-reference check never fired on it
957962 * either, and the denylist did not know it.
958963 *
959- * These tests are written against the IMPORTED `SCOPE_ROOTS` , not a copy of
964+ * These tests are written against the IMPORTED vocabulary , not a copy of
960965 * it, because "a future root is covered for free" is the whole argument for
961966 * the allowlist and a hand-copied list in the test would assert the opposite
962- * of what it claims — it would go green on a root the rule never saw.
967+ * of what it claims — it would go green on a root the rule never saw. Since
968+ * #13935 the imported thing is `FIELD_RULE_JUDGED_ROOTS` rather than
969+ * `SCOPE_ROOTS`: the judged vocabulary is now the WIDER "bound at some
970+ * evaluation site" set, and generating from the baseline would have left
971+ * exactly the ambient roots #13935 added out of the table.
963972 */
964- describe ( 'field-level `*When` roots are an ALLOWLIST over SCOPE_ROOTS (#6713)' , ( ) => {
973+ describe ( 'field-level `*When` roots are an ALLOWLIST over the judged vocabulary (#6713/#13935 )' , ( ) => {
965974 /** The three the surface really binds. Everything else must be rejected. */
966975 const BOUND = [ 'record' , 'previous' , 'parent' ] as const ;
967- const RESIDUAL = SCOPE_ROOTS . filter ( ( r ) => ! ( BOUND as readonly string [ ] ) . includes ( r ) ) ;
976+ const RESIDUAL = FIELD_RULE_JUDGED_ROOTS . filter ( ( r ) => ! ( BOUND as readonly string [ ] ) . includes ( r ) ) ;
968977
969978 const fieldIssues = ( predicate : string , slot = 'visibleWhen' ) =>
970979 validateStackExpressions ( {
@@ -1019,6 +1028,171 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
10191028 expect ( hit [ 0 ] ! . message ) . toContain ( '`visibleWhen` reads `data`' ) ;
10201029 } ) ;
10211030
1031+ /**
1032+ * ── The AMBIENT roots (#13935) ────────────────────────────────────────
1033+ *
1034+ * `SCOPE_ROOTS` answers "is this declared platform-wide"; this rule needs
1035+ * "is this bound at SOME evaluation site". They agreed for 27 roots and
1036+ * disagreed for `app`, which objectui's `ExpressionProvider` binds on the
1037+ * very surface an author migrates a rule DOWN from. Falling outside the
1038+ * membership test sent `app` to the bare-reference check, which
1039+ * prescribed `record.app` — and following THAT earns `unknown field
1040+ * \`app\``. The defect is WHICH diagnostic fires, so every assertion here
1041+ * names the specific diagnostic rather than counting that "something
1042+ * fired".
1043+ */
1044+ describe ( 'ambient roots — bound somewhere, absent from SCOPE_ROOTS (#13935)' , ( ) => {
1045+ /**
1046+ * The ruling, pinned as a boundary test rather than restated in prose:
1047+ * the fix widens the vocabulary THIS package assembles and leaves
1048+ * `@objectstack/formula`'s published accept baseline alone. A future
1049+ * edit that "simplifies" this by adding `app` to `SCOPE_ROOTS` widens a
1050+ * published accept set — every surface judging bare identifiers stops
1051+ * faulting it — and goes red right here.
1052+ */
1053+ it ( 'does NOT widen `SCOPE_ROOTS` — the judged set is a strict superset assembled locally' , ( ) => {
1054+ expect ( [ ...FIELD_RULE_AMBIENT_ROOTS ] ) . toEqual ( [ 'app' ] ) ;
1055+ // The baseline is untouched: `app` is still not declared platform-wide.
1056+ expect ( SCOPE_ROOTS ) . not . toContain ( 'app' ) ;
1057+ // …and the judged vocabulary contains all of it, plus the ambient set.
1058+ expect ( FIELD_RULE_JUDGED_ROOTS ) . toEqual ( [ ...SCOPE_ROOTS , ...FIELD_RULE_AMBIENT_ROOTS ] ) ;
1059+ expect ( FIELD_RULE_JUDGED_ROOTS . length ) . toBe ( SCOPE_ROOTS . length + 1 ) ;
1060+ } ) ;
1061+
1062+ it ( 'gives `app` the SCOPE diagnostic — not the bare-reference prescription' , ( ) => {
1063+ const hit = fieldIssues ( "app.locale == 'en'" ) ;
1064+ // One verdict, not two: the bare-reference check no longer also fires.
1065+ // ⛔ Do not soften this to `toBeGreaterThan(0)` — the length IS the
1066+ // pin that catches the suppression silently missing.
1067+ expect ( hit ) . toHaveLength ( 1 ) ;
1068+ expect ( hit [ 0 ] ! . severity ) . toBe ( 'error' ) ;
1069+ expect ( hit [ 0 ] ! . message ) . toContain ( '`visibleWhen` reads `app`' ) ;
1070+ expect ( hit [ 0 ] ! . message ) . toContain ( 'binds only `record`' ) ;
1071+ // The wording the card was filed about, in both halves: the generic
1072+ // diagnostic's identity, and the prescription that is actively false.
1073+ expect ( hit [ 0 ] ! . message ) . not . toContain ( 'bare reference' ) ;
1074+ expect ( hit [ 0 ] ! . message ) . not . toContain ( 'Write `record.app`' ) ;
1075+ } ) ;
1076+
1077+ it ( 'tells `app` the truth about where it binds — ambient, renderer-only' , ( ) => {
1078+ const hit = fieldIssues ( "app.locale == 'en'" ) ;
1079+ expect ( hit [ 0 ] ! . message ) . toContain ( 'AMBIENT root' ) ;
1080+ expect ( hit [ 0 ] ! . message ) . toContain ( '⛔ Do NOT write `record.app`' ) ;
1081+ // ⛔ NOT the general tier's claim, which is false for an ambient root
1082+ // in both of its clauses.
1083+ expect ( hit [ 0 ] ! . message ) . not . toContain ( 'is declared platform-wide' ) ;
1084+ } ) ;
1085+
1086+ /**
1087+ * Why `record.app` had to be refused IN the message rather than merely
1088+ * left out: it is exactly what the pre-#13935 diagnostic told this
1089+ * author to write, and it does not work.
1090+ */
1091+ it ( 'pins that the OLD prescription was false — `record.app` earns `unknown field`' , ( ) => {
1092+ const hit = fieldIssues ( 'record.app == 1' ) ;
1093+ expect ( hit ) . toHaveLength ( 1 ) ;
1094+ expect ( hit [ 0 ] ! . message ) . toContain ( 'unknown field `app`' ) ;
1095+ } ) ;
1096+
1097+ /**
1098+ * The discriminator. `current_user` is the positive control that passed
1099+ * before this card and must keep passing UNCHANGED — same tier, same
1100+ * prescription. A repair that gave every rejected root the new ambient
1101+ * wording would satisfy the `app` assertions above and be wrong.
1102+ */
1103+ it ( 'leaves the `current_user` control on the USER tier, not the ambient one' , ( ) => {
1104+ const hit = fieldIssues ( "current_user.id == 'U1'" ) ;
1105+ expect ( hit ) . toHaveLength ( 1 ) ;
1106+ expect ( hit [ 0 ] ! . message ) . toContain ( '`visibleWhen` reads `current_user`' ) ;
1107+ expect ( hit [ 0 ] ! . message ) . toContain ( 'move the predicate to the option\'s own' ) ;
1108+ expect ( hit [ 0 ] ! . message ) . not . toContain ( 'AMBIENT root' ) ;
1109+ } ) ;
1110+
1111+ /**
1112+ * Tie-break no-regression. `SCOPE_ROOTS` is spliced in FIRST, so a
1113+ * predicate reading both a baseline root and an ambient one reports the
1114+ * baseline root — the same root, and the same message, it reported
1115+ * before #13935 widened the vocabulary.
1116+ *
1117+ * The LENGTH is the second half of this pin and it is the half that
1118+ * moved: before #13935 this predicate earned two issues — the `ctx`
1119+ * verdict plus a bare reference to `app` prescribing `record.app`, the
1120+ * exact false advice this card removes. The rule emits one verdict per
1121+ * slot, so `app` waits its turn rather than being told something untrue.
1122+ */
1123+ it ( 'keeps the pre-#13935 tie-break — a baseline root still wins over an ambient one' , ( ) => {
1124+ const hit = fieldIssues ( "ctx.locale == 'en' && app.locale == 'en'" ) ;
1125+ expect ( hit ) . toHaveLength ( 1 ) ;
1126+ expect ( hit [ 0 ] ! . message ) . toContain ( '`visibleWhen` reads `ctx`' ) ;
1127+ expect ( hit [ 0 ] ! . message ) . not . toContain ( 'Write `record.app`' ) ;
1128+ } ) ;
1129+
1130+ /**
1131+ * …and the second root is not LOST, only deferred: fixing `ctx` earns
1132+ * `app` its own correct verdict on the next run. Without this the pin
1133+ * above would be satisfied by a repair that simply dropped the root.
1134+ */
1135+ it ( 'reports the ambient root on the next pass, once the baseline root is fixed' , ( ) => {
1136+ const hit = fieldIssues ( "record.amount > 0 && app.locale == 'en'" ) ;
1137+ expect ( hit ) . toHaveLength ( 1 ) ;
1138+ expect ( hit [ 0 ] ! . message ) . toContain ( '`visibleWhen` reads `app`' ) ;
1139+ } ) ;
1140+
1141+ /**
1142+ * Root-vs-MEMBER, at ambient width: `record.app_id` is an ordinary
1143+ * field name that merely starts like the new root.
1144+ */
1145+ it ( 'does NOT trip on a `record` member merely spelled like an ambient root' , ( ) => {
1146+ const issues = validateStackExpressions ( {
1147+ objects : [ {
1148+ name : 'showcase_deal' ,
1149+ fields : {
1150+ app_id : { type : 'text' } ,
1151+ gate : { type : 'text' , visibleWhen : "record.app_id != ''" } ,
1152+ } ,
1153+ } ] ,
1154+ } ) . filter ( ( i ) => i . where . includes ( "field 'gate' visibleWhen" ) ) ;
1155+ expect ( issues ) . toHaveLength ( 0 ) ;
1156+ } ) ;
1157+
1158+ /**
1159+ * BLAST RADIUS. The suppression is gated on a field-rule verdict, so it
1160+ * reaches the field level and nothing else. A per-OPTION `visibleWhen`
1161+ * is deliberately NOT passed through this rule (options resolve against
1162+ * the host's predicate scope — see the #6290 note in the field walk),
1163+ * so `app` there still meets the bare-reference check exactly as it did
1164+ * before this card. Pinned because "suppress the bare-reference verdict"
1165+ * is the half of this repair that could quietly go wide.
1166+ */
1167+ it ( 'does not reach the per-OPTION surface — `app` there keeps the bare-reference verdict' , ( ) => {
1168+ const hit = validateStackExpressions ( {
1169+ objects : [ {
1170+ name : 'showcase_deal' ,
1171+ fields : {
1172+ gate : {
1173+ type : 'select' ,
1174+ options : [ { value : 'a' , visibleWhen : "app.locale == 'en'" } ] ,
1175+ } ,
1176+ } ,
1177+ } ] ,
1178+ } ) . filter ( ( i ) => i . where . includes ( 'option' ) ) ;
1179+ expect ( hit ) . toHaveLength ( 1 ) ;
1180+ expect ( hit [ 0 ] ! . message ) . toContain ( 'bare reference `app`' ) ;
1181+ } ) ;
1182+
1183+ /**
1184+ * …and a bare FIELD reference on a field-rule slot is untouched: no
1185+ * ambient root is read, so no verdict fires and nothing is suppressed.
1186+ * Guards the gate itself — a suppression keyed on the wrong condition
1187+ * would swallow this and leave the author with silence.
1188+ */
1189+ it ( 'leaves a plain bare field reference on a field-rule slot alone' , ( ) => {
1190+ const hit = fieldIssues ( "nope == 1" ) ;
1191+ expect ( hit ) . toHaveLength ( 1 ) ;
1192+ expect ( hit [ 0 ] ! . message ) . toContain ( 'bare reference `nope`' ) ;
1193+ } ) ;
1194+ } ) ;
1195+
10221196 /**
10231197 * The partition, both halves. The rule judges `SCOPE_ROOTS` membership,
10241198 * NOT strict-env declaredness — and that is a measured distinction, not a
@@ -2264,6 +2438,15 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
22642438 // receiver above) and the provenance index (`unprovisionedIndex` /
22652439 // `anchors`), whose keys are Map/Set methods, never metadata keys.
22662440 'pending' , 'celNode' , 'celRecv' , 'anchors' , 'unprovisionedIndex' ,
2441+ // [#13935] The field-rule verdict, split into a compute half and a push
2442+ // half so the walk can tell `check` a verdict was issued. `verdict`'s
2443+ // keys are this helper's own `{ root, message, source }`, never metadata
2444+ // keys; `diagnostic` is a formula error STRING and its one "key" is
2445+ // `String.prototype.startsWith`. Both are named to stay clear of the
2446+ // `message` / `source` metadata receivers — a local called `message`
2447+ // here would have been excused into masking a genuine
2448+ // `validations[].message` read.
2449+ 'verdict' , 'diagnostic' ,
22672450 ] ) ;
22682451 expect ( receivers . filter ( ( r ) => ! tabled . has ( r ) && ! PLUMBING . has ( r ) ) ) . toEqual ( [ ] ) ;
22692452 } ) ;
0 commit comments