Skip to content

[finding] 39 scripts/ modules export bindings with no entry guard — the class has now been fixed one file at a time twice, and check:entry-guard accepts "no guard at all" by design #10631

Closed
1 of 1 issue completed

Description

@claude

Filed unassigned from the #10610 dev seat (session session_01DdCnBGcHeufjrq7drTD3wt, PR #10628). This is the sweep that #10610's "Not claimed" section explicitly left undone — "I have not swept the other scripts/** gates for the same missing guard. If several are unguarded the shape of the fix changes."

Several are. 39.

The measurement

On origin/main at 5e50e2e3ba, files under scripts/ that export at least one binding and have no isEntrypoint(import.meta.url) guard — comments and string literals masked with check-entry-guard.mjs's own codeOnly(), so a prose mention of the idiom is not miscounted as a guard:

scripts/ files scanned                       : 123
with a real (code, not comment) entry guard  : 41   (42 after PR #10628)
exporting bindings AND unguarded             : 40   (39 after PR #10628)

The 39 that remain:

ablation-dist-preflight.mjs        check-release-page-status.mjs
adr-anchors.mjs                    check-required-contexts.mjs
check-changeset-no-major.mjs       check-route-envelope.mjs
check-dispatcher-error-vocabulary  check-runtime-services-index.mjs
check-driver-memory-census.mjs     check-shard-attestation.mjs
check-empty-changeset.mjs          check-spec-parsed-alias.mjs
check-engine-split-ratio.mjs       check-startup-registry-verdict.mjs
check-error-code-casing.mjs        check-tenant-chokepoint.mjs
check-error-status-conformance.mjs check-test-completeness.mjs
check-examples-live-imports.mjs    check-workflow-status-functions.mjs
check-filter-alias-parity.mjs      checklist-select.mjs
check-nul-bytes.mjs                cli-build-prerequisite.mjs
check-org-identifier.mjs           console-spec-probes.mjs
check-query-options-erasure-ratchet.mjs  docs-audit/check-audit-scope.mjs
check-quick-reference-counts.mjs   eslint-fatal-guard.mjs
check-ratchet-remedy-authority.mjs eslint-stack-headroom.mjs
                                   i18n-bundle-surface.mjs
                                   measure-test-shard-timings.mjs
                                   pm/check-governed-prose.mjs
                                   pm/check-label-desc-cap.mjs
                                   pm/check-skill-id-lint.mjs
                                   pm/check-skill-line-ratchet.mjs
                                   regen-artifacts.mjs

Reproduce:

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { codeOnly } from './scripts/check-entry-guard.mjs';
// walk scripts/, skipping node_modules and dotfiles, for .mjs/.js/.cjs
const guarded = /isEntrypoint\s*\(\s*import\.meta\.url\s*\)/.test(codeOnly(src));
const exports = /^export\s/m.test(codeOnly(src));

Why this is a class, not 39 tickets

It has now been fixed twice, one file at a time:

Both were found by someone tripping over one instance. Nothing gates the next one, and there are 39 more sitting there. check-entry-guard.mjs exists precisely because a one-time sweep is worth little on its own — its own header says so about the spelling problem — but it currently enforces only that a guard, if present, is spelled the one right way. A file with no guard at all is accepted; that case is an explicit self-test assertion:

t('a file with no guard at all is accepted', n("console.log('hello');\n") === 0);

which is correct for a script that exports nothing, and is exactly the hole for one that does.

Suggested shape (not claimed)

Extend check-entry-guard.mjs with a second finding kind: a scripts/** file that both exports a binding and performs top-level side-effectful dispatch must carry the guard. That closes the class the way the spelling half was closed, instead of a 40th ticket later.

The hard part is deciding "top-level side-effectful dispatch" precisely enough to avoid false positives — a module of pure export functions with no top-level statements is fine unguarded, and several of the 39 may be exactly that. Worth measuring before choosing between:

  • A. Require the guard whenever the file exports AND has any top-level call expression. Strongest; needs a real parse, and check-entry-guard.mjs is deliberately a masked-source scanner rather than a parser.
  • B. Require it only when the file exports AND reads process.argv. Cheap, no parser, catches the CLI-dispatch shape all three known instances had — including this one. Misses a module whose top-level side effect is not argv-driven.
  • C. Behavioural: import each candidate in a child and assert silence + exit 0. Directly measures the property. check-entry-guard.mjs's header already rejected running scripts wholesale, but importing them is a much weaker action than invoking them — though not a safe one for the modules whose top level has real side effects, which is the same objection in a smaller form.

B looks like the best value for the risk; A is the one that actually closes the class. Someone should measure how many of the 39 have top-level statements at all before picking — that number decides whether this is a 5-file fix or a 39-file one.

Not claimed


Generated by Claude Code

Activity

  1. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    Claim: /pm-dispatch domain:devx execution seat.
    Session: session_01DdCnBGcHeufjrq7drTD3wt
    Branch: claude/issue-10631-entry-guard-class
    Worktree: objectstack-issue-10631
    Domain: domain:devx
    File surface: scripts/check-entry-guard.mjs preferred; a 39-file sweep only if the gate-level answer is ruled out with evidence

    ⭐ PM steer, on the record before work starts: this class has now been fixed one file at a time twice (#9757, then #10610/PR #10628), and the card's own framing is that check:entry-guard accepts "no guard at all" as an explicit self-test assertion. Three instances of a shape is the argument for asking whether the fourth belongs in the gate rather than in a 40th ticket. ⛔ Do not open with a 39-file sweep; price the gate-level answer first and argue whichever way the measurement points.

    ⛔ 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 (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
    ContributorAuthor
    {
      "issue": 10631,
      "status": "done",
      "branch": "claude/issue-10631-entry-guard-class",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10665",
      "premise_still_valid": true,
      "summary": "Re-derived on today's origin/main (9dd192d48b): 123 scanned / 42 guarded / 39 exporting-and-unguarded - the card's numbers reproduce exactly. GATE, not sweep, and the measurement decides it. Every exporting scripts/ file was imported in a child process for its exports alone with a trailing ALIVE marker: of the 42 guarded, 33 loadable ones were all silent+alive (0 dead, 0 noisy); of the 39 unguarded, 8 killed the importer (FIVE of them with exit 0), 14 ran the tool loudly, and 7 were silent. Those 7 - adr-anchors, cli-build-prerequisite, console-spec-probes, eslint-fatal-guard, eslint-stack-headroom, i18n-bundle-surface, regen-artifacts - are pure library modules and are the legitimate exceptions the card asked me to measure before proposing a rule. They cost ZERO allowlist entries because the rule is 'a file that exports must have every top-level statement that RUNS behind the guard', which structurally never reaches a module of declarations. Static rule and behavioural probe agree on all 29 loadable files (7 silent / 22 not), no disagreement either way - that agreement is the licence to make it a rule. The 35 files that violate it go into an inline shrink-only KNOWN_IMPORT_UNSAFE debt list with the expanding remedy REFUSED outright (#8435 route b, so no MAINTAINER-ONLY token and no CONTROL entry in check-ratchet-remedy-authority is owed - that gate is green and still classes this file as handing out no ratchet-expanding remedy); a fixed file fails as STALE and names itself, so the list cannot rot into an allowlist. Burn-down filed as sub-issue #10667 and deliberately excluded: the claim declares scripts/check-entry-guard.mjs, and 35 gate scripts is the surface other seats work in. KEY CORRECTION TO THE CARD: 39 is keyed on guard PRESENCE, which is the wrong property. Keyed on what actually RUNS the population is 35, and its membership is not a subset of the 39: 11 files are guarded through a const alias (const isMain = isEntrypoint(import.meta.url); if (isMain)...), including the inverted 'if (!invokedDirectly) {} else if' idiom - my first cut called all 11 violations - and 3 files the card counts as ALREADY FIXED are leaking: qa/qa-rollup.mjs runs its self-test inside any importer whose argv carries --self-test, objectui-range.mjs prints help for --help, and ts-parse.mjs:462 registers a process.on('exit') hook under an env var. Both measurable ones are silent with a clean argv, so the card's option C (behavioural) would have missed all three. That argv-conditional shape is exactly what pm/check-governed-prose.mjs already hand-works-around by filtering --self-test out of process.argv before importing a sibling.",
      "tests": "All at final commit 4a83b1a3db (union re-derived AFTER the final commit). No build and no dist anywhere in this change - these are plain node scripts - so no ablation rebuild step applies; every mutation was instead confirmed on disk by anchor count before the reading was trusted. No heavy build/test ran, so scripts/pm/os-verify-lock.sh was not used and there is no VERDICT line to quote; inventing one would be worse than saying so. node_modules is absent in this container, which is why 10 of the 39 and 9 of the 42 could not be loaded by the import probe - stated in the PR table rather than papered over. GATES (verdict lines as the gates printed them, exit codes captured before any pipe): 'check-entry-guard self-test: 47 cases pass - all 11 measured spellings rejected, canonical form and masked prose/payloads accepted, and the import-safety rule recognised on both sides' (exit 0; was 26 cases, +21 all on the new rule) | 'check:entry-guard: 123 scripts/ file(s) - every entry guard goes through invoked-as.mjs; 81 export bindings, 46 of them inert on import (35 known-unsafe, SHRINK-ONLY)' (exit 0) | 'check-nul-bytes: OK (scanned 6211 text file(s) ... no raw ASCII control bytes)' (exit 0) | 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.' (check:cross-package-test-inputs, exit 0) | 'OK check-ratchet-remedy-authority: 109 scripts swept; 7 mark the expanding remedy MAINTAINER-ONLY, 3 turn it down outright, 99 hand out no ratchet-expanding remedy' (exit 0) | check:parse-guard exit 0. check:single-claim-paths exits 2 here for want of PR_NUMBER/GITHUB_TOKEN - a CI-context refusal, not a result about this change. grep -naP over the changed file for raw control bytes: no match. UNION: node scripts/pm/dispatch-gates.mjs with NO paths, exit 0, derived from merge base 9dd192d48b: check:entry-guard and check:cross-package-test-inputs (plus the same via ci.yml). Cross-checked by hand rather than read as a clearance - check:ratchet-remedy-authority and check:parse-guard are implicated by what I wrote (a new shrink-only ledger; a new source scanner) and neither appears in the union, so both were run by hand and both are green. HARM REPRODUCED: importing scripts/check-nul-bytes.mjs for its exports printed its full 6211-file verdict and the probe's trailing ALIVE line NEVER PRINTED, with IMPORTER_EXIT=0 - silent, exit-0 death of the importer mid-import. check-engine-split-ratio.mjs exits 2 into the importer (#9757 still live in a second file). Controls: adr-anchors.mjs and regen-artifacts.mjs both printed ALIVE with zero output, and no import mutated the tree (git status --porcelain empty after). ABLATIONS (mutation confirmed on disk by anchor count in BOTH directions, each restored with git diff --quiet verified clean): (1) guard exemption removed -> predicted 'many', observed exactly 39 fresh findings = the guarded-clean exporters, plus 5 self-test cases red. (2) guard-alias support removed -> predicted 11, observed exactly 11, plus 3 self-test cases red. (3) exportsBindings forced true -> predicted 42, observed exactly 42 = precisely the non-exporting CLIs. (4) POSITIVE CONTROL, runsOnImport blinded to false -> predicted all 35 read STALE, observed exactly 35 stale: the gate cannot go green by going blind, which is the assertion a negative gate most needs. (5) one ledger line deleted -> 1 fresh. (6) a bogus ledger line -> 1 stale, named. TWO ABLATIONS WERE VOID ON THE FIRST ATTEMPT AND ARE REPORTED AS RE-RUNS, NOT FIRST RESULTS: the bogus-entry one passed a literal backslash-n into the source (syntax error - red for the wrong reason), and the ledger-deletion one used grep -F with an EMPTY pattern as its confirmation, which matches every line and printed 707. In that second case the removal half (1 -> 0) was valid and the gate output was correct; I re-ran it cleanly rather than accept a self-contradicting instrument. SHORTFALL THAT WAS A REAL FINDING: ablation (3) reddened ZERO on its first run. The cause was my own code - the export test was written twice, once in the detector and once in the census, so blinding one copy left the gate green. Collapsed to one exportsBindings definition in commit 4a83b1a3db; after that the ablation reddens 42. A rule enforced in one path and not the other is exactly the shape this card is about, one level down. BURN-DOWN LOOP DEMONSTRATED END TO END on check-nul-bytes.mjs and then reverted (git diff --quiet clean): guard added -> gate fails with '1 stale KNOWN_IMPORT_UNSAFE entry: scripts/check-nul-bytes.mjs'; line deleted -> green at '47 of them inert on import (34 known-unsafe)'; re-probed -> 'ALIVE - importer survived; exports: classify, findControlBytes, scan, scannedCharClass'; CLI unchanged -> 6211 files OK and --self-test 75 assertions.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10667 (sub-issue of #10631): burn down the 35 scripts/ modules that run on import - the KNOWN_IMPORT_UNSAFE ledger, with the 4-line zero-re-indentation recipe and the ~5 files needing a real main() extraction",
        "NOT filed, reported here as it is inside #10667's scope: pm/check-governed-prose.mjs mutates process.argv at module scope to filter --self-test out before importing a sibling. That workaround is a scar from this exact class and looks stale now that pm/check-governed-merges.mjs carries the guard - flagged in #10667 rather than preserved",
        "FALSE IN THE CARD, option A: 'require the guard whenever the file exports AND has any top-level call expression' would flag essentially the whole tree - const HERE = resolve(fileURLToPath(import.meta.url), '..') is a top-level call in nearly every file here, including check-entry-guard.mjs itself. The workable form exempts declarations and asks only about statements that run",
        "FALSE IN THE CARD, option B: 'exports AND reads process.argv' has a false-positive side the card does not mention - eslint-stack-headroom.mjs exports, reads argv, and is completely inert on import, so B would demand a pointless empty guard on a pure library module",
        "FALSE IN THE CARD, option C: importing each candidate and asserting silence does NOT directly measure the property. It is blind to argv- and env-conditional leaks and reported qa/qa-rollup.mjs and objectui-range.mjs as clean; both demonstrably run inside an importer whose argv carries --self-test or --help",
        "BRIEF, minor: the GitHub search API is unavailable to this session ('sessions are bound to their configured repositories'), and the MCP search_issues tool returned 'API rate limit already exceeded'. The pre-filing duplicate search was done by paging repos/.../issues instead - worth knowing before the next seat is told to search first",
        "PLATFORM, not another actor: GitHub appended its own '_Generated by [Claude Code]_' block to the PR body on create, duplicating the footer I wrote. A PATCH that removed mine left the platform re-adding one in the BARE form. Content is fully intact; I stopped editing rather than churn the body"
      ]
    }

    Generated by Claude Code

  3. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    Landed in #10665 (merged 2026-08-21T08:30:33Z). Closing as completed.

    That PR carried no Closes #10631 reference, so this card stayed open after its fix merged — closing it by hand and dropping pm:dispatched.


    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