Repository navigation
fix(lint): a field-level app predicate is told the root binds nowhere, not that the renderer mounts it - #18176
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 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 c87e6085734ec23a7cb0a9b3cd99bc1b5b6227e8 && git checkout c87e6085734ec23a7cb0a9b3cd99bc1b5b6227e8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 689d606f80d27f1ab8364aa2e1585b809abc1956 a78bfd682feb157869aaf43f2dace2f01d148f6a && git checkout -B drift-repro 689d606f80d27f1ab8364aa2e1585b809abc1956 && git merge --no-ff a78bfd682feb157869aaf43f2dace2f01d148f6a
node scripts/docs-audit/affected-docs.mjs --json 689d606f80d27f1ab8364aa2e1585b809abc1956
|
Fixes #17330
Clause-②: no
The diff narrows nothing and widens no published or accept surface. It renames a package-internal constant (measured absent from this package’s published entry), re-founds a docblock on decision batch #67, and rewrites diagnostic prose. The engine’s declared scope list is untouched, no schema moves, no error code is added and no export changes; the one package the diff moves under the source tree is graded patch, which is the shape a yes would refuse. Recorded twice already — the PM’s claim comment on #17330 declares the same value, and so does the dev report.
FIELD_RULE_AMBIENT_ROOTSis renamedFIELD_RULE_NOWHERE_BOUND_ROOTSand keeps its single memberapp. The membership was never the false part — the name, the docblock and one clause of the message were.What was wrong
#13935 added
appto this locally-assembled vocabulary on the premise that objectui's app-shell bound it at the renderer, so the honest verdict was "bound somewhere, just not here". Decision batch #67 (2026-09-07) ruled option B — the engine'sSCOPE_ROOTSis the contract and ObjectUI aligns to it. ObjectUI shipped that, its scope builder no longer bindsapp, and the producer-side option-A card was closednot_plannedunder the same ruling. So the constant's name asserts a fact that stopped being true, and its docblock cites a source that no longer supports it.Which version of the card's strongest claim this PR asserts
The card and triage say the cited spec docblock was deleted. Measured on
origin/mainand confirmed independently by the dispatching seat, that is slightly wrong and the accurate version is stronger:## Ambient roots — renderer behaviour, NOT contract-guaranteedsection still exists, atpackages/spec/src/ui/page.zod.ts:365.features,os.useranddata. Backtickedappoccurs 0 times in that section (lit control: backtickedfeaturesoccurs 1); the only twoapphits in it areapp-shell.⇒ The constant is not sourceless — it is contradicted: its cited source of truth still exists and no longer supports its membership of
app. This PR asserts that version. No commit naming the PR the card blames appears in the recent history ofmain, so this PR does not assert that it landed.Why the obvious fix is the wrong one
Emptying the constant is the card's literal ask. Measured, it makes a worse message reachable, so it is not what shipped.
With the root gone from the judged vocabulary, both
appspellings fall through to@objectstack/formula's generic bare-reference check, whose prescription is to rewrite the root as a member of the record. Following that earnsunknown field `app`from the field-existence pass one line up — the exact two-step wrong correction #13935 existed to remove, on the exact root, and the exact spelling this rule's own surviving message refuses by name. It also turns the suppression and the whole message tier into unreachable branches: with an empty arrayisBareReferenceToAnycan never return true.So the membership stays and only its grounds move.
The before and after an author reads
Same instrument for both readings: a driver calling
validateStackExpressionson the fixture shape the test helper uses, run against this package's source. The shared leading clause is elided with…; the differing tail is verbatim.Bare
app(sourcesappandapp == 'x') and dottedapp.theme(andapp.locale) produce byte-identical text on each side, because the tier is chosen by ROOT — so both spellings are quoted once and both are pinned separately.Before:
After:
The bolded tail is the whole repair: the old text ended by offering a destination that no longer binds the root, which is the same class of error the tier exists to stop.
What does not change: before and after, both spellings yield exactly 1 issue at
severity: 'error'. No verdict moves and nothing that used to lint clean stops doing so.Acceptance bar
⛔ On no path may an author read the record-rewrite prescription for this root. Probed over seven cases:
apppredicate: 0nope): firesrecord.app == 1still earnsunknown field \app` on `showcase_deal`` — which is why the refusal is kept in the message rather than droppedFour new pins assert this on the bare and the dotted spelling. Ablating the fix (emptying the constant) turns all four red, alongside the five pre-existing #13935 pins — mutation proved on disk by blob hash before the run was believed, restored with
git checkout HEAD -- pathand re-proved byte-identical after.Blast radius
An
app.-rooted field-rule predicate occurs exactly once repo-wide and it is this package's own per-OPTION test fixture, not shipped app metadata (lit control: 396*Whenslots in the same corpus).app.(theme|locale|id|name)acrosscontent+examples: 0. So no shipped metadata is mis-advised today, either way — which is what kept this change small rather than a redesign of the tier.Not a published break
FIELD_RULE_AMBIENT_ROOTSwas never re-exported from this package's entry —src/index.tsexportsvalidateStackExpressions,fieldRuleRootIssueandFIELD_RULE_BOUND_ROOTSonly, with zero star-exports, and the builtdist/index.d.tscarries 0 occurrences of the old name (lit controlFIELD_RULE_BOUND_ROOTS: 3, including the export statement). The rename is internal and no consumer import moves.Changeset
A real
patch, notskip-changeset, judged on the publish surface:packages/lintshipsdist, and the diagnostic text an author observes is part of what it publishes. A user-visible behaviour change in a released package takes a changeset. Nothing removed or renamed is authorable or exported, so no migration mapping and no ADR-0087 disposition is owed.Verification
pnpm --filter @objectstack/lint test— 103 files / 3826 tests passedpnpm --filter @objectstack/lint typecheck— pass, includingcheck:test-typecheckpnpm --filter @objectstack/lint check:doc-formula-expressions— pass (the second consumer offieldRuleRootIssue)scripts/pm/dispatch-gates.mjs --commands: 61 families, 61 run, 0 NOT-MEASURED, 0 UNRUN, reconciled with--rancarrying each exit code captured before any pipe. Two families first answeredexit 3(PREREQUISITE NOT MET — unbuiltdist); a fullpnpm buildfixed the prerequisite and the whole sweep was re-run rather than patched.eslint . --no-inline-configover the whole repo — 6752 files, 0 errors, 0 warningsOut of scope, filed separately
packages/lint/CHANGELOG.mdclaimsFIELD_RULE_AMBIENT_ROOTSandFIELD_RULE_JUDGED_ROOTSare "exported beside"FIELD_RULE_BOUND_ROOTS; neither is on the published entry. That is released text and per AGENTS.md is amended in a dedicated docs-only PR, ⛔ never as a rider here. Reported on the card and taken by the triage seat.Generated by Claude Code