Skip to content

Commit 1af8286

Browse files
claude[bot]claude
andauthored
fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline (#14182)
* fix(lint): judge field-rule roots against the bound-somewhere vocabulary (#13935) `fieldRuleRootIssue` filtered candidate roots through `@objectstack/formula`'s `SCOPE_ROOTS`, which answers "is this declared platform-wide" rather than the question this rule asks, "is this bound at some evaluation site". The two sets agreed for 27 roots and disagreed for `app` — bound by objectui's `ExpressionProvider`, absent from the baseline — so a field-level `*When` reading `app` fell through to the generic bare-reference check and was told to write `record.app`, which then earns `unknown field `app``. Assemble the judged vocabulary in this package as SCOPE_ROOTS plus the ambient roots the spec records in ui/page.zod, leaving the published baseline untouched, and give ambient roots a prescription tier that is true of them. Keep the two partitions disjoint by suppressing the bare-reference verdict for a root this rule has claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5 * fix(lint): scope the bare-reference suppression to ambient roots, dodge the #5017 receiver scan (#13935) Three findings from the first full run, all of them real: - A predicate reading a baseline root AND an ambient one kept the bare reference for the ambient one, re-emitting the exact false `record.app` prescription this card removes. Suppression is now gated on a verdict having been issued and covers the ambient roots, not only the root the tie-break named. - #5017's receiver scan reads `page.zod` and `record.${root}` inside a STRING literal as reads off `page` / `record` receivers, exactly as the file's existing `sectionFields` and `*.form` comments warn. Name the spec module in prose and assemble the `record.` spelling with `+`. - The two new locals are named `verdict` / `diagnostic` rather than `message` so excusing them cannot mask a genuine validations[].message read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d672c4 commit 1af8286

3 files changed

Lines changed: 414 additions & 25 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): a field-level `*When` reading `app` gets the scope diagnostic, not the false `record.app` prescription (#13935)
6+
7+
`fieldRuleRootIssue` judged field-rule roots against `@objectstack/formula`'s
8+
`SCOPE_ROOTS`, which answers "is this root declared **platform-wide**". The
9+
question this rule needs answered is "is this root bound at **some** evaluation
10+
site". The two agreed for all 27 baseline roots and disagreed for exactly one:
11+
`app`, which objectui's `ExpressionProvider` binds on the form-view surface an
12+
author migrates a field rule *down* from.
13+
14+
Falling outside the membership test sent `app` to the generic bare-reference
15+
check, whose prescription is ``Write `record.app` `` — and following that
16+
advice earns ``unknown field `app` on `invoice` `` from the field-existence
17+
pass. A first diagnostic that asserts something false about where the root
18+
binds, plus a wasted correction cycle. `current_user`, `user`, `ctx`, `os`,
19+
`features` and `data` all got the correct message; `app` alone did not.
20+
21+
Authoring a field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` on
22+
`app` now earns the same scope diagnostic every other unbound root gets —
23+
"a field-level conditional rule binds only `record` (plus `previous`, and
24+
`parent` on a master-detail line item)" — with a prescription tier of its own
25+
that says what is actually true of an ambient root: it is *not* declared
26+
platform-wide, it is mounted only by the renderer, and `record.app` is
27+
explicitly refused rather than merely omitted, because that is the advice the
28+
author just followed out of the old diagnostic.
29+
30+
**No accept set moves.** `SCOPE_ROOTS` is `@objectstack/formula`'s published
31+
strict-lint baseline — adding `app` there would stop *every* surface that
32+
judges bare identifiers from faulting it, to fix one surface's wording. The
33+
widened vocabulary is assembled in `@objectstack/lint` instead, where the
34+
per-surface question is asked, and both diagnostics involved were already
35+
`severity: 'error'`, so this changes which message an author reads and nothing
36+
about what lints clean.
37+
38+
`FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported beside
39+
the existing `FIELD_RULE_BOUND_ROOTS`.

packages/lint/src/validate-expressions.test.ts

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
1010
import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
1111
import { 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';
1419
import 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

Comments
 (0)