From bf714562d94ba224013ce772f7748e30cd83fa3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:21:20 +0000 Subject: [PATCH] fix(lint): field-formula check reads the declared `expression`, activating a pass that never ran (#5026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validate-expressions.ts`'s field-formula pass read `f.formula`. `FieldSchema` declares the computed slot as `expression`; `formula` is one of the names `field.zod.ts:333` rejects BY NAME (`aliases: { formula: 'expression', … }`). The rule is registered `input: 'parsed'`, so on the compile path it sees `ObjectStackSchema`'s output, where `f.formula` is always `undefined` — the whole branch had never executed against a stack an author can ship. Converging the read onto `expression` therefore ACTIVATES a check rather than deleting a dead branch: field formulas now carry the ADR-0032 §1a/1b verdicts (CEL syntax, `record.` existence, the #1928 bare-reference and type-soundness tiers). Bare refs in a formula silently evaluate to null, and they are the single most common defect in AI-authored formula slots, which is what #1928 built this check for. Coverage widened, zero new findings on real metadata. Swept every stack in the repo with the activated check: examples/app-showcase (3 `expression` slots), examples/app-crm (5), examples/app-todo (0) all parse and stay green; platform-objects and plugin-security's default-permission-sets declare no formula fields; the `skills/` samples are already canonical. Two bare-ref samples in `content/docs` + `content/blog` are out of this rule's consumer radius and filed as #5116 rather than fixed here. - `TRACKED_UNDECLARED_READS` in the meta-guard drops to EMPTY — every key the rule reads is now one the spec declares. - Diagnostic locator renamed `… field 'Y' formula` → `… field 'Y' expression`, so the message names the key the author edits instead of propagating the spelling the schema refuses. - The 7 field-predicate fixtures spelled `formula:` move to `expression:`; a `fields[].formula → expression` case joins the rejected-alias table (schema refuses by name, this rule stays silent) and a reachability case proves the pass fires from a stack that really parses. - New reverse verification: broken `expression` red, correct one green, `formula:` left to the schema, and the read mutated back to `f.formula` caught by the declared-key guard — executed, not asserted. - `validate-null-guards.ts`'s surface ledger renames the row to field `expression` and states that only the NULL-GUARD verdict stays excluded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../field-expression-check-activated.md | 42 ++++ .../lint/src/validate-expressions.test.ts | 203 +++++++++++++++--- packages/lint/src/validate-expressions.ts | 31 ++- packages/lint/src/validate-null-guards.ts | 17 +- 4 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 .changeset/field-expression-check-activated.md diff --git a/.changeset/field-expression-check-activated.md b/.changeset/field-expression-check-activated.md new file mode 100644 index 0000000000..02bb3b95cb --- /dev/null +++ b/.changeset/field-expression-check-activated.md @@ -0,0 +1,42 @@ +--- +"@objectstack/lint": minor +--- + +fix(lint): 字段公式校验首次对 spec 合法元数据生效 —— `f.formula` 收敛为 `f.expression` (#5026) + +`validate-expressions.ts` 的字段公式校验(`validateStackExpressions` 里的 +field-formula pass)一直读 `f.formula`。`FieldSchema` 声明的是 `expression`,而 +`formula` 恰是 `field.zod.ts:333` **按名拒绝**的别名之一 +(`aliases: { formula: 'expression', calculation: 'expression', compute: 'expression' }`)。 +该规则以 `input: 'parsed'` 注册,compile/build/validate 路径上看到的是 +`ObjectStackSchema` 的解析产物,所以 `f.formula` 恒为 `undefined` —— +**整段检查对任何 spec 合法 stack 从未执行过一次**。 + +这不是删死代码,是**启用一条从未跑过的检查**。字段公式从此真正受 +ADR-0032 §1a/1b 的三条判决管辖:CEL 语法、`record.` 字段存在性、 +以及 #1928 的裸引用 / 类型健全性。对 AI 生成的元数据这一条最要紧 —— +`amount * probability`(而不是 `record.amount * record.probability`)正是公式槽位 +最常见的错法,它在 CEL 里静默求值为 null,过去没有任何门拦得住。 + +**覆盖面扩大,但对现有元数据零新红。** 激活后在仓库全部真实元数据上实测过: +`examples/app-showcase`(3 个 `expression` 槽)、`examples/app-crm`(5 个)、 +`examples/app-todo`(0 个)全部 `ObjectStackSchema` 解析通过,新增判决 0 条; +`platform-objects`、`plugin-security` 的 default-permission-sets 不含公式字段; +`skills/` 里的公式样例全部已是 canonical 拼法。 + +**Authoring impact.** 之前拼 `formula:` 的字段本来就无法解析,schema 会按名拒绝 +并给出 `Did you mean \`formula\` → \`expression\`?`——该行为不变,本规则不再对同一个键 +给出第二套说法。诊断定位串同步改名以免继续传播错拼法: + +``` +FROM object 'X' · field 'Y' formula +TO object 'X' · field 'Y' expression +``` + +`validate-null-guards.ts` 的 surface ledger 相应把该行从 `Field.formula` 正名为 +field `expression`(`Field.formula({ expression: … })` 写入的槽)。null-guard 判决 +**仍然**排除该 surface(公式是 `value` 角色、天然可空,`guard ? value : null` 是祝福 +写法),排除的只是 null-guard 这一条,语法 / 字段存在性 / 裸引用判决从此生效。 + +`validate-expressions.test.ts` 的 `TRACKED_UNDECLARED_READS` 记账随之清空 —— 这 +份"只缩不长"的清单现在是零条,规则读的每一个键都是 spec 声明的键。 diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index a96e9ae0e4..f45960261a 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -182,12 +182,12 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { fields: { amount: { type: 'currency' }, probability: { type: 'percent' }, - expected_revenue: { type: 'formula', name: 'expected_revenue', formula: 'amount * probability / 100' }, + expected_revenue: { type: 'formula', name: 'expected_revenue', expression: 'amount * probability / 100' }, }, }], }); expect(issues).toHaveLength(1); - expect(issues[0].where).toContain("field 'expected_revenue' formula"); + expect(issues[0].where).toContain("field 'expected_revenue' expression"); expect(issues[0].message).toMatch(/bare reference `(amount|probability)`/); }); @@ -211,7 +211,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { fields: { amount: { type: 'currency' }, probability: { type: 'percent' }, - expected_revenue: { type: 'formula', name: 'expected_revenue', formula: 'record.amount * record.probability / 100' }, + expected_revenue: { type: 'formula', name: 'expected_revenue', expression: 'record.amount * record.probability / 100' }, }, validations: [{ name: 'amt', condition: 'record.amount != null && record.amount >= 0' }], }], @@ -231,14 +231,14 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { end_date: { type: 'date' }, days: { type: 'formula', name: 'days', - formula: 'record.start_date != null && record.end_date != null ? (record.end_date - record.start_date) + 1 : null', + expression: 'record.start_date != null && record.end_date != null ? (record.end_date - record.start_date) + 1 : null', }, }, }], }); expect(issues).toHaveLength(1); expect(issues[0].severity).toBe('error'); - expect(issues[0].where).toContain("field 'days' formula"); + expect(issues[0].where).toContain("field 'days' expression"); expect(issues[0].message).toMatch(/date arithmetic/i); expect(issues[0].message).toMatch(/daysBetween/); }); @@ -252,7 +252,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { end_date: { type: 'date' }, days: { type: 'formula', name: 'days', - formula: 'record.start_date != null && record.end_date != null ? daysBetween(record.start_date, record.end_date) + 1 : null', + expression: 'record.start_date != null && record.end_date != null ? daysBetween(record.start_date, record.end_date) + 1 : null', }, }, }], @@ -326,13 +326,13 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { name: 'crm_lead', fields: { company: { type: 'text' }, - score: { type: 'formula', formula: 'record.company * 2' }, + score: { type: 'formula', expression: 'record.company * 2' }, }, }], }); const w = issues.filter(i => i.severity === 'warning'); expect(w).toHaveLength(1); - expect(w[0].where).toMatch(/formula/); + expect(w[0].where).toMatch(/expression/); expect(w[0].message).toMatch(/type mismatch/i); expect(w[0].message).toMatch(/record\.company/); }); @@ -345,7 +345,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { amount: { type: 'currency' }, probability: { type: 'percent' }, close_date: { type: 'date' }, - expected: { type: 'formula', formula: 'record.amount * record.probability / 100' }, + expected: { type: 'formula', expression: 'record.amount * record.probability / 100' }, }, // The `!= null` guard is load-bearing since #4763: `close_date` is a // declared NULLABLE field, and an un-guarded `>=` over it faults at @@ -1161,14 +1161,21 @@ describe('null-guard gate (#4763)', () => { ).toHaveLength(0); }); - it('leaves `Field.formula` expressions alone (blessed `guard ? value : null`, #3306)', () => { + it('leaves field `expression` alone for the NULL-GUARD verdict (blessed `guard ? value : null`, #3306)', () => { + // Load-bearing since #5026: this pass now genuinely RUNS on `expression` + // (it read the rejected `formula` spelling before, so the fixture was + // silent for the wrong reason). Zero issues here therefore means the + // null-guard gate really is excluded from this surface, not that the + // surface is unreachable. `budget` / `spent` are both nullable and `-` is + // applied to them unguarded — exactly the shape the gate rejects on + // `requiredWhen`. expect( validateStackExpressions({ objects: [{ ...project, fields: { ...project.fields, - remaining: { type: 'formula', formula: 'record.budget - record.spent' }, + remaining: { type: 'formula', expression: 'record.budget - record.spent' }, }, }], }), @@ -1352,22 +1359,22 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s ]; /** - * The ONE read in this file that is still undeclared, tracked rather than - * silently tolerated. + * Undeclared reads still tracked rather than fixed. **Empty since #5026** — + * every key this rule reads is one `@objectstack/spec` declares. * - * `FieldSchema` declares the computed slot as `expression` and rejects - * `formula` by name ("Did you mean `formula` → `expression`?"), so the - * field-formula pass has never run against a stack that parses. It is NOT - * fixed here because converging it would ACTIVATE a check rather than delete a - * dead branch — a coverage change with its own verification to do, not - * dead-code removal. Filed as #5026. + * The last entry was `f.formula`: `FieldSchema` declares the computed slot as + * `expression` and rejects `formula` by name ("Did you mean `formula` → + * `expression`?"), so the field-formula pass had never run against a stack that + * parses. #5026 converged it onto `expression`, which ACTIVATED the check + * rather than deleting a dead branch — hence its own PR, with a sweep of every + * real stack in the repo attached (8 field `expression` slots across + * `app-showcase` / `app-crm` / `app-todo`; zero new findings). * - * This list must shrink, never grow: a second entry means the next author - * treated it as a place to put exceptions rather than a debt to pay down. + * This list must shrink, never grow. It is at zero: any entry at all now means + * someone treated it as a place to park an exception rather than a debt to pay + * down, and the `expected: []` assertions below make that a deliberate act. */ -const TRACKED_UNDECLARED_READS: Array<{ receiver: string; key: string; issue: number }> = [ - { receiver: 'f', key: 'formula', issue: 5026 }, -]; +const TRACKED_UNDECLARED_READS: Array<{ receiver: string; key: string; issue: number }> = []; describe('validateStackExpressions — reads only keys the spec declares (meta-test, #5017)', () => { it.each(READ_SURFACES)('every key read off `$receiver` is declared by $declaredBy', (surface) => { @@ -1380,16 +1387,22 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t expect(read.filter((k) => !declared.includes(k))).toEqual([]); }); - it('the field receiver reads only declared keys, plus exactly the tracked debt', () => { + it('the field receiver reads only declared keys — the tracked debt is now empty (#5026)', () => { const read = keysReadOff('f'); - expect(read).toEqual(['formula', 'name', 'readonlyWhen', 'requiredWhen']); + expect(read).toEqual(['expression', 'name', 'readonlyWhen', 'requiredWhen']); const declared = Object.keys(FieldSchema.shape); const tracked = TRACKED_UNDECLARED_READS.filter((t) => t.receiver === 'f').map((t) => t.key); - expect(tracked).toEqual(['formula']); + expect(tracked).toEqual([]); expect(read.filter((k) => !declared.includes(k) && !tracked.includes(k))).toEqual([]); - // And the debt is real, not a stale entry: `formula` genuinely is not there. - expect(declared).not.toContain('formula'); + // The canonical key is the declared one, and the spelling this read used to + // carry is genuinely absent — so a revert to `f.formula` fails here, loudly, + // rather than quietly re-inerting the pass. expect(declared).toContain('expression'); + expect(declared).not.toContain('formula'); + }); + + it('nothing is tracked as an undeclared read any more — the list shrinks, never grows', () => { + expect(TRACKED_UNDECLARED_READS).toEqual([]); }); it('the computed key lists are spelled from the declaring schema', () => { @@ -1504,6 +1517,25 @@ const REJECTED_ALIASES: Array<{ label: string; stack: Record; r refusal: /Unrecognized key\(s\) on this field: `referenceTo`.*Did you mean `referenceTo` → `reference`/s, lintAfter: null, }, + { + // #5026. The newest member of this table, and the one that reads + // differently from the rest: the other spellings were rejected AND unread, + // while this one was rejected and READ — the only key the rule honoured. + // Converging the read onto `expression` moved it here, where it belongs. + label: 'objects[].fields[].formula → expression', + stack: { + objects: [{ name: 'crm_opportunity', label: 'Opp', sharingModel: 'private', + fields: { + amount: { type: 'currency', label: 'Amount' }, + // A defect the rule WOULD name if it still read this key: bare refs. + expected_revenue: { type: 'formula', label: 'Expected', formula: 'amount * 2' }, + } }], + }, + refusal: /Unrecognized key\(s\) on this field: `formula`.*Did you mean `formula` → `expression`/s, + // Silent: the rule reads `expression` now, so an author who spells `formula` + // hears it from the schema, by name, once — not twice in two vocabularies. + lintAfter: null, + }, { label: 'actions[].object → objectName', stack: { @@ -1741,4 +1773,117 @@ describe('validateStackExpressions — every changed read is reachable from a sp expect(issues).toHaveLength(1); expect(issues[0].message).toMatch(/declares 2 `master_detail` relationships/); }); + + it('fields[].expression — the field-formula pass fires from a stack that PARSES (#5026)', () => { + // The whole point of #5026. Before it, no spec-valid stack could reach this + // branch at all: it was keyed on `formula`, which the schema refuses. This + // fixture goes through a real `ObjectStackSchema.parse` and still produces + // the finding — the first time that sentence has been true. + const issues = validateStackExpressions( + specValid({ + objects: [{ + name: 'crm_opportunity', label: 'Opp', sharingModel: 'private', + fields: { + amount: { type: 'currency', label: 'Amount' }, + probability: { type: 'percent', label: 'Probability' }, + expected_revenue: { type: 'formula', label: 'Expected', expression: 'amount * probability / 100' }, + }, + }], + }), + ); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].where).toBe("object 'crm_opportunity' · field 'expected_revenue' expression"); + expect(issues[0].message).toMatch(/bare reference `(amount|probability)`/); + }); +}); + +/** + * ── Reverse verification for the ACTIVATION itself (#5026) ─────────────────── + * + * #5017's five removals were behaviour-neutral on every parsing stack, so their + * reverse verification only had to show the alias limbs were dead. This one is + * the opposite shape: converging `f.formula` → `f.expression` turned a branch + * that had NEVER run into one that runs on every compile. So the verification + * owes four things, not one: + * + * 1. a broken `expression` on a spec-valid stack is now RED, and named; + * 2. a correct one is GREEN (the gate is not simply on); + * 3. the `formula:` spelling is handled by the SCHEMA, by name — this rule + * says nothing about it, so the author hears one diagnostic, not two; + * 4. mutating the read back to `f.formula` is caught by the declared-key + * meta-guard above — i.e. this cannot silently re-inert. + * + * Point 4 is asserted against the same source scan the guard uses rather than + * described, because "a guard that would have caught it" is exactly the claim + * that is worth nothing unless executed. + */ +describe('validateStackExpressions — the field-formula check now actually runs (#5026)', () => { + const OPP = (expression: string) => ({ + objects: [{ + name: 'crm_opportunity', label: 'Opp', sharingModel: 'private', + fields: { + amount: { type: 'currency', label: 'Amount' }, + probability: { type: 'percent', label: 'Probability' }, + expected_revenue: { type: 'formula', label: 'Expected', expression }, + }, + }], + }); + + it('1 — a broken `expression` is RED on a stack the spec accepts', () => { + const issues = validateStackExpressions(specValid(OPP('record.no_such_field * 2'))); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].message).toMatch(/unknown field `no_such_field`/); + expect(issues[0].where).toContain("field 'expected_revenue' expression"); + }); + + it('1b — and a syntactically broken one is RED too', () => { + const issues = validateStackExpressions(specValid(OPP('record.amount *'))); + expect(issues.length).toBeGreaterThanOrEqual(1); + expect(issues[0].severity).toBe('error'); + expect(issues[0].where).toContain("field 'expected_revenue' expression"); + }); + + it('2 — a correct `expression` is GREEN (the gate discriminates, it is not just on)', () => { + // The shape every real app in this repo actually ships — verified against + // all 8 field `expression` slots in `examples/app-{showcase,crm,todo}` when + // the check was activated, all of which stayed green. + expect(validateStackExpressions(specValid(OPP('record.amount * record.probability / 100')))).toEqual([]); + }); + + it('3 — the `formula:` spelling is the SCHEMA’s to refuse; this rule stays silent', () => { + const badSpelling = { + objects: [{ + name: 'crm_opportunity', label: 'Opp', sharingModel: 'private', + fields: { + amount: { type: 'currency', label: 'Amount' }, + expected_revenue: { type: 'formula', label: 'Expected', formula: 'amount * 2' }, + }, + }], + }; + const parsed = ObjectStackSchema.safeParse({ manifest: MANIFEST, ...badSpelling }); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues.map((i) => i.message).join(' ')).toMatch(/Did you mean `formula` → `expression`/); + // And the lint adds nothing of its own — no second vocabulary for one key. + expect(validateStackExpressions(badSpelling)).toEqual([]); + }); + + it('4 — mutating the read back to `f.formula` is caught by the declared-key guard', () => { + // The mutation, applied to the real source the guard scans. + const mutated = RULE_CODE.replace(/\bf\.expression\b/g, 'f.formula'); + expect(mutated, 'the read moved — this mutation no longer reproduces #5026').not.toBe(RULE_CODE); + + const readAfterMutation = [ + ...new Set([...mutated.matchAll(/\bf\??\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])), + ].sort(); + const declared = Object.keys(FieldSchema.shape); + const undeclared = readAfterMutation.filter( + (k) => !declared.includes(k) && !TRACKED_UNDECLARED_READS.some((t) => t.receiver === 'f' && t.key === k), + ); + // The guard's own assertion, run against the mutant: it fails, naming the key. + expect(undeclared).toEqual(['formula']); + // …and the un-mutated source passes the identical assertion. + expect(keysReadOff('f').filter((k) => !declared.includes(k))).toEqual([]); + }); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ab6f53f1cc..3b641d46df 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -36,6 +36,7 @@ * | `objects[].validations[]` | `ObjectSchema` | * | `validations[].condition` / `.when` / `.then` / `.otherwise` | the six `*ValidationSchema` variants | * | `objects[].fields[].reference` | `FieldSchema` | + * | `objects[].fields[].expression` | `FieldSchema` | * | `actions[].objectName` | the action schema | * | `sharingRules[].condition` / `.object` | `SharingRuleSchema` | * @@ -60,16 +61,19 @@ * `relatedTo` / `target` / `targetObject` / `lookupObject`) to `reference`. * - `actions[].object` — the action schema's own rejection says "Did you mean * `object` → `objectName`?". + * - `objects[].fields[].formula` / `.calculation` / `.compute` — the three names + * `field.zod.ts:333` aliases back to `expression`. The field-formula pass read + * `formula` until #5026, so it had never run against a stack that parses; + * converging it onto `expression` ACTIVATED the check rather than deleting a + * dead branch, which is why it moved on its own PR with a real-metadata sweep + * attached rather than riding along with #5017's five removals. * * Alias tolerance belongs at the schema's refusal, not in a consumer (Prime * Directive #12) — in a consumer it also converts a loud, named rejection into * a silently-inert (or, above, silently-WRONG) gate. * - * One read here is still undeclared and is tracked rather than fixed in place: - * the field-formula pass below reads `f.formula`, which `FieldSchema` rejects - * in favour of `expression`. Converging it would ACTIVATE a check that has - * never run against a parsing stack, which is a coverage change, not dead-code - * removal — see the note at that call site and the tracking issue. + * Every read in this file is now a key the spec declares; the meta-guard in + * `validate-expressions.test.ts` pins that with no tracked exceptions left. */ import { validateExpression, collectCelRootIdentifiers } from '@objectstack/formula'; @@ -582,7 +586,15 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { objectName, 'fail-open', ); - if (f.formula) { + if (f.expression) { + // `expression` is the key `FieldSchema` declares for a computed field — + // what `Field.formula({ expression: … })` writes. This read was spelled + // `formula` until #5026, one of the names `field.zod.ts:333` REJECTS by + // aliasing it back to `expression`, so the whole pass had never run + // against a stack that parses. Converging it ACTIVATED a check rather + // than deleting a dead branch — the real-metadata sweep that had to + // accompany that is in #5026's PR body. + // // formulas are `value` role (any return type), still CEL. They are // `record`-scoped — `record.`, never bare — so flag bare refs (#1928). // @@ -597,9 +609,12 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // writes `(record.budget == null ? 0 : record.budget) - …`), so the cost of // deciding later is low. Raise it as its own issue rather than widening // this call. Ledger: `validate-null-guards.ts`. - const res = validateExpression('value', f.formula as string | { dialect?: string; source?: string }, + const res = validateExpression('value', f.expression as string | { dialect?: string; source?: string }, objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: 'record' } : { scope: 'record' }); - const fieldWhere = `object '${objectName}' · field '${fname}' formula`; + // Names the KEY the author edits, not the field type. Saying "formula" + // here is how the wrong spelling propagates: the next author reads the + // diagnostic and writes `formula:`, which the schema then rejects. + const fieldWhere = `object '${objectName}' · field '${fname}' expression`; for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' }); for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' }); } diff --git a/packages/lint/src/validate-null-guards.ts b/packages/lint/src/validate-null-guards.ts index 63c8e7d04f..952e69ad1f 100644 --- a/packages/lint/src/validate-null-guards.ts +++ b/packages/lint/src/validate-null-guards.ts @@ -78,7 +78,7 @@ * | action `visible` / `disabled` | sparse | evaluated client-side; no materialization exists in `objectui` | excluded | * | flow / edge `condition` | sparse | `record-change-trigger.ts` seeds `{...inputDoc, ...after}` | excluded | * | sharing-rule `condition` | n/a | compiled to a SQL filter; `NULL > x` is three-valued, never faults | excluded | - * | `Field.formula` | n/a | product judgement, not a wiring gap — see below | excluded | + * | field `expression` (`Field.formula`) | n/a | product judgement, not a wiring gap — see below | excluded | * * The three exclusions that are *not* self-evident, spelled out because a * surface excluded without a reason is indistinguishable from one nobody @@ -107,11 +107,16 @@ * (The flattened-scope ambiguity is real for a *bare-identifier* checker — * flow inputs shadow record fields, and a node's `outputVariable` can * overwrite either — but that is a different, unbuilt pass.) - * - **`Field.formula`.** Excluded by product judgement, not by this criterion: - * a formula is `value`-role and natively nullable, and `guard ? value : null` - * is the blessed shape (#3306 rewrites it via `dyn(...)`). Making guarded - * arithmetic mandatory there would change what authors are *allowed to - * write*, which is a decision for the maintainer, not a wiring gap to close. + * - **Field `expression`** (the slot `Field.formula({ expression: … })` + * writes; spelled `formula` here until #5026 renamed the read to the key + * `FieldSchema` actually declares). Excluded by product judgement, not by + * this criterion: a formula is `value`-role and natively nullable, and + * `guard ? value : null` is the blessed shape (#3306 rewrites it via + * `dyn(...)`). Making guarded arithmetic mandatory there would change what + * authors are *allowed to write*, which is a decision for the maintainer, + * not a wiring gap to close. Note this exclusion is about the NULL-GUARD + * verdict only — since #5026 the slot does carry the syntax / + * field-existence / bare-reference verdicts, which it never did before. */ import { Environment } from '@marcbachmann/cel-js';