Uh oh!
There was an error while loading. Please reload this page.
fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182
Conversation
…ary (#13935) `fieldRuleRootIssue` filtered candidate roots through `@objectstack/formula`'s `SCOPE_ROOTS`, which answers "is this declared platform-wide" rather than the question this rule asks, "is this bound at some evaluation site". The two sets agreed for 27 roots and disagreed for `app` — bound by objectui's `ExpressionProvider`, absent from the baseline — so a field-level `*When` reading `app` fell through to the generic bare-reference check and was told to write `record.app`, which then earns `unknown field `app``. Assemble the judged vocabulary in this package as SCOPE_ROOTS plus the ambient roots the spec records in ui/page.zod, leaving the published baseline untouched, and give ambient roots a prescription tier that is true of them. Keep the two partitions disjoint by suppressing the bare-reference verdict for a root this rule has claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…ge the #5017 receiver scan (#13935) Three findings from the first full run, all of them real: - A predicate reading a baseline root AND an ambient one kept the bare reference for the ambient one, re-emitting the exact false `record.app` prescription this card removes. Suppression is now gated on a verdict having been issued and covers the ambient roots, not only the root the tie-break named. - #5017's receiver scan reads `page.zod` and `record.${root}` inside a STRING literal as reads off `page` / `record` receivers, exactly as the file's existing `sectionFields` and `*.form` comments warn. Name the spec module in prose and assemble the `record.` spelling with `+`. - The two new locals are named `verdict` / `diagnostic` rather than `message` so excusing them cannot mask a genuine validations[].message read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
…eld-rule-root-vocabulary
📓 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 — 5 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 421c1c454fa5c4668c0d78b320701b848baa862f && git checkout 421c1c454fa5c4668c0d78b320701b848baa862f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 74cce24156ba9aa78ebb651b85d9c230f45c2e8d && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff 74cce24156ba9aa78ebb651b85d9c230f45c2e8d
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13935
A field-level
requiredWhen/readonlyWhen/visibleWhenreadingappearned the generic bare-reference diagnostic —bare reference `app` … Write `record.app`.— advice that is actively false, because following it producesunknown field `app` on `invoice`from the field-existence pass one line up. Every other root an author reaches for here (current_user,user,ctx,os,features,data) already got the correct scope diagnostic.appalone did not.The premise the ruling conditioned on — measured first, before any repair
The triage ruling (
5486907899) is conditional: option 1 is dispatchable only if both diagnostics carry the same severity, because otherwise it would downgrade an error to a warning, which is a gate weakening. Triage recorded the bare-reference side as unpinned rather than guessing it.Measured: both are
error. The premise holds. Located by symbol, not by the line numbers in triage's read:checkFieldRuleRoot(nowfieldRuleRootVerdict+ its push site) pushesseverity: 'error'— unchanged by this PR.packages/formula/src/validate.ts, inside theschema?.scope === 'record'branch, byerrors.push({ … }). Its own comment reads "In arecord-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lintmapsres.errorstoseverity: 'error'.Confirmed at runtime as well as by reading, on the pre-repair tree:
app.locale == 'en'on a field-levelrequiredWhenreturned exactly one issue,severity: "error", carrying the bare-reference text;current_user.id == 'U1'returned exactly one issue,severity: "error", carrying the scope text. Same severity, different message.⇒ This PR changes which message an author reads and nothing about what lints clean.
The repair — option 1, and
SCOPE_ROOTSis untouchedfieldRuleRootIssuefiltered candidate roots through@objectstack/formula'sSCOPE_ROOTS, which answers "is this root declared platform-wide". The question this rule needs answered is "is this root bound at some evaluation site". The two agreed for all 27 baseline roots and disagreed for exactly one.The judged vocabulary is now assembled in
@objectstack/lint, where the per-surface question is asked:FIELD_RULE_AMBIENT_ROOTS = ['app']— roots bound somewhere that the baseline does not declare.FIELD_RULE_JUDGED_ROOTS = [...SCOPE_ROOTS, ...FIELD_RULE_AMBIENT_ROOTS]—SCOPE_ROOTSspliced in first, so the existing tie-break keeps its exact pre-change precedence.⛔
packages/formula/src/cel-engine.tsis not touched. AddingapptoSCOPE_ROOTSwould have routed it to the correct branch for free, butSCOPE_ROOTSis the published strict-lint accept baseline: every surface that judges bare identifiers would stop faultingapp. That widens a published accept set to fix one surface's wording. A pin asserts the boundary rather than restating it in prose —expect(SCOPE_ROOTS).not.toContain('app')goes red on exactly that "simplification".The in-repo source for the vocabulary
The dispatch flagged an unverified assumption: that "roots bound at some evaluation site" can be expressed without importing an objectui-side list into
packages/lint. It can.packages/spec/src/ui/page.zod.tscarries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which namesapp,featuresandos.useras mounted by app-shell'sExpressionProvider, measured at a pinned objectui sha. Onlyapplands in the new constant:featuresandosare alreadySCOPE_ROOTSmembers, so the intersection of "ambient" and "not in the baseline" is this one root. No cross-repo list is copied.A prescription tier of its own
Routing
appinto the existing "everything else" tier would have replaced one false sentence with another — that tier says the root "is declared platform-wide and bound at OTHER evaluation sites (flow, automation, screen and action predicates)", and both halves are wrong for an ambient root. Ambient roots get their own tier, which says what is true: not declared platform-wide, mounted only by the renderer, resolving in a form view's own field predicate and on no server path. It refusesrecord.appexplicitly rather than merely omitting it, because that is the advice the author just followed out of the old diagnostic.Keeping the two partitions disjoint
This rule's docblock has always asserted that it and the bare-reference check are disjoint. That held for free while every judged root was a
SCOPE_ROOTSmember — a declared root resolves in the strict env, so the bare-reference check could not fire on it whatever this rule decided. An ambient root is undeclared there, so both checks see it, and without a second halfappwould earn both verdicts — including the false prescription this PR exists to remove.So the field walk computes the verdict first and tells
checkone was issued;checkthen drops bare-reference errors naming an ambient root. The suppressed set is the ambient roots rather than only the root the tie-break named, and the difference is load-bearing: withctx.locale == 'en' && app.locale == 'en'the tie-break namesctx, andappwould otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixesctx, re-runs, andappearns its own correct verdict: the same one-root-at-a-time iteration this rule already does for two baseline roots.Its blast radius is pinned: the suppression is gated on a field-rule verdict, so a per-option
visibleWhenreadingappkeeps the bare-reference verdict exactly as before, and a plain bare field reference on a field-rule slot is untouched.Two traps this file warns about, both hit and both fixed
The file's existing comments warn that #5017's receiver scan strips comments but not strings, which is why
sectionFieldsand*.formappear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:page.zodinside the message registeredpageas a read receiver. The spec module is now named in prose instead.`record.${root}`registeredrecord, because$is an identifier character to the scan. The spelling is assembled with+.The two new locals are named
verdictanddiagnosticrather thanmessage/source, so excusing them in the scan's plumbing list cannot mask a genuinevalidations[].messageread.Evidence
All readings below are from the final commit,
74cce241.Ablation — on the committed tree, reverting the one line that is the repair's mechanism (
FIELD_RULE_JUDGED_ROOTS→SCOPE_ROOTSat the filter site):56f78d28…→3a8feaff…../validate-expressions.js), so vitest resolves it from source, not through a packageexportsintodist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.appassertion. Thecurrent_userpositive control stayed green, as did the vocabulary-shape pin and the per-option blast-radius pin. The test discriminates on which diagnostic fires, not on "some diagnostic fires".git checkout HEAD -- <abs path>(an absolute path, from a trap armed before the mutation), thengit diff HEAD0 bytes,git statusclean, and the restored blob hash back to56f78d28….Tests —
pnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed.pnpm --filter @objectstack/lint typecheck: clean.typecheckdid not read the new test assertions, and this is stated rather than glossed:packages/lint/tsconfig.jsonexcludes**/*.test.tsand there is no sibling test project, verified with--listFiles(validate-expressions.test.ts: 0 hits;validate-expressions.ts: 1). Pre-existing, not introduced here, and filed as #14173.The rule's second consumer —
pnpm --filter @objectstack/lint check:doc-formula-expressions, which importsfieldRuleRootIssuefrom this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level*Whenpredicates on a statically determinable field layer.Gate family, derived at the actual diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(33 commands; exit codes captured by redirect before any pipe): 30 pass, 3 PREREQUISITE NOT MET —check-test-completeness(needs a savedturbo run testlog),check:dual-build-cjs-loadsandcheck:type-check-debt(both need a fullpnpm build). Those three print their own NOT MEASURED verdicts and exit 3, distinct from a finding's 1; they are recorded as not measured, never as passes.ESLint — narrowed to the changed files, and the narrowing is a measurement rather than a skip:
eslint.config.mjswhich never enables type-aware linting for any file (noparserOptions.project, no typed@typescript-eslintrules) — stated and positive-control-measured in that config's own header. No rule reads across files, so this diff cannot move any untouched file's verdict.--format jsonover the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset.mdreports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.debugger;) did not fire — only 6 rules are enabled for this path. A plantedno-restricted-importsviolation did: exit 1, 1 error, restored withgit diff HEAD0 bytes.Scope
Field-rule root vocabulary only. ⛔ The flow leg (
validate-expressionshas no flow leg for bare identifiers) is a separate card in the same file, held serial behind this one, and is not touched here — different defect shape, different repair. Nothing else in this file was changed while passing through.Filed out of scope: #14173 —
packages/linthas no tsc program that compiles its tests, so the ~2,700-line pin file (the receiver scan included) is type-checked by nobody. Same class as thepackages/plugins/**,packages/objectqlandpackages/restinstances; this package is not named by any of them.Structural note, for the record — not built here
This is the third sighting of the point #6713 already made: a hand-maintained list doing a per-surface job drifts.
SCOPE_ROOTS' own comment claims "the last one this list was missing";appis that sentence's second counterexample, not an analogy to it. The clean shape would be a single declared source for "roots bound somewhere" — most naturally an exported constant inpackages/specbeside the page-component schema that already documents the ambient set in prose, withpackages/lintand the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.Generated by Claude Code