Skip to content

[finding] checkGuardAdoption's call bans only read the gate FILE — a measurement moved one import out is invisible to all three #10625

Description

@os-zhuang

Recorded from the #10599 dev seat (session session_01DdCnBGcHeufjrq7drTD3wt, branch
claude/issue-10599-fatal-guard-linttext, PR #10624) while closing the lintText half of the
adoption check. Not touched there — out of that card's scope, and it is a bound rather than a live
false green.

The bound

checkGuardAdoption() reads exactly the sources named in GUARDED_GATES:

for (const gate of GUARDED_GATES) {
  src = readFileSync(resolve(repoRoot, gate), 'utf8');
  problems.push(...guardAdoptionProblems(gate, src));
}

So every test it applies — the import test, the armed test, the .lintFiles( ban, and now the
.lintText( ban from #10599 — is a statement about one file's text. A gate that moved its
counting into a sibling helper (import { measure } from './lint-population.mjs', with the raw
eslint.lintFiles() or eslint.lintText() living there) presents a gate file with no banned call
shape in it at all. The two bans see nothing. The import and armed tests still pass as long as the
gate file keeps any one strict call — which is the same mixed shape #10599 closed, one indirection
further out.

This is the same sentence #10123 → #10458 → #10599 keep landing on, each time one level up: the
check proves a fact about the text it read, and the claim it is used to make is about the
measurement. #10599 moved the undecidable part (which result is COUNTED) to a declaration at the
call site; nothing yet decides WHICH FILES have to carry those declarations.

Severity: latent

Neither gate has a helper module today — both check-slot-lookup-ratchet.mjs and
check-query-options-erasure-ratchet.mjs lint inline. Nothing is green that should be red right
now. It is worth writing down because the natural next refactor of either gate (both are past 700
lines) is exactly "pull the measuring part into a module", and that refactor silently removes the
gate from its own guard with no diff to the check.

Shapes worth weighing, none obviously right

  1. Leave it, and say in checkGuardAdoption()'s docblock that its claim is scoped to the gate file
    — the same narrowing checkGuardAdoption still passes a gate that counts messages from lintText() — the adoption check tests the call it names, not the measurement it guards #10599 applied to the printed sentence.
  2. Follow the import graph from each gate and apply guardAdoptionProblems() to every local module
    it reaches. Decidable for static import specifiers, and it makes the population a derived fact
    rather than a hand-kept list — but it needs a specifier resolver the check does not have.
  3. Ban the raw call shapes repo-wide outside scripts/eslint-fatal-guard.mjs, so no file can host
    an unguarded lint at all. Widest, and the one that stops depending on which files are named;
    costs a sweep of whatever else in scripts/** legitimately lints.

Refs: #10123 (the card that created the guard) · #10458 / PR #10598 (comments-as-code, and the
armed test) · #10599 / PR #10624 (the lintText half, where this was noticed).


Generated by Claude Code

Activity

  1. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim: /pm-dispatch domain:devx execution seat.
    Session: session_01DdCnBGcHeufjrq7drTD3wt
    Branch: claude/issue-10625-guard-adoption-file-bound
    Worktree: objectstack-issue-10625
    Domain: domain:devx
    File surface: scripts/eslint-fatal-guard.mjs (checkGuardAdoption / GUARDED_GATES) + the --self-test in scripts/check-query-options-erasure-ratchet.mjs

    ⚠️ Severity is latent and the card says so — neither guarded gate has a helper module today. ⛔ Do not write the PR as if a live false green were being closed. This is the fourth level of one sentence (#10123 → #10458 → #10599 → here), and the honest framing is that it is a bound on what the check can claim.

    ⛔ Governed surface out of scope (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**) and the #9465 epic fence (.changeset/ tooling, cut-rc.yml, release.yml, root package.json).
    ⚠️ No formatter of record in this repo (no format/prettier script, prettier not in devDeps, no .prettierrc/.editorconfig) — ⛔ do not run prettier --write (#10622).
    ⭐ Changeset: AGENTS.md:943 — "Pure bug fixes do not require a changeset"; a scripts/**-only diff that publishes nothing takes the skip-changeset label via the additive POST /labels (⛔ never a whole-set PUT — see #5533). Precedent: PR #10502.


    Generated by Claude Code

  2. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10625,
      "status": "done",
      "branch": "claude/issue-10625-guard-adoption-file-bound",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10668",
      "premise_still_valid": true,
      "summary": "The bound is real and reproduced twice. checkGuardAdoption() now judges each gate over its LOCAL IMPORT CLOSURE (relative specifiers, transitive, literal-aware) instead of the gate file alone: the two call bans extend to every closure member, while the import and armed tests stay file-scoped on the gate because read over the closure they DILUTE (eslint-stack-headroom.mjs already imports the guard for collectFatalMessages, so any gate importing it would pass a closure-scoped import test regardless of what the gate does). The guard module itself is excluded from both the scan and the walk -- its raw calls are the implementation and its own `export async function lintFilesStrict(` would answer an armed test about a call somewhere else. Where the walk stops being decidable (computed specifier, unresolvable import, one leaving the repo) it reports that rather than claiming a closure it did not walk. lintFilesUnguarded({ why }) is the lintFiles twin of #10599's declaration, needed because a closure module that lints raw for a real reason must be able to say so instead of living in a hand-kept exemption list. ONE CARD CLAIM IS FALSE, and it is the severity rationale: 'neither gate has a helper module today -- both lint inline' is wrong. Both gates import scripts/eslint-stack-headroom.mjs, which has held a raw eslint.lintFiles([file]) since #10449, invisible to the old check. Severity still does NOT move to live-false-green -- that call is fatal-aware (it hands results to collectFatalMessages and returns the fatals), so nothing is mis-measured. What moves is the DISTANCE: the sibling module the defect needs already exists in both closures.",
      "tests": "All at d8a0eb9 (== pushed HEAD, working tree clean), heavy work via scripts/pm/os-verify-lock.sh. GATE UNION: `node scripts/pm/dispatch-gates.mjs` with no paths, exit 0, derived 3 families from 3 changed paths (check:cross-package-test-inputs, check:query-options-erasure, check-cross-package-test-inputs.mjs). HAND CROSS-CHECK ADDED check:slot-lookup and check:entry-guard: the derivation matches on PATHS and the slot-lookup gate is affected through an IMPORT EDGE into scripts/eslint-stack-headroom.mjs, which no path derivation can see -- a small live instance of #10648. RUN (VERDICT command-exit 0 -- each gate's own verdict line quoted, exit codes captured before any pipe): check-query-options-erasure-ratchet.mjs --self-test EXIT=0 '✓ self-test: 10 reporting shape(s), 10 silent counterpart(s) ... adoption proved both ways over 11 synthetic gate source(s), files AND text, and the closure walk over 12 synthetic tree(s)'; the gate itself EXIT=0 '✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'; check-slot-lookup-ratchet.mjs EXIT=0 '✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'; check-cross-package-test-inputs.mjs EXIT=0 'OK: 13 package(s) read outside themselves, all declared'; check-nul-bytes.mjs EXIT=0; check-entry-guard.mjs EXIT=0. REPRODUCTION, before any edit, against the checker as PR #10624 left it: (A) synthetic gate delegating a second population to a sibling helper -> gate problems [] (0), helper problems 2 but never read; (B) LIVE TREE -> checkGuardAdoption(repoRoot) = [] while scripts/eslint-stack-headroom.mjs:212 holds `const results = await eslint.lintFiles([file]);` and is imported by both gates. ABLATIONS -- 8 legs, each: mutate -> RE-READ THE FILE FROM DISK and count the anchor (removed-anchor 1->0, injected-anchor 0->1) -> run -> restore -> count again (1/0, the --absent leg) -> re-run green. NO BUILD STEP IS INVOLVED and that is verified, not assumed: these gates import each other through relative ./*.mjs specifiers, so the code under test is the source on disk, never a dist/ artifact. A=closure sweep removed: predicted 4, got 4. B=walk made depth-1: predicted 1, got 1. C=guard-module exclusion removed: predicted 5, GOT 16. D=literal-awareness removed: predicted '1 + live noise', got 8. E=computed-specifier report removed: 1/1. F=repo-escape detection removed: 1/1. G=lintFilesUnguarded stops requiring `why`: 1/1. H=THE LIVE ONE, canary reverted to a raw call: predicted 2, got 2 (both gates red through a file neither of them is). BOTH OVERSHOOTS ARE EXPLAINED, not waved past: C also drags the guard module into every FIXTURE's closure via FIXTURE_IMPORT, where the synthetic file map has no entry for it, so 8 more cases fail on 'unreadable'; D shows literal-blindness costs exactly what #10598 predicted one level out -- invoked-as.mjs's templated `await import(${...})` read as 2 computed imports per gate, plus this gate's own fixture STRINGS followed as real imports. THE HARNESS CAUGHT ITSELF ONCE: the first ablation run used open(path,'w').write(read(path)...), and Python truncates before the argument is evaluated, so the file was zeroed and the 'mutation' wrote an empty file. The on-disk anchor read-back reported injected-anchor=0 and ABORTED the run rather than reporting a green ablation over a 0-byte source; restore was free because the work was committed first. Exactly the failure the anchor-count rule exists for, and it fired. NEGATIVE CONTROL: unmodified tree passes (checkGuardAdoption -> []); #10599's legitimate lintText calls stay unflagged -- the four cases asserting a declared/guarded lintText is clean still expect [] and get it; all 11 pre-existing GUARD_ADOPTION_CASES unchanged in text AND expectation.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10666: the guard-adoption class is bounded four times over, never retired -- a capability boundary (the guard owning ESLint construction, handing back count() and nothing else) would end it; a fifth source-text patch will not. Unassigned, `finding` label, no pm:queue."
      ],
      "notes": {
        "file_surface_amendment": "The claim comment declared scripts/eslint-fatal-guard.mjs + the --self-test in scripts/check-query-options-erasure-ratchet.mjs. ADD scripts/eslint-stack-headroom.mjs: canaryParseFailures() converted to lintFilesUnguarded({ why }). Bounded in-place fix -- same defect class (it IS the instance this card's own check discovers), shape pinned by #10599's existing sibling declaration, same gate family, no new verification surface. Named in the PR body with its evidence, as the exemption requires.",
        "convergence_read": "Asked for, and it is the more useful answer than the patch. On COST TO EVADE this converges: the population is derived rather than named, so the next level out is already covered and the remaining escapes take deliberate obfuscation rather than a natural refactor. On KIND it does NOT, and no fifth patch of this shape will -- every test is a regex over source text and which result is COUNTED is data flow, which is exactly why #10599 had to move the decision to an author declaration. The answer that retires the class is different in nature: stop letting a guarded gate hold an ESLint instance at all. Filed as #10666 rather than smuggled in here, and filed as a direction to WEIGH -- including the option of saying no to it, since a startup-stage repo does not owe every latent class a structural fix.",
        "considered_and_rejected": "checkHeadroomAdoption() in scripts/eslint-stack-headroom.mjs has the same file-bound shape but is NOT the same defect and was left alone: ensureStackHeadroom() re-execs the process, so it must be called from the gate's own top level. Moving it into a helper makes that test go red LOUDLY (a false positive), never silently green. No issue filed."
      }
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions