Skip to content

Commit f85a83b

Browse files
fix(lint,objectql)!: the object save door gives the build's formula verdict, and a formula fault is logged once per object and field (#22031)
Fixes #22019 Clause-②: no (narrowing) ## What changes **The save door gives the build's formula verdict.** `formulas.mdx` says "the same `validateExpression` validator backs `os build` and metadata registration". At the object save door it did not. An object whose formula field calls an unregistered function (`sqrt(record.amount)`) saved with a 200, and the field read `null` on every row. - **The cause was one registry declaration, not the door's code.** `saveMetaItem` (publish mode) and the draft promotion already run the runtime authoring gate. That gate runs the rules the author-time registry declares for the written type. The build's expression rule, `validateStackExpressions` (`packages/lint/src/authoring-rules.ts`), declared `runtimeTypes: ['flow', 'action', 'hook']`. So `runtimeAuthoringRulesFor('object')` never dispatched it, and its field-formula pass, the call `os build` makes, never ran on an object write. - **The fix is in the producer, `@objectstack/lint`.** The entry now declares `object` as well, and it passes the gate's write type to the rule. On an `object` write the rule runs one pass: the field-formula pass over `fields[].expression`. It reuses the build's own `validateExpression('value', …)` call with the build's scope (the object's fields and field types, `record` scope). It also keeps that pass's warnings and the unprovisioned-anchor warning on the same key. Every other object-borne pass the build runs stays off this door, by name. Those are: validation-rule predicates, the field-rule slots (`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`) with their `parent` and null-guard gates, option `visibleWhen`, and the object's own action predicates. See the out-of-scope finding below. - **The verdict is the build's finding.** The door's 422 issue and `runAuthoringRules('build', …)` give the same rule (`expression-invalid`), location (`object 'fx_sqrt' · field 'score' expression`), message and hint. - **The published signature does not move.** `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through `runStackExpressionPasses(stack, options)`, the one body both run, which is not on the package's entry. - **No code change in `packages/metadata-protocol`.** The landing moved there from the claim's file surface because the producer is the registry, measured under H1 below. The door's code was already right: it holds no rules by design, and `authoring-rule-wiring.test.ts` refuses a door that names one. **The read path no longer swallows the fault.** `applyFormulaPlan` (`packages/objectql/src/engine.ts`) mapped a failed evaluation to `null` with no log line. It now hands the field and the evaluator's error to a sink. The engine binds that sink to the object, at `find`, at `findOne` and at the write response (`hydrateWriteFormulas`). `ObjectQL.reportFormulaFault` logs one `warn` per (object, field) per engine instance. The line names the object, the field and the error's kind and first line, and the full message goes in the metadata. The value stays `null`. Stored rows are not migrated or refused: a row stored before the gate keeps reading until its object is next saved. Measured line, for `sqrt(record.amount)` on `fx_sqrt.score`: ```text formula field 'score' on 'fx_sqrt' could not be evaluated, so it reads null wherever this fault holds (runtime: found no matching overload for 'sqrt(dyn)'). If the expression itself is at fault — an unknown function, a missing field — `os validate`, or a re-save of the object, refuses it with a located message; if it faults on some records' values, guard the operands it reads. Reported once per object and field per engine instance. ``` ## Pins - (a) **The door refuses `sqrt(record.amount)`** with a 422 `INVALID_METADATA` that carries the build's located finding, and nothing lands. This is pinned through the real `saveMetaItem`, and through the real `publishMetaItem` after a draft save, which is never gated. File: `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, block "the formula field verdict". - (b) **`floor(record.amount)` still saves**, and the row lands. - (c) **A row stored before the gate still reads.** The object is registered straight into the registry and the rows are written straight to the store. `find`, `findOne` and the write response read `null`, and the logger gets one line naming the object and the field, however many rows and reads. A second faulting field gets its own line, a formula that evaluates logs nothing, and a second engine instance says it again. File: `packages/objectql/src/engine-formula-fault-log.test.ts`. - (d) **The door and `os build` give the same verdict text.** Rule, where, path, message and hint are compared key by key against `runAuthoringRules('build', …)`. - **The lint door and its fence** are in `packages/lint/src/runtime-gate.object-formula-writes.test.ts`. They cover the refusal, the field-existence half, the control, door/build parity, and the differential (a stored sibling's broken formula is not this write's). The fence is a body with a fault in each fenced pass: the build flags every one, and the object door flags none. The object door roster pin in `runtime-gate.object-writes.test.ts` gains `validateStackExpressions` as a ruled join. - **No dogfood case.** A door-level pin is not needed. The REST mapping of the protocol's 422 `INVALID_METADATA` on `PUT /meta/object` is already pinned (`packages/rest/src/meta-object-owd-gate.test.ts`), and (a) runs the protocol door itself. ## Reverse verification (one-off, from committed HEAD `fca16e0688`) - **What was removed.** `'object'` was taken out of the entry's `runtimeTypes` with `scripts/ablation-replace.mjs`: anchor hit 1 time, 1 → 0, blob `5dd250340913` → `d1ae5ae5f889`. - **Rebuild and dist proof.** `@objectstack/lint` was rebuilt. `ablation-dist-preflight` on the pristine HEAD build found the marker present in 4 built files. On the mutated build, `--absent --source-marker=…` found it absent from all 14 built files. - **The door (dist-mediated): red as predicted.** (a) ×2 and (d) went red: 3 failed, 1 passed. (b) stayed green. - **The lint door test (source): 6 failed, 3 passed.** - **Restore.** The source was restored: blob `5dd250340913` equals HEAD, and `git diff HEAD` is empty (0 bytes). Lint was rebuilt and the preflight found the marker back in 4 built files. Both suites went green again: door block 4 passed, lint 9 passed. - **(c), a separate ablation.** The sink call (`onFault?.(fp.name, r.error)`) was deleted, and the objectql pin went 5 failed / 1 passed. The case still green is the one that pins the unchanged `null` answer. Restored blob-equal, and `git diff HEAD` empty. ## Measurements (H1–H4) - **H1: confirmed in part, falsified in part.** The save path judges an object body at `assertRuntimeAuthoringRules`: `packages/metadata-protocol/src/protocol.ts:20410` for `saveMetaItem` and `:21973` for the draft promotion. That calls `evaluateRuntimeAuthoringGate` (`runtime-authoring-gate.ts:897`), which calls `runRuntimeAuthoringRules` (`packages/lint/src/runtime-gate.ts:917`), filtered by `runtimeAuthoringRulesFor` (`:550`). Save and publish therefore already share one gate. The falsified half: the check does not belong in the door. The build's entry is the registry rule `validateStackExpressions`. Its formula call is at `packages/lint/src/validate-expressions.ts:2043` at base (`:1957` at head), and the dispatch gap is the declaration at `authoring-rules.ts:574` at base. The fix reuses that call and copies nothing. - **H2: these are the object-borne expression sites the build judges** (`validate-expressions.ts`): - `validations[].condition` and `.when`, with null guards over `then` and `otherwise`; - `fields[].requiredWhen`, `readonlyWhen`, `conditionalRequired` and `visibleWhen`, with the root verdict, the `parent` gate, the `requiredWhen` null guard and the traversal refusal; - `fields[].options[].visibleWhen`; - `fields[].expression`, with the unprovisioned-anchor warning; - `actions[].visible` and `actions[].disabled`. - Default values are NOT judged by the build, so the hypothesis's "default values" is falsified. Only the formula pass is mirrored. The rest is the out-of-scope finding below. - **H3: confirmed.** At base, `engine.ts:2255` mapped a fault to `null` with no log, and the planning compile at `:1562` discards its result. Two per-key log-once shapes exist. One is the module-global `warnOnce` (`validation/record-validator.ts:1755`, `console.warn`, per process). The other is the engine's per-instance sets (`transactionUnsupportedReported` `:3683` and `cascadeNotAtomicReported` `:3692`, used by `warnCascadeNotAtomic` `:15911` through `this.logger.warn`). Neither is a callable helper. The new report follows the per-instance engine shape (`formulaFaultReported`, `reportFormulaFault`), so it goes through the engine's logger. - **H4: confirmed; the stop condition was not met.** Every stored formula field in this repository was judged by the build's pass and by the door's own function at its snapshot shape: 29 fields on 28 objects. That is examples 7 on 6 (`app-crm` 4 on 3, `app-showcase` 2 on 2, `app-todo` 1 on 1, `app-multi-package` none) and the platform `display_title` formulas 22 on 22. The result was 0 build errors, 0 build warnings, 0 door errors and 0 door advisories. The card's `sqrt` body, used as a positive control in the same harness, gave 1 build error and 1 door error. No templates carry formula fields. ## Clause-② (measured) - **Accept set: narrowing.** An object write in publish mode, including the draft promotion and the package draft publish, answered 200 for a formula field whose expression the validator refuses. It now answers 422. - **Built entry declarations, base vs head**, each package rebuilt from base sources and then restored, blob-equal: - `@objectstack/lint`: one doc comment added (`AuthoringRuleContext.runtimeWriteType`), plus chunk-hash renames. No exported signature moves. - `@objectstack/objectql`: three `private` member names on `ObjectQL` (`formulaFaultReported`, `formulaFaultSink`, `reportFormulaFault`), plus chunk-hash renames. - `@objectstack/metadata-protocol`: no source change. - **Changesets.** `.changeset/22019-object-save-door-formula-verdict.md` covers `@objectstack/lint` and `@objectstack/metadata-protocol`: `minor`, BREAKING, with the remedy and the ADR-0087 disposition `not-required (no-migration-prescription)`. `.changeset/22019-objectql-formula-fault-log.md` covers `@objectstack/objectql` as a `patch`. ## Tests and gates (at `fca16e0688`) - **Package tests:** - `@objectstack/lint`: 120 files, 5638 tests passed. - `@objectstack/metadata-protocol`: 219 files passed and 3 skipped; 28049 tests passed and 19 skipped. - `@objectstack/objectql`: 379 files, 7513 tests passed. - `typecheck` passed for all three packages. - **Gates.** The `dispatch-gates.mjs --commands` list was re-derived at this head (68 commands, unchanged from the derivation at `2850ecbec6`). It was run together with the artifact-roster block (54) and the four symbol-anchor sweeps: 125 commands in all, all green. Two notes on that run: - `check:dual-build-cjs-loads` and `check:published-readme-exports` first answered PREREQUISITE NOT MET. They were re-run green after a full workspace build. - `check:engine-double-contract` and `check:objectql-double-limit` were red on a first draft that carried its own fake engine. The pins now ride an already-pinned double, and the objectql fake driver applies the caller's bound after its filter. - **PR-body gates.** `check-partof-closing-keyword` and `check-changeset-no-major --event` were run against this body before it was posted. `check-closing-target-claim` and `check-single-claim-paths` need this PR's number, so their readings are in the report on the card. ## Acceptance notes - **The planning compile in `planFormulaProjection` (`engine.ts:1562`) does nothing.** `ExpressionEngine.compile` never throws (the CEL engine catches and returns `{ ok: false }`), and its result is not read. Its comment ("surface syntax errors at planning stage") and the `evaluateFormulaField` docblock ("a formula that does not COMPILE throws") describe a throw that never happens. This PR leaves both untouched. No carrier. - **`evaluateFormulaField`, the exported hook-side helper with no engine, still answers `null` without a line.** The changeset says so. No carrier. - **A comment is stale.** `protocol.runtime-authoring-gate.test.ts:360` says "ALL SEVEN object-gated rules", which was already nine before this PR and is ten after it. No carrier. - **Docs.** `formulas.mdx` was not edited. Its promise ("backs `os build` and metadata registration") now holds for formula fields. The "Build-time validation" section names `registerFlow` as the second door and could name the object save door too: a docs addition, not a false line. - **QA loop.** On landing, `api-backend.formula-stdlib-matrix` A6 scores the new behaviour. The item goes on #22017's retest list. ## Out-of-scope finding (reported for the seat to file; not filed here) `class: b` — **the object save door still gives no build verdict on the other object-borne expressions.** Those are: validation-rule predicates, the field-rule slots, option `visibleWhen`, and the object's action predicates. The contract is the same `formulas.mdx` sentence. - **Seam:** `spec:ObjectSchema.validations[].condition / FieldSchema.requiredWhen → runtime:runtimeAuthoringRulesFor('object') (packages/lint/src/runtime-gate.ts)`. - **reach:** measured through the real `saveMetaItem` in publish mode. An object with `validations[].condition: 'sqrt(record.amount) > 1'` and `requiredWhen: 'amount > 1'` (a bare reference) saved with success. `os build`'s entry refused both at error: "found no matching overload for 'sqrt(dyn)'" and "bare reference `amount`". - **Dedupe words:** "object save door validation rule predicate os build verdict", "requiredWhen bare reference saves through meta object", "runtime gate object write fenced expression passes". --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 099a94d commit f85a83b

9 files changed

Lines changed: 772 additions & 50 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019)
7+
8+
Clause-②: no (narrowing)
9+
10+
`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row.
11+
12+
The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
17+
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write.
18+
19+
**Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
20+
21+
**Unchanged.**
22+
23+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row.
24+
- The other expressions an object carries are still not judged at this door: validation-rule predicates, the field-rule slots (`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`), option `visibleWhen`, and the object's own action predicates. `os build` judges them, and the door does not, as before. Each needs its own crossing, measured over the stored corpus first.
25+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
26+
- Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22.
27+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`.
28+
29+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the object save door of a formula expression the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose formula the validator refuses keeps reading until it is next saved, and the repair is the author's edit of the expression, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a formula field that does not evaluate is logged once per object and field, instead of reading `null` in silence (#22019)
6+
7+
A formula the engine cannot evaluate reads `null`, on `find`, on `findOne` and on the write response. Before this change nothing said why. A formula calling an unregistered function (`sqrt(record.amount)`) read `null` on every row with no log line anywhere. ADR-0032 says a call site must not silently swallow an expression fault.
8+
9+
The engine now reports the fault through its logger at `warn`, once per (object, field) per engine instance, however many rows and reads hit it. The line names the object, the field and the evaluator's error (kind and first line; the full message is in the log metadata). It also says where the repair is: `os validate` or a re-save of the object refuses an expression-level fault with a located message, and a fault that depends on a record's values needs a guard on the operands it reads.
10+
11+
Unchanged: the field still reads `null`, because what a read returns is protocol. `evaluateFormulaField`, the hook-side helper with no engine, still returns `null` without a log line. The built entry declarations gain three `private` member names on `ObjectQL`.

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
// Imported per-module, never through `./index.js`: the barrel would make this
9898
// file a cycle partner of its own package entry, and the runtime surface
9999
// (`./runtime.js`) needs a graph it can reason about rule by rule.
100-
import { validateStackExpressions } from './validate-expressions.js';
100+
import { runStackExpressionPasses } from './validate-expressions.js';
101101
import { validateListViewMode } from './validate-list-view-mode.js';
102102
import { validateFunctionalCompleteness } from './validate-functional-completeness.js';
103103
import { validateManagedApiMethods } from './validate-managed-api-methods.js';
@@ -306,6 +306,12 @@ export interface AuthoringRuleContext {
306306
* members can judge a partial per-write snapshot without inventing
307307
* findings. No other rule reads it, and none should without the same
308308
* argument.
309+
*
310+
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
311+
* is one entry over several PASSES, and an `object` write is admitted for its
312+
* field-formula pass alone (`runStackExpressionPasses`, `StackExpressionOptions`). The
313+
* entry-level `runtimeTypes` can say that an object write reaches the rule; it
314+
* cannot say which of the rule's passes judge that write.
309315
*/
310316
runtimeWriteType?: string;
311317
/**
@@ -570,10 +576,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
570576
// crossing: 79 actions and 6 hooks (showcase 70/4, todo 8/1, crm 1/1) →
571577
// 0 differential findings, with lit synthetic probes refused per type in
572578
// `runtime-gate.inert-type-writes.test.ts`.
579+
//
580+
// [#22019] `object` joins, for ONE pass: a formula field's `expression`.
581+
// `formulas.mdx` says the same `validateExpression` validator backs
582+
// `os build` and metadata registration; at the object save door it did
583+
// not, so `sqrt(record.amount)` — refused by `os build` as an unknown
584+
// function — saved with a 200 and read `null` on every row, logged
585+
// nowhere. The door now gives the build's verdict, from this entry, in the
586+
// build's words (rule, location, message and hint are the same finding).
587+
//
588+
// NARROW by construction, not by snapshot shape: `ctx.runtimeWriteType`
589+
// reaches `runStackExpressionPasses` — the body `validateStackExpressions`
590+
// runs, whose public signature is unchanged — which on an object write runs
591+
// the field-formula pass and fences every other object-borne expression
592+
// pass off by name (`StackExpressionOptions.runtimeWriteType`) — each of
593+
// those is a crossing of its own, not a rider on this one.
594+
//
595+
// MEASURED over the stored corpus at the door's own snapshot shape before
596+
// crossing: every formula field the repository ships — 29 fields on 28
597+
// objects (examples: app-crm 4 on 3, app-showcase 2 on 2, app-todo 1 on 1,
598+
// app-multi-package none; platform `display_title` formulas: 22 on 22) →
599+
// 0 differential errors and 0 advisories, against 1 refusal for the card's
600+
// own `sqrt(record.amount)` body under the same harness.
573601
surfaces: CLI_AND_RUNTIME,
574-
runtimeTypes: ['flow', 'action', 'hook'],
575-
run: (stack) =>
576-
validateStackExpressions(stack).map((i) => ({
602+
runtimeTypes: ['flow', 'action', 'hook', 'object'],
603+
run: (stack, ctx) =>
604+
runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({
577605
severity: i.severity ?? 'error',
578606
rule: EXPRESSION_INVALID,
579607
where: i.where,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22019 — the OBJECT write door runs the build's field-formula pass, and only
5+
* that pass.
6+
*
7+
* ## The state this closes
8+
*
9+
* `validateStackExpressions` is the build's expression rule. Its field-formula
10+
* pass calls `validateExpression('value', …)` on every `fields[].expression` —
11+
* the verdict `formulas.mdx` says backs both `os build` and metadata
12+
* registration. The entry declared `runtimeTypes: ['flow', 'action', 'hook']`,
13+
* so on an `object` write the gate never dispatched it: a formula calling an
14+
* unregistered function (`sqrt(record.amount)`) published clean and read
15+
* `null` on every row.
16+
*
17+
* ## The crossing, and its fence
18+
*
19+
* `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the
20+
* rule (`runStackExpressionPasses`), which on an object write runs the
21+
* field-formula pass alone. Every other object-borne pass the build runs —
22+
* validation-rule predicates, the field-rule slots, option `visibleWhen`, the
23+
* object's own action predicates — is FENCED off this door by name, and the
24+
* fence is pinned below with the build still flagging the same body, so a
25+
* later widening moves that line consciously rather than by drift.
26+
*
27+
* The protocol-level half — the same verdict through the real `saveMetaItem`
28+
* and `publishMetaItem`, and the door/build equality of the finding — is
29+
* the #22019 block of `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`.
30+
*/
31+
import { describe, expect, it } from 'vitest';
32+
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
33+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
34+
import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js';
35+
36+
/** The card's object, with the formula under test. `sharingModel` keeps `security-owd-unset` quiet. */
37+
const fxSqrt = (expression: string, over: Record<string, unknown> = {}) => ({
38+
name: 'fx_sqrt',
39+
label: 'Formula Probe',
40+
sharingModel: 'private',
41+
fields: {
42+
name: { type: 'text', label: 'Name' },
43+
amount: { type: 'number', label: 'Amount' },
44+
score: { type: 'formula', label: 'Score', expression },
45+
},
46+
...over,
47+
});
48+
49+
const WHERE = "object 'fx_sqrt' · field 'score' expression";
50+
51+
const gateObject = (item: unknown) =>
52+
runRuntimeAuthoringRules({ type: 'object', item, context: { objects: [] } });
53+
54+
const expressionFindings = <T extends { rule: string }>(fs: readonly T[]): T[] =>
55+
fs.filter((f) => f.rule === EXPRESSION_INVALID);
56+
57+
const dump = (r: unknown) => JSON.stringify(r, null, 2);
58+
59+
describe('#22019 — the object door dispatches the build\'s expression rule', () => {
60+
it('`validateStackExpressions` is on the object door', () => {
61+
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
62+
});
63+
64+
it('⭐ LIT — a formula calling an unregistered function (`sqrt`) is REFUSED, located at the key the author edits', () => {
65+
const result = gateObject(fxSqrt('sqrt(record.amount)'));
66+
67+
expect(result.rulesRun).toContain('validateStackExpressions');
68+
const errs = expressionFindings(result.errors);
69+
expect(errs, dump(result)).toHaveLength(1);
70+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE, path: WHERE });
71+
expect(errs[0]!.message).toContain('`sqrt` is not a callable name here');
72+
});
73+
74+
it('⭐ LIT — a formula reading a field the object has not got is REFUSED (the same pass, its field-existence half)', () => {
75+
const result = gateObject(fxSqrt('floor(record.amont)'));
76+
77+
const errs = expressionFindings(result.errors);
78+
expect(errs, dump(result)).toHaveLength(1);
79+
expect(errs[0]).toMatchObject({ where: WHERE });
80+
expect(errs[0]!.message).toContain('amont');
81+
});
82+
83+
it('⭐ CONTROL — a registered call (`floor(record.amount)`) publishes clean', () => {
84+
const result = gateObject(fxSqrt('floor(record.amount)'));
85+
86+
expect(result.rulesRun).toContain('validateStackExpressions');
87+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
88+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
89+
});
90+
91+
it('⭐ PARITY — the door finding IS the build finding for the same body', () => {
92+
const body = fxSqrt('sqrt(record.amount)');
93+
const atDoor = expressionFindings(gateObject(body).errors);
94+
const stack = { objects: [body] };
95+
const atBuild = expressionFindings(runAuthoringRules('build', { normalized: stack, parsed: stack }));
96+
97+
expect(atBuild).toHaveLength(1);
98+
expect(atDoor).toEqual(atBuild);
99+
});
100+
101+
it('a stored sibling\'s broken formula is not this write\'s to answer for (the differential)', () => {
102+
const sibling = { ...fxSqrt('sqrt(record.amount)'), name: 'fx_sibling' };
103+
const result = runRuntimeAuthoringRules({
104+
type: 'object',
105+
item: fxSqrt('floor(record.amount)'),
106+
context: { objects: [sibling] },
107+
});
108+
109+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
110+
});
111+
});
112+
113+
describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => {
114+
/**
115+
* One body carrying a fault in each fenced pass, and a CLEAN formula. The
116+
* build flags every one of them; the object door flags none. Each fault is
117+
* one the build refuses at `error`, so "the door is silent" cannot be read
118+
* as "there was nothing to say".
119+
*/
120+
const fenced = () => fxSqrt('floor(record.amount)', {
121+
validations: [
122+
{ name: 'amount_root', type: 'script', condition: 'sqrt(record.amount) > 1', message: 'x', severity: 'error' },
123+
],
124+
actions: [
125+
{ name: 'fx_close', label: 'Close', type: 'script', target: 'close', visible: 'record.status ==' },
126+
],
127+
});
128+
const withFieldRule = () => {
129+
const body = fenced();
130+
(body.fields as Record<string, unknown>).name = {
131+
type: 'text', label: 'Name', requiredWhen: 'amount > 1',
132+
};
133+
return body;
134+
};
135+
136+
it('the build (no `runtimeWriteType`) still flags each fenced site', () => {
137+
const wheres = validateStackExpressions({ objects: [withFieldRule()] })
138+
.filter((i) => (i.severity ?? 'error') === 'error')
139+
.map((i) => i.where);
140+
141+
expect(wheres.some((w) => w.includes("validation 'amount_root'")), dump(wheres)).toBe(true);
142+
expect(wheres.some((w) => w.includes("field 'name' requiredWhen")), dump(wheres)).toBe(true);
143+
expect(wheres.some((w) => w.includes("action 'fx_close'")), dump(wheres)).toBe(true);
144+
expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false);
145+
});
146+
147+
it('the object door flags none of them — only a formula field\'s `expression` is judged there', () => {
148+
const result = gateObject(withFieldRule());
149+
150+
expect(result.rulesRun).toContain('validateStackExpressions');
151+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
152+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
153+
});
154+
155+
it('the narrowing is the rule\'s, keyed on the write type — a flow write still runs every pass it ran', () => {
156+
// `runtimeWriteType: 'flow'` is what the gate passes on a flow write; the
157+
// option narrows ONLY on `object`, so nothing about the three existing
158+
// doors moves.
159+
const stack = { objects: [withFieldRule()] };
160+
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'flow' })).toEqual(validateStackExpressions(stack));
161+
// And the object pass set is a strict subset of what the build reports.
162+
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object' })).toEqual([]);
163+
});
164+
});

‎packages/lint/src/runtime-gate.object-writes.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
111111
// distinguishable, and a rule silently joining or leaving this door is
112112
// precisely the drift this pin exists to catch.
113113
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toEqual([
114+
// [#22019] The build's expression rule joins, for ONE of its passes: a
115+
// formula field's `expression` — the `validateExpression` verdict the
116+
// docs say backs metadata registration. Its other object-borne passes are
117+
// fenced off this door inside the rule (`StackExpressionOptions`), and
118+
// that fence is pinned in `runtime-gate.object-formula-writes.test.ts`.
119+
'validateStackExpressions',
114120
'validateFunctionalCompleteness',
115121
'validateManagedApiMethods',
116122
'validatePresetComparands', // #8793 — at this door before #4716
@@ -391,6 +397,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
391397
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
392398
// "clean" and "nothing ran" must stay distinguishable.
393399
expect(result.rulesRun).toEqual([
400+
'validateStackExpressions', // [#22019] — see the roster pin above
394401
'validateFunctionalCompleteness',
395402
'validateManagedApiMethods',
396403
'validatePresetComparands',

0 commit comments

Comments
 (0)