You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 94379f4
Browse filesBrowse the repository at this point in the historyBrowse files
fix(formula)!: the unknown-field check judges every member spelling of record / previous, not only the dot (#22428) (#22494)
Fixes#22428
Clause-②: yes (narrowing)
## What changed
`validateExpression`'s field-existence pass (`checkFieldExistence` in
`packages/formula/src/validate.ts`) found the members an expression
reads on `record` / `previous` with a dot-only regex. It now reads them
through the AST member reader that the relationship-traversal analysis
uses. Every spelling that names a member gets the dot spelling's
verdict, at every slot the pass judges:
- `record.FIELD`, `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`,
`record[?'FIELD']`, the same on `previous`, and any of them inside
`has(...)`;
- the attached-on-read leaf check from #22425 (`record.BLOCK.LEAF`
against `ObjectSchema.attachedOnRead`) rides the same reads, so
`record.viewer['can_actt']`, `record.viewer.?can_actt` and
`record['viewer'].can_actt` are judged like `record.viewer.can_actt`.
There is no lint arm. `@objectstack/lint`'s slot walk is unchanged, so
`os build` and the object save door give the shared validator's verdict,
as they already did for the dot spelling.
### One reader, one parse
`relationship-traversal.ts` now has one lower-level reader,
`readRootMembers(ast, roots)`. It walks a parsed AST once and reports
each read of a member of the named roots in source order, with the
member, the next segment when there is one, and whether the read goes
deeper. Both questions fold over it:
- `analyzeRelationshipTraversals(source, root)` is `parseCelToAst` plus
`traversalsOf(readRootMembers(ast, [root]))`. Its signature and answers
are unchanged (equivalence below).
- `checkFieldExistence` reads `readRootMembers(ast, ['record',
'previous'])`.
- `validateExpression` parses once, lazily, and hands the same AST to
the field-existence pass and to the traversal-conflict arm. That arm
used to parse a second time through `analyzeRelationshipTraversals`.
`readRootMembers` and `traversalsOf` are module exports only.
`src/index.ts` does not change, and `dist/index.d.ts` has zero mentions
of either.
Measured on the as-is reuse question (dispatch mechanism assumption 2):
`analyzeRelationshipTraversals` could answer existence as written (first
segments = `bareFields` plus the keys of `traversals`; leaves =
`traversals.get(block)`), but only one root per call, so two parses per
expression, and its sets drop source order. That is why the reader was
extracted rather than reused as it was.
### What gets no verdict (dispatch mechanism assumption 5)
- A computed key (`record[record.kind]`, `record[someVar]`) names no
member before evaluation. The reader reports no read for it, so there is
no finding. This is pinned
(`validate-field-existence-spellings.test.ts`, "gives a computed key no
verdict"; `validate-attached-on-read.test.ts`,
`record.viewer[record.status]`).
- A method call on a member (`record.name.startsWith('A')`) still judges
the member.
- Three shapes the regex misread as a member of the root are no longer
refused, because none of them names one: text inside a string literal
(`record.name == 'record.typo'`), a root name after another root
(`vars.record.x`), and a method call on the root itself
(`record.size()`). Measured: the regex reported `typo`, `x` and `size`
as unknown fields, and the AST reader reports nothing for each. This is
a small widening inside the narrowing, and the changeset states it.
## Premise, measured at both doors before the change (`origin/main`
`8b713fad78`, which includes `9af0005d55`)
Probe: an object with fields `name` and `tier`, the expression on one
slot. "build" is `validateStackExpressions`. "door" is
`runRuntimeAuthoringRules({ type: 'object' })` (the gate `saveMetaItem`
runs). Counts are `expression-invalid` errors.
| spelling (`zz_typo` undeclared) | option `visibleWhen` build / door |
`requiredWhen` build / door | validation `condition` build / door |
|---|---|---|---|
| `record.zz_typo == 'a'` (control) | 1 / 1 | 1 / 1 | 1 / 1 |
| `record['zz_typo'] == 'a'` | **0 / 0** | **0 / 0** | **0 / 0** |
| `previous['zz_typo'] == 'a'` | **0 / 0** | **0 / 0** | **0 / 0** |
| `record.?zz_typo.orValue('') == 'a'` | **0 / 0** | **0 / 0** | **0 /
0** |
| `record[?'zz_typo'].orValue('') == 'a'` | **0 / 0** | **0 / 0** | **0
/ 0** |
| `has(record.zz_typo)` | 1 / 1 | 1 / 1 | 1 / 1 |
After the change, every row reads `1 / 1` in all three slots. Every
spelling of the declared `name` reads `0 / 0` before and after.
Two premise details came out differently from the card:
- `has(record.x)` was already refused, because the regex saw the dot
inside `has(...)`. It is pinned so it stays refused.
- A bare `record.?zz_typo == 'a'` is refused at compile (`invalid-cel`:
cel-js finds no `==` overload for an optional of `dyn` against a
string). The optional spelling that passed was
`record.?zz_typo.orValue(...)`, and that is the one the pins use.
## Pins
- `packages/formula/src/validate-field-existence-spellings.test.ts`
(new, 31 tests):
- 6 spellings × 2 roots refused, with `code: 'unknown-field'` and
params;
- the same 12 on a declared field, accepted;
- did-you-mean in a bracket spelling;
- one finding per name across spellings and roots, in source order;
- a comprehension body;
- a computed key gets no verdict;
- a string literal is not a read;
- no field list means no verdict.
- `packages/formula/src/validate-attached-on-read.test.ts`: the pin that
held "index read unjudged" is replaced. The leaf is now judged in 6
non-dot spellings, with the declared leaf as control in 4. A computed
key, a method call and a third segment stay unjudged.
- `packages/formula/src/relationship-traversal.test.ts`: 5 tests on
`readRootMembers`:
- multi-root source order;
- leaf and deeper;
- the three non-reads;
- a method receiver;
- `analyzeRelationshipTraversals` equals the fold of its reads.
-
`packages/lint/src/runtime-gate.object-field-existence-spellings.test.ts`
(new, 36 tests): 3 slots (option `visibleWhen`, `requiredWhen`,
validation `condition`) × 6 spellings. Each is refused at the build's
rule table (`runAuthoringRules('build')`) and at the object door
(`runRuntimeAuthoringRules`), and each assertion checks the named
subject, the location and the severity. A declared field in every
spelling publishes clean at both doors.
## Reverse verification (committed fix first, `HEAD` `57efbdd5f6`)
The mutation went through `scripts/ablation-replace.mjs`. One anchor
(`for (const read of readRootMembers(tree, FIELD_EXISTENCE_ROOTS)) {`, 1
hit, then 0) was replaced with the old regex reader: the dot-only head
regex plus the sticky dot-only second-segment regex. Blob `3fc4e3033d47`
became `383b80a635d8`. `@objectstack/formula` was rebuilt, and
`scripts/ablation-dist-preflight.mjs` showed the marker in
`dist/index.js` and `dist/index.mjs`. The lint pins resolve formula
through `dist/`.
| suite | mutant (regex reader) | restored |
|---|---|---|
| formula: `validate-field-existence-spellings` +
`validate-attached-on-read` | **13 failed** / 28 passed | 41 passed |
| lint: `runtime-gate.object-field-existence-spellings` | **12 failed**
/ 24 passed | 36 passed |
The direction was as predicted:
- Red: every bracket, `previous[...]`, `.?` and `[?]` row (3 slots × 4
in lint), the non-dot leaf spellings, did-you-mean in a bracket, source
order, the comprehension case and the string-literal case.
- Green: the dot and `has()` rows, and every control.
Restore was proven by `ablation-replace` (blob equal to `HEAD`, `git
diff HEAD` empty) and by `ablation-dist-preflight --absent` (marker
absent from all 6 built files, tree clean).
Disclosed: the first mutant run failed its DTS build on an unused
constant (`TS6133 FIELD_EXISTENCE_ROOTS`). The JS bundles were emitted
and carried the marker, and the red counts were the same. The run was
repeated with the constant referenced, so the mutant build exited 0. The
numbers above come from that clean run.
## Equivalence of the extracted reader
The `BASE` version of `relationship-traversal.ts` was run against this
branch's version over every string literal that mentions
`record`/`previous` member access in the repository's `packages/**` and
`examples/**` TypeScript: 1,174 files and 2,264 expressions, each
analysed for both roots, 4,528 analyses in total. 2,664 analyses parsed
and 1,322 were non-empty. The two versions differed **0** times,
comparing `traversals`, `bareFields` and `multiHopFields` with their
insertion order.
## Population census (dispatch mechanism assumption 4)
- **Text census**, over all tracked files (`git grep`): CEL in metadata
that spells a `record`/`previous` member with a bracket, `.?` or `[?]`
exists only in test fixtures. Each one names a declared field, has no
field list in its hint, or goes through a path that does not run this
check. That is an ObjectQL engine fixture registered straight into the
registry, where the dot form of the same key also passes. No example,
app template, platform object, plugin object or `skills/**` file uses
such a spelling.
- **Differential run** of `validateStackExpressions`:
- Stacks covered: `examples/app-todo`, `examples/app-crm`, the two
`examples/app-multi-package` sub-stacks, the objects, actions, flows,
views and pages of `examples/app-showcase`, the 51 objects
`@objectstack/platform-objects` exports, and `plugin-approvals`'
`sys_approval_request`.
- Base `8b713fad78`, before and after: identical. Every count is 0
except `sys_approval_request`, which had 8 at base because it did not
yet declare its `viewer` block.
- After merging `origin/main` (which brought #22387's `attachedOnRead: {
viewer }`), at `501184a648`: the regex reader and the AST reader give
identical output, 0 everywhere. The approvals object's eight action
predicates read declared `viewer` leaves.
- No producer needed an edit.
## Local verification
All readings below are at `8d07b21aac` unless noted. That head is this
branch with `origin/main` `35ef501e13` merged in. The last commit
touches only the changeset file.
- **Dispatch gates**, derived with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` (no paths): 62 commands.
**62 run, all exit 0.** Each exit code was captured before any pipe.
Reconciled with `--ran`: "62 derived famil(ies) accounted for — 62 run,
0 NOT-MEASURED". Three families answered `PREREQUISITE NOT MET` on the
first pass at `57efbdd5f6` (`check:docs-transcript-drift`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`). After `pnpm
--filter '@objectstack/objectql...' --filter '@objectstack/lint...'
build`, the final pass measured all three, exit 0. Families the tool
places outside the runnable union (CI jobs, type-check lanes, the
artifact-roster block) are CI's.
- **Typecheck**: `pnpm --filter @objectstack/formula run typecheck` and
`pnpm --filter @objectstack/lint run typecheck` exit 0. `tsc -p
tsconfig.test.json --listFiles` includes all four touched or new test
files.
- **Tests**, at `501184a648`. Its `packages/` tree is byte-identical to
`8d07b21aac`.
- `pnpm --filter @objectstack/formula exec vitest run`: 46 files, 1315
passed.
- `pnpm --filter @objectstack/lint exec vitest run`: 131 files, 5979
passed.
- Both were also green before the merge, at `57efbdd5f6`.
- **eslint**, narrowed to the 6 changed `.ts` files with
`--no-inline-config --format json`: 6 files, 0 errors, 0 warnings. The
population is from eslint's own config: `--print-config` resolves a
config for each file, and none is ignored. Invariance for untouched
files: `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move the
verdict on a file it does not touch. The full `pnpm lint` run is CI's.
- **CJS**: `require('./packages/formula/dist/index.js')` loads, and
`validateExpression` and `analyzeRelationshipTraversals` are functions.
## Acceptance notes
- The pending changeset of #22425
(`.changeset/22386-validator-attached-on-read-leaf.md`) says index
access on a read attachment stays unjudged. This PR makes that false for
unreleased text. This PR's changeset says so ("an earlier entry in this
release ... describes the check before this change"), following the
convention `.changeset/22402-option-gate-fails-closed.md` uses. The
other changeset is not edited.
- A comprehension variable that shadows a root (`list.exists(record,
record.x == 1)`) is read as the root. The regex did the same, and so
does `analyzeRelationshipTraversals`, so no verdict moves. It is noted,
not filed: no producer in the repository writes it.
- `packages/objectql/src/validation/rule-validator.ts` has its own
source reading of which columns a lock predicate reads. Its comment near
line 1313 treats `record['x']` as reading every field, which is
conservative. It was not touched or measured here.
- The suite-duration ratchet that landed on `main`
(`scripts/check-test-suite-ceilings.mjs`) has no `ceilings` table yet in
`scripts/test-shard-timings.json`, so it is NOT MEASURED for
`@objectstack/formula` / `@objectstack/lint`. The new lint file ran 36
tests in about 12 s locally with 2 workers on a shared box.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(formula)!: the unknown-field check judges every spelling of a `record` / `previous` member, not only the dot, so `os build` and the object save door refuse `record['typo']`, `previous['typo']`, `record.?typo` and `record[?'typo']` (#22428)
6
+
7
+
Clause-②: yes (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) A refusal at `os build` and at the object save door of an expression that names an undeclared field in a non-dot member spelling: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose expression is refused keeps loading until it is next saved, and the repair is the author's own (declare the field or fix the typo), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict and this diff adds none (not registered / already-registered); and no exported TypeScript declaration changes (not runtime-interface-only / type-surface-only). -->
10
+
11
+
**BREAKING**: an accept-set change, in both directions, of the `os build`, `os validate` and `os lint` verdicts and of the object save door, shipped as `minor` under the launch-window convention for accept-set narrowings. It narrows: an expression that names an undeclared field of its object in a non-dot member spelling (`record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']`, or any of those on `previous`), or an undeclared leaf of a declared read attachment in such a spelling, is now refused where it was accepted. It widens: three shapes the dot-only regex misread as a member of the root, and refused as unknown fields, are now accepted, because none of them names a member. They are text inside a string literal (`record.name == 'record.typo'`), a root name after another root (`vars.record.x`), and a method call on the root itself (`record.size()`). No refusal code is added.
12
+
13
+
`validateExpression`'s field-existence pass judges each member an expression reads on `record` or `previous` against the object's fields, and refuses an undeclared one with the `unknown-field` code. It found those members with a regex over the dot spelling, so it judged `record.typo` and `has(record.typo)` and nothing else. `record['typo']`, `record["typo"]`, `previous['typo']`, `record.?typo` and `record[?'typo']` name the same column, and they reached the evaluator with no verdict at every slot the pass judges, among them a select option's `visibleWhen`, a field's `requiredWhen` and a validation rule's condition. There the expression faults on a key the record never carries, or reads a value that never exists.
14
+
15
+
The pass now reads members through the same AST member reader the relationship-traversal analysis is built on, so every spelling gets the dot spelling's verdict, did-you-mean included:
16
+
17
+
-`os build`, `os validate` and `os lint` refuse an expression that names an undeclared field of its object as `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']` or any of those on `previous`, at `error`, with the message they already give for `record.FIELD`.
18
+
- The object save door runs the same pass, so an object write in publish mode that carries such an expression is now refused with an `expression-invalid` issue located at the slot, the build's own finding.
19
+
- A declared read attachment's leaf (`ObjectSchema.attachedOnRead`) is judged in the same spellings: `record.viewer['can_actt']` and `record.viewer.?can_actt` are refused like `record.viewer.can_actt`. An earlier entry in this release says index access on a read attachment stays unjudged; that describes the check before this change.
20
+
21
+
**Remedy.** Declare the field on the object, or fix the typo to the field the message suggests. The spelling itself is not the defect: `record['status']`, `record.?status` and `record[?'status']` on a declared `status` are accepted, as before.
22
+
23
+
**Unchanged.**
24
+
25
+
- A computed key (`record[record.kind]`, `record[someVar]`) names no member before evaluation, so it is not judged.
26
+
- A method call on a member (`record.name.startsWith('A')`) still judges the member (`name`).
27
+
- Stored rows are not migrated or refused on read; an object stored before this change keeps loading until it is next saved, and that save is judged. Drafts are not gated.
28
+
- Measured before crossing: the stacks this repository ships give the same expression findings before and after the change. That covers `examples/app-todo`, `examples/app-crm`, both `examples/app-multi-package` sub-stacks, the objects, actions, flows, views and pages of `examples/app-showcase`, the 51 objects `@objectstack/platform-objects` exports, and `plugin-approvals`' `sys_approval_request`, whose eight action predicates read the leaves of its declared `viewer` read attachment and pass. No in-repo producer writes a non-dot spelling of an undeclared field.
29
+
- No public export or signature moves; only the doc comments of `ExprSchemaHint.fields` and `ExprSchemaHint.attachedOnRead` change. `analyzeRelationshipTraversals` answers exactly what it answered before: measured identical, set order included, on 4,528 analyses of the 2,264 `record` / `previous` expressions in this repository's TypeScript sources.
0 commit comments