Repository navigation
fix(formula)!: the unknown-field check judges every member spelling of record / previous, not only the dot (#22428) - #22494
Conversation
…he AST member reader checkFieldExistence matched a dot-only regex, so record['x'], previous['x'], record.?x and record[?'x'] reached the evaluator with no existence verdict at every record-scoped slot. It now reads members through readRootMembers, the one AST member reader relationship-traversal analysis is folded from, and the attached-on-read leaf check rides the same reads. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…t for the narrowing Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…eld-existence-every-spelling Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
… the census line Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 17f3da6b0f2effdc4ed74ff580449c064e19c17a && git checkout 17f3da6b0f2effdc4ed74ff580449c064e19c17a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e148ca984258c7c0d162eefd04b1f0f77c3fa9fd 82af2c170387753f34bd476f52bafc63128b41ab && git checkout -B drift-repro e148ca984258c7c0d162eefd04b1f0f77c3fa9fd && git merge --no-ff 82af2c170387753f34bd476f52bafc63128b41ab
node scripts/docs-audit/affected-docs.mjs --json e148ca984258c7c0d162eefd04b1f0f77c3fa9fd
|
… the accept set Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…eld-existence-every-spelling Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
… in the BREAKING paragraph only Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, all read in this act at 2026-10-09T15:28Z, and nothing else: card #22428 (body and all 7 comments — triage 6076113360, unlock 6080500613, claim 6081526953, round-0 report 6082880893, claim amendment 6082932577, patch rounds 6083268166 and 6083726436); PR #22494 (body, 7-file list, its one docs-drift comment, no reviews); the net diff of the head against its merge base with Check-runs on the head, as read: 34 of 34 ① Derived judgmentsAccept set. The surface is
Public surface.
② Semver level
③ Boundary flagsRound-0 report (6082880893) —
Claim amendment (6082932577): Patch round 1 (6083268166):
Patch round 2 (6083726436) —
Also read: all 7 branch commits end with the model-free trailer pair; no model identifier in the PR title, body, changeset or commits; the branch matches the Implemented-by: VERDICT: PASS |
Fixes #22428
Clause-②: yes (narrowing)
What changed
validateExpression's field-existence pass (checkFieldExistenceinpackages/formula/src/validate.ts) found the members an expression reads onrecord/previouswith 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 onprevious, and any of them insidehas(...);record.BLOCK.LEAFagainstObjectSchema.attachedOnRead) rides the same reads, sorecord.viewer['can_actt'],record.viewer.?can_acttandrecord['viewer'].can_acttare judged likerecord.viewer.can_actt.There is no lint arm.
@objectstack/lint's slot walk is unchanged, soos buildand the object save door give the shared validator's verdict, as they already did for the dot spelling.One reader, one parse
relationship-traversal.tsnow 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)isparseCelToAstplustraversalsOf(readRootMembers(ast, [root])). Its signature and answers are unchanged (equivalence below).checkFieldExistencereadsreadRootMembers(ast, ['record', 'previous']).validateExpressionparses 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 throughanalyzeRelationshipTraversals.readRootMembersandtraversalsOfare module exports only.src/index.tsdoes not change, anddist/index.d.tshas zero mentions of either.Measured on the as-is reuse question (dispatch mechanism assumption 2):
analyzeRelationshipTraversalscould answer existence as written (first segments =bareFieldsplus the keys oftraversals; 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)
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]).record.name.startsWith('A')) still judges the member.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 reportedtypo,xandsizeas 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/main8b713fad78, which includes9af0005d55)Probe: an object with fields
nameandtier, the expression on one slot. "build" isvalidateStackExpressions. "door" isrunRuntimeAuthoringRules({ type: 'object' })(the gatesaveMetaItemruns). Counts areexpression-invaliderrors.zz_typoundeclared)visibleWhenbuild / doorrequiredWhenbuild / doorconditionbuild / doorrecord.zz_typo == 'a'(control)record['zz_typo'] == 'a'previous['zz_typo'] == 'a'record.?zz_typo.orValue('') == 'a'record[?'zz_typo'].orValue('') == 'a'has(record.zz_typo)After the change, every row reads
1 / 1in all three slots. Every spelling of the declarednamereads0 / 0before and after.Two premise details came out differently from the card:
has(record.x)was already refused, because the regex saw the dot insidehas(...). It is pinned so it stays refused.record.?zz_typo == 'a'is refused at compile (invalid-cel: cel-js finds no==overload for an optional ofdynagainst a string). The optional spelling that passed wasrecord.?zz_typo.orValue(...), and that is the one the pins use.Pins
packages/formula/src/validate-field-existence-spellings.test.ts(new, 31 tests):code: 'unknown-field'and params;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 onreadRootMembers:analyzeRelationshipTraversalsequals the fold of its reads.packages/lint/src/runtime-gate.object-field-existence-spellings.test.ts(new, 36 tests): 3 slots (optionvisibleWhen,requiredWhen, validationcondition) × 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,
HEAD57efbdd5f6)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. Blob3fc4e3033d47became383b80a635d8.@objectstack/formulawas rebuilt, andscripts/ablation-dist-preflight.mjsshowed the marker indist/index.jsanddist/index.mjs. The lint pins resolve formula throughdist/.validate-field-existence-spellings+validate-attached-on-readruntime-gate.object-field-existence-spellingsThe direction was as predicted:
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.has()rows, and every control.Restore was proven by
ablation-replace(blob equal toHEAD,git diff HEADempty) and byablation-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
BASEversion ofrelationship-traversal.tswas run against this branch's version over every string literal that mentionsrecord/previousmember access in the repository'spackages/**andexamples/**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, comparingtraversals,bareFieldsandmultiHopFieldswith their insertion order.Population census (dispatch mechanism assumption 4)
git grep): CEL in metadata that spells arecord/previousmember 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 orskills/**file uses such a spelling.validateStackExpressions:examples/app-todo,examples/app-crm, the twoexamples/app-multi-packagesub-stacks, the objects, actions, flows, views and pages ofexamples/app-showcase, the 51 objects@objectstack/platform-objectsexports, andplugin-approvals'sys_approval_request.8b713fad78, before and after: identical. Every count is 0 exceptsys_approval_request, which had 8 at base because it did not yet declare itsviewerblock.origin/main(which brought plugin-approvals:sys_approval_requestdeclares its per-callerviewerblock underattachedOnRead, with a conformance test againstattachViewers(#22211 ruling A, plugin-approvals half) #22387'sattachedOnRead: { viewer }), at501184a648: the regex reader and the AST reader give identical output, 0 everywhere. The approvals object's eight action predicates read declaredviewerleaves.Local verification
All readings below are at
8d07b21aacunless noted. That head is this branch withorigin/main35ef501e13merged in. The last commit touches only the changeset file.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 answeredPREREQUISITE NOT METon the first pass at57efbdd5f6(check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure). Afterpnpm --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.pnpm --filter @objectstack/formula run typecheckandpnpm --filter @objectstack/lint run typecheckexit 0.tsc -p tsconfig.test.json --listFilesincludes all four touched or new test files.501184a648. Itspackages/tree is byte-identical to8d07b21aac.pnpm --filter @objectstack/formula exec vitest run: 46 files, 1315 passed.pnpm --filter @objectstack/lint exec vitest run: 131 files, 5979 passed.57efbdd5f6..tsfiles with--no-inline-config --format json: 6 files, 0 errors, 0 warnings. The population is from eslint's own config:--print-configresolves a config for each file, and none is ignored. Invariance for untouched files:eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on a file it does not touch. The fullpnpm lintrun is CI's.require('./packages/formula/dist/index.js')loads, andvalidateExpressionandanalyzeRelationshipTraversalsare functions.Acceptance notes
.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.mduses. The other changeset is not edited.list.exists(record, record.x == 1)) is read as the root. The regex did the same, and so doesanalyzeRelationshipTraversals, so no verdict moves. It is noted, not filed: no producer in the repository writes it.packages/objectql/src/validation/rule-validator.tshas its own source reading of which columns a lock predicate reads. Its comment near line 1313 treatsrecord['x']as reading every field, which is conservative. It was not touched or measured here.main(scripts/check-test-suite-ceilings.mjs) has noceilingstable yet inscripts/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