Repository navigation
fix(formula): let an authoring surface declare the binding roots it mounts (ExprSchemaHint.roots) - #18696
Conversation
…mounts `ExprSchemaHint.roots` — the roots a surface binds beyond the platform baseline (`SCOPE_ROOTS`), declared as plain data by the caller. A page component's `visibleWhen` binds three roots at runtime and the hint could express neither of the two shapes it needs: `scope: 'record'` refused `page.selectedProjectId != ''` — the worked example `page.zod.ts`'s own `visibleWhen` describe ends with — and prescribed `record.page`, which names nothing on any layer; `scope: 'flattened'` accepted that and accepted a bare `status == 'done'` with it, which is the shorthand the narrowing exists to catch. Declaring a root is not becoming permissive: the bare-field shorthand, an undeclared root and a typo of a declared root all stay hard errors. The key only ever adds, so a call site that does not pass it keeps its verdict and its prescription byte for byte. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 7 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 a099c46c0f0def2ee50fd8685d0396f3da3ed0d0 && git checkout a099c46c0f0def2ee50fd8685d0396f3da3ed0d0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1bc22b3dcddc8a30b4826da8625e7787d5518a8f 4f385ea7e767966c3e8385c260a3c701954e2049 && git checkout -B drift-repro 1bc22b3dcddc8a30b4826da8625e7787d5518a8f && git merge --no-ff 4f385ea7e767966c3e8385c260a3c701954e2049
node scripts/docs-audit/affected-docs.mjs --json 1bc22b3dcddc8a30b4826da8625e7787d5518a8f
|
|
Pre-landing checks, all three re-taken at this stroke — ⛔ none carried over from earlier in the round.
Governed-surface predicate, derived by the script from this PR's own file list rather than from a list this seat typed: ⇒ Going ready and into the merge queue (SQUASH). This seat follows it to MERGED. Generated by Claude Code |
Fixes #18554
Clause-②: yes (widening)
ExprSchemaHintgainsroots— an authoring surface naming the binding roots it mounts beyond the platform baseline (SCOPE_ROOTS), sovalidateExpressioncan accept them without standing down on everything else.The premises, re-measured on this base
Measured against
packages/formula/distbuilt fromorigin/mainat6dfa3ea772, before any edit — script and raw output in the report comment on #18554.scope 'flattened'+status == 'done'scope 'flattened'+page.selectedProjectId != ''scope 'record'+status == 'done'record.status(the narrowing that is right)scope 'record'+page.selectedProjectId != ''page… Writerecord.page.scope 'record'+current_user.id == 'x'(control)pagespecificallySCOPE_ROOTS.includes('page')/('current_user')false/truerootshint passed tovalidateExpressionEvery corner and the control reproduce. The card's sharpest claim reproduces too: the refused spelling is the one
packages/spec/src/ui/page.zod.ts:390publishes itself — itsvisibleWhendescribe names the contract-bound roots asrecord,current_userand page state in thepage.VARform, and endse.g. "page.selectedProjectId != ''". The prescriptionrecord.pagenames nothing underrecordon any layer.One reading is narrower than the thread records it. The filer's amendment adds that under
flattenedwith a field catalogpage.*produces a non-blocking did-you-mean warning. Measured here, that warning is conditional on the catalog holding a near-miss: withfields: ['pages','status']it warns; withfields: ['status','amount']or['page_size']there is no warning at all —page.*is silently clean. The two faces therefore differ by more than strict/lenient in only some catalogs; in the page-draft case the card describes (no catalog at all)flattenedis simply silent.The shape, and why not the other two
⛔ The repair is not "make the validator permissive at that surface". Trading a false refusal for a silent acceptance is the worse of the two directions here, so the surface declares which roots it binds and every other name keeps the verdict it had.
SCOPE_ROOTS. That is one accept set for every surface, andpageis genuinely unbound at the hook and validation-rule surfaces — this card's false positive traded for that card's false negative.collectCelRootIdentifiersreads the AST and lets a surface refuse a baseline root it never mounts, andSCOPE_ROOTS's own docblock says that is how a closed surface expresses itself. What had no expression was the opposite direction — a surface that binds more than the baseline.rootsis that, and only that. The two directions stay two mechanisms.firstUndeclaredReference(source, knownNames)is published and@objectstack/lint's view/page gate already supplies exactly such a list (VIEW_PAGE_EXTRA_ROOTS = ['current_user', 'page']). What that route costs a consumer is everything elsevalidateExpressiondoes — the compile check, the braces hint, field existence, the role catalog, the type-soundness pass. This key is the same vocabulary reaching the shared validator, so a surface can declare its roots without dropping out of the one validator ADR-0032 §Decision 1 exists to keep single.What it does and does not move
rootscan turn a refusal into an acceptance and never the reverse. Re-running the measurement above against the built change moves exactly one line — therootsprobe, ERROR to clean. All four corners and the control are byte-identical.page: the bare-field shorthand is still a hard error, an undeclared root (wizard.step == 2) is still a hard error, and a source mixing both still refuses on the field.pge.selectedProjectId) is named an unbound root and pointed atpage, instead of being handedrecord.pge. Three guards keep that from ever prescribing the wrong fix: no declared roots, a name infields, or no near-miss all fall back to the existing message. A bare value reference (pge == 'x') keepsrecord.pge— nothing there says it is a mistyped root rather than a mistyped field.introspectScopeadvertises what the validator accepts, from the same declaration, so an accepted root is never one an author has no way to discover.rootsabsent androots: []each reproduce the previous verdict and the previous prescription. No caller in this repo passesrootsyet; this PR adds the expression, not an adoption.Serial constraints on this seam
Held: the diff does not touch
packages/formula/src/types.ts(EvalContext, so #18318'sapimember is untouched) and adds nocan(#18545). The extension point is plain declarative data — a list of names the caller already knows, not a resolver callback — which is the same shape #18545's ruling fixes for permission data, so nothing here narrows that change's options.origincarries a branchclaude/issue-18545-formula-can-functionwith zero commits ahead ofmain— the empty-branch landing marker a dispatched dev pushes before its first edit. No file overlap with this PR either way.Verification
Final head
4f385ea7e7; every reading below is from that head.pnpm --filter @objectstack/formula test— 30 files / 873 tests passed (17 of them new).pnpm --filter @objectstack/formula typecheck— green (tsc --noEmit+check:test-typecheck, test layer compiles, ledger held).dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 57 families from the three real changed paths; all 57 ran with their exit codes captured to disk before anything was read.--ranverdict:57 derived famil(ies) accounted for — 57 run, 0 NOT-MEASURED, 0 UNRUN. Three (check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) first exited 3PREREQUISITE NOT METand were re-run to green afterturbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 successful). ⭐check:cross-package-test-inputspassed — no spurious red, consistent with fix(gates): judge a cross-package walk root against the index, not the working tree #18641.firstUndeclaredReference(source)— mutation proved on disk by anchor counts (new=1 old=0tonew=0 old=1) and a moved blob hash (e751cb54tofa72f895), not by an editor's exit code. Predicted direction: turns red, and it does — 2 of 17 pins fail (resolves once the surface declares 'page' as one of its roots;judges the declared root and the bare field in one source). The mistyped-root pins stay green, because that arm is the refusal message and the mutation was the accept side — the partition is the expected one. Restored withgit checkout HEAD --naming the file by absolute path under anEXIT/INT/TERMtrap; restoration proved by blob hash back toe751cb54and an emptygit diff HEAD, then the file re-run green (17/17). The test imports./validateas package-relative source, so nodistsits in this ablation's resolution path and no rebuild leg applies.pnpm lint(eslint . --no-inline-config) is CI's run. Ran instead:eslint --no-inline-config --format jsonover the two changed source files — 0 errors, 0 warnings, 2 files counted from the JSON. Population, read from ESLint's own API rather than guessed: 6815 tracked files carry a linted extension and ESLint ignores none of them globally. The narrowing is safe because this repo's singleeslint.config.mjsnever enables type-aware linting for any file —parserOptions.projectandprojectServiceappear zero times in it, and the config says so in its own prose with a positive control — so no rule resolves types across files and this diff cannot move the verdict of a file it does not contain. The changeset file carries no linted extension.Acceptance notes
Out of scope for this PR, noted rather than filed:
introspectScope's baseline roots are still surface-blind. It advertisesinput,osandvarsto every caller regardless of what the surface binds — the accept-side twin this card's body names, already tracked as objectui#9645. This PR makes declared roots joinable to that list but does not narrow it, because narrowing what a surface advertises is that card's verdict to give. Carrier for the finding: objectui#9645.flatteneddid-you-mean is catalog-conditional, as measured above. Not a defect — the threshold isnearestName's shared one and behaving as designed — but the thread records the warning unconditionally, so the reading is corrected here where a reviewer will see it. No carrier needed; it is a correction to a reading, not to code.Contract review
This PR moves what the validator accepts, so
Clause-②: yes (widening)is declared at column 0 above and apatch-above changeset (minor) ships with it — ⛔skip-changesetwould be wrong here.needs:contract-reviewis on the card; applying it to this PR is the seat's stroke, not this one's.Generated by Claude Code