fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline - #14182

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary
Sep 1, 2026
Merged

fix(lint): judge field-rule roots against the bound-somewhere vocabulary, not the published SCOPE_ROOTS baseline#14182
os-support-ai merged 3 commits into
mainfrom
claude/issue-13935-field-rule-root-vocabulary

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13935

A field-level requiredWhen / readonlyWhen / visibleWhen reading app earned the generic bare-reference diagnostic — bare reference `app` … Write `record.app`. — advice that is actively false, because following it produces unknown 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. app alone 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 (now fieldRuleRootVerdict + its push site) pushes severity: 'error' — unchanged by this PR.
  • The bare-reference diagnostic is produced in packages/formula/src/validate.ts, inside the schema?.scope === 'record' branch, by errors.push({ … }). Its own comment reads "In a record-scoped site a bare top-level identifier is a silent bug … Hard error."packages/lint maps res.errors to severity: 'error'.

Confirmed at runtime as well as by reading, on the pre-repair tree: app.locale == 'en' on a field-level requiredWhen returned 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_ROOTS is untouched

fieldRuleRootIssue filtered candidate roots through @objectstack/formula's SCOPE_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_ROOTS spliced in first, so the existing tie-break keeps its exact pre-change precedence.

packages/formula/src/cel-engine.ts is not touched. Adding app to SCOPE_ROOTS would have routed it to the correct branch for free, but SCOPE_ROOTS is the published strict-lint accept baseline: every surface that judges bare identifiers would stop faulting app. 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.ts carries a section titled "Ambient roots — renderer behaviour, NOT contract-guaranteed" which names app, features and os.user as mounted by app-shell's ExpressionProvider, measured at a pinned objectui sha. Only app lands in the new constant: features and os are already SCOPE_ROOTS members, 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 app into 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 refuses record.app explicitly 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_ROOTS member — 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 half app would earn both verdicts — including the false prescription this PR exists to remove.

So the field walk computes the verdict first and tells check one was issued; check then 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: with ctx.locale == 'en' && app.locale == 'en' the tie-break names ctx, and app would otherwise keep its bare reference — re-emitting the exact false prescription on the exact root. Suppressed, the author fixes ctx, re-runs, and app earns 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-optionvisibleWhen reading app keeps 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 sectionFields and *.form appear in messages without their extensions. The first run went red on two more instances of that same shape, from the new message:

  • page.zod inside the message registered page as a read receiver. The spec module is now named in prose instead.
  • `record.${root}` registered record, because $ is an identifier character to the scan. The spelling is assembled with +.

The two new locals are named verdict and diagnostic rather than message / source, so excusing them in the scan's plumbing list cannot mask a genuine validations[].message read.

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_ROOTSSCOPE_ROOTS at the filter site):

  • Mutation confirmed on disk before running anything, anchored to the text being changed in both directions: the new spelling went 1 → 0 hits, the old 0 → 1, and the blob hash moved 56f78d28…3a8feaff….
  • No rebuild is owed and none was done: the test imports the subject relatively (./validate-expressions.js), so vitest resolves it from source, not through a package exports into dist/. The mutation leg proves this rather than asserting it — the run went red with no build in between.
  • Result: 4 failures, every one of them an app assertion. The current_user positive 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".
  • Restore proven, not assumed: git checkout HEAD -- <abs path> (an absolute path, from a trap armed before the mutation), then git diff HEAD0 bytes, git status clean, and the restored blob hash back to 56f78d28….

Testspnpm --filter @objectstack/lint test: 88 files / 2457 tests passed, 0 failed. pnpm --filter @objectstack/lint typecheck: clean.

⚠️typecheck did not read the new test assertions, and this is stated rather than glossed: packages/lint/tsconfig.json excludes **/*.test.ts and 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 consumerpnpm --filter @objectstack/lint check:doc-formula-expressions, which imports fieldRuleRootIssue from this package's built output: self-test 58 cases pass; corpus clean, including the 14 field-level *When predicates 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 METcheck-test-completeness (needs a saved turbo run test log), check:dual-build-cjs-loads and check:type-check-debt (both need a full pnpm 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:

  1. Population. The repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed @typescript-eslint rules) — 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.
  2. Count.--format json over the 3 changed paths: 2 linted, 0 errors / 0 warnings. The changeset .md reports "File ignored because no matching configuration was supplied" — outside eslint's population by config, not by this narrowing.
  3. Positive control. A zero-hit is not a reading, and the first control chosen (debugger;) did not fire — only 6 rules are enabled for this path. A planted no-restricted-imports violation did: exit 1, 1 error, restored with git diff HEAD 0 bytes.

Scope

Field-rule root vocabulary only. ⛔ The flow leg (validate-expressions has 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: #14173packages/lint has 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 the packages/plugins/**, packages/objectql and packages/rest instances; 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"; app is 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 in packages/spec beside the page-component schema that already documents the ambient set in prose, with packages/lint and the renderer both reading it, so the docblock and the vocabulary cannot disagree. Deliberately not built in this PR.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 421c1c454fa5c4668c0d78b320701b848baa862f — the merge of head 74cce24156ba9aa78ebb651b85d9c230f45c2e8d into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-support-ai@claude