fix(lint): visibility-bare-identifier reports the unwrapped occurrence beside a has() guard - #16413
Conversation
…tifier` Red-first pins for the reachability gap: an identifier written bare beside a `has()` guard in the same visibility predicate publishes clean, while the same identifier alone gates. Two of the eight rows fail on this commit — the guarded row and the different-name row that tells positional masking apart from a name-keyed exclusion — and the `has(status)`-alone row is the negative control that must stay silent after the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…to the occurrence `firstUndeclaredReference` reads the FIRST error cel-js's checker reports and acts only on `Unknown variable: X`. A bare `has(x)` fails that check with `has() invalid argument` instead, so a first error of a different class masked every undeclared reference behind it in the same predicate — the same name and any other one alike. Mask each `has(…)` call out of the source before the checker sees it, using the canonical AST's own spans and a same-width `true` literal. The argument occurrence is excluded, which is the exclusion it earns as a select target, while every other occurrence is judged exactly as it would be with no guard written beside it. The rewrite only deletes source, so it can remove a finding but never invent one, and the rest of the predicate reaches the checker byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…identifier fix Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cba3123a0805d5acdcf44a872f8ae70c75750848 && git checkout cba3123a0805d5acdcf44a872f8ae70c75750848
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin de75e407e53af5a54b265261bc886e167626ce98 efd084c428c0e6ff7985648a9ff7f532c5c42d3d && git checkout -B drift-repro de75e407e53af5a54b265261bc886e167626ce98 && git merge --no-ff efd084c428c0e6ff7985648a9ff7f532c5c42d3d
node scripts/docs-audit/affected-docs.mjs --json de75e407e53af5a54b265261bc886e167626ce98 |
|
Queue note — second group run; one honest shard-5 red so far, no removal — PM seat
Not this PR's failure: the diff is one lint rule + its test + a changeset; every PR-side row at Generated by Claude Code |
Fixes #16118
What changed
visibility-bare-identifiernow reports an identifier written bare beside ahas()guard in the same visibility predicate. The exclusion ahas()argument earns is unchanged in intent — it is a legitimate select target — but it is now keyed to that occurrence instead of spreading over the predicate.One rule id, one severity, no new rule, no change to
visibility-predicate-syntax,visibility-predicate-unknown-function,visibility-predicate-over-budgetor the RHS-position rule.The mechanism, measured — the card's hypothesis is falsified
The card and the triage both read the silence as an exclusion "keyed on the identifier NAME across the whole predicate". Measured on
origin/mainatde75e407e: there is no such exclusion in the rule at all.namespaceRootsonly declares receivers (a.b,a?.b,a['b'],a.exists(…)), and ahas()argument is not a receiver — the rule had nohas()handling of any kind.The silence came from the shared oracle.
firstUndeclaredReferencereads the first error cel-js's checker reports and acts only on aUnknown variable: Xmessage. A barehas(x)—has()applied to something that is not a select — fails that check withhas() invalid argument, a different class, so everything behind it in the same predicate went unjudged.Probe against the strict environment the helper builds, raw checker verdict beside the helper's answer:
firstUndeclaredReferencestatus == "qualified"Unknown variable: status"status"has(status) && status == "qualified"has() invalid argumentnullhas(status) && other == "x"has() invalid argumentnullother == "x" && has(status)Unknown variable: other"other"has(record.status) && status == "qualified"Unknown variable: status"status"Row 3 is what tells the two mechanisms apart: the masked name is not the guarded one. The masking is positional, not name-keyed. Row 4 shows it is order-dependent — the same predicate with its arms swapped reports.
The fix
maskHasCalls(source, ast)replaces everyhas(…)call with a same-widthtrueliteral, using the canonical AST's ownstart/endspans, and the masked source is what reaches the checker. That does both jobs at once: the argument occurrence is gone (the exclusion, kept, now per-occurrence), and no first error of another class is left to mask the rest.Three properties the shape is chosen for:
has()call in the author's own bytes.namespaceRootsandbareRhsOnlyIdentifiersstill read the original AST, so the declared list is unmoved.Red-first
Eight pins added on
formStack, run on the tests-only commit before any source change:The six that passed include both controls the triage asked for: row 1 (
status == "qualified", must keep firing) and row 3 (has(status)alone, must stay silent).Ablation
Implementation committed first, then the occurrence keying reverted in place (
maskHasCalls(source, ast)back tosource) with a trap-guarded restore:Exactly the two rows the fix is for go red; rows 1 and 3 and the five other pins stay green, so the pins discriminate and the negative control is not carried by the fix. No rebuild leg: the suite imports the mutated module by relative path inside
packages/lint/src, so it resolves to source and not todist/.Positive control on a real app
objectstack validateoverexamples/app-showcase, with the predicate attask.view.ts:368swapped fromrecord.priority == 'urgent'tohas(priority) && priority == 'urgent':That is the card's own shape reaching the real CLI. Unmutated, all four example apps validate at exit 0 — the fix surfaces no pre-existing defect in the examples.
Verification at
efd084c42pnpm --filter @objectstack/lint test— 100 files, 3425 passed / 5 skipped,VERDICT command-exit 0pnpm --filter @objectstack/lint typecheck—VERDICT command-exit 0pnpm --filter @objectstack/metadata-protocol test— 2414 passed / 10 skipped,VERDICT command-exit 0pnpm --filter @objectstack/cli exec vitest run --project unit— 2453 passed / 6 expected fail,VERDICT command-exit 0. Theintegrationtier is declared to CI: the diff touches no tier-integration file, nobin/entry and no spawn helper.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0 at this head shadispatch-gates.mjsderives for this diff — run,--ranreconciles54 derived, 54 run, 0 NOT-MEASURED, 0 UNRUN. Two needed a prerequisite before they measured anything (check:docs-transcript-driftandcheck:dual-build-cjs-loads, both exit 3 / PREREQUISITE NOT MET until the build; green after).check:type-check-debtneeds its own documented 6144 MB ceiling — exit 3 under a tighter one, exit 0 at 6144 withnone above its recorded number.The 54 downstream consumers of
@objectstack/lintare effectively the whole workspace; CI runs the farm.Behaviour change
Predicates that used to publish clean now gate: a guarded-but-unprefixed
visibleWhenon a view, page component or form section is refused at build, validate and lint alike. That is the fail-open shape the rule exists to catch — such a predicate never evaluates for any record, and an unevaluablevisibleWhenon a form surface renders the field and carries itsrequired: trueinto the console's submit check (card 688).@objectstack/lintpatch.Out of scope
firstUndeclaredReferencereturns null for every undeclared reference behind a first checker error of another class #16412, unassigned —firstUndeclaredReferencereturnsnullfor every undeclared reference behind a first checker error of another class, andflow-variable-scope.ts:273plusvalidate.ts:688/:713read it too. This PR repairs only the visibility rule's call site.has(x)is itself refused by the canonical checker (has() invalid argument) yet parses, so no rule in this family reports it — row 3 is silent because nothing judges it, not because something decided it was fine. The triage pinned row 3 as the negative control and this PR keeps that verdict unchanged; whether some rule should own that source is a separate decision.🤖 Generated with Claude Code
https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Generated by Claude Code