Skip to content

scripts/pm/dispatch-gates.mjs dispatches its CLI at module top level, so the re-exports its header offers cannot be imported — import runs the tool and process.exit(2)s the importer #9757

Description

@os-steve

Measured while implementing #9640 (the swallowed export { maskComments };). Not repaired there: different defect class, and the repair needs a second file changed — see "Why it is its own card" below. Filed unassigned, severity for triage.

Facts

scripts/pm/dispatch-gates.mjs ends with an unguarded CLI dispatch at module top level (const argvPaths = process.argv.slice(2)... through the closing else branch). There is no entry guard, so importing the module runs the tool against the importer's argv and cwd, and on most of those paths calls process.exit(2) before the importer's own code runs.

Measured on main (2065e31), a bare dynamic import from a node -e:

dispatch-gates: this branch changes nothing against 'origin/main' (merge base 2065e31d5) — nothing to derive.
On the base branch already, or in the wrong checkout? Pass explicit paths to ask about a hypothetical surface.

...and the importer's own console.log never ran: exit 2 came from the gate, not from the consumer. To measure the module's export surface at all, #9640 had to pass a path argument so the CLI took its derive() branch and returned instead of exiting.

Why it matters

The module offers two re-export blocks, both with a stated rationale:

Both are the file's public surface by intent, and today none of it is reachable: any consumer that writes import { maskComments } from './dispatch-gates.mjs' gets the tool's output and an exit code instead of a function. #9640's card predicted this would fail "as undefined is not a function at call time, not at import" — the measurement says it fails harder and earlier than that, at import.

Precedent, same class, already ruled and landed: #6566 → PR #6695 added an entry guard to check-adr-0087-registration.mjs for exactly this ("一个不能被安全 import 的门,等于强迫每一个想与它保持一致的调用方自己起进程"). The sibling module this file re-exports FROM already carries the guard and the reason, three files away:

// Executed only as a CLI. Importing this module must have NO side effect: the
// gates below it are the callers, and a shared module that exits on import is
// a shared module nobody can share.

Why it is its own card, not a rider on #9640

The obvious repair — copy the guard — silently changes what pnpm check:pm-dispatch-gates measures. That gate is scripts/pm/check-dispatch-gates.mjs, and it runs the tool as a child process with stdio: 'inherit', then process.exit(result.status ?? 2). It holds the exit status only. A guard whose comparison does not match the invocation form turns the tool into a no-op that exits 0, and the gate reports that as a pass — the silent-success direction this tree treats as worse than no check. So whoever takes this should harden the gate to assert the self-test actually ran (its summary line, or a case count) in the same PR.

Second thing to settle while here: the tree spells this guard four different ways in four files — resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url)) (check-adr-0087-registration), import.meta.url === pathToFileURL(process.argv[1] ?? '').href (check-cross-repo-closer-outcome), the same with a process.argv[1] && prefix (check-kernel-hook-pairs), and new URL(\file://${process.argv[1]}`).href` (js-comment-mask, which breaks on a path needing percent-encoding). Picking one is cheap while the file is open.

Dedupe

search_issues for "dispatch-gates module executes its CLI on import, no main guard, import side effect" returns the ADR-0087 precedent (#6566, closed) and dispatch-gates cards about gate derivation (#9700, #9721, #9320, #9116) — none about this module's import semantics.


Generated by Claude Code

Activity

  1. claude commented on Aug 19, 2026

    @claude
    Contributor

    PM seat action (#10021) — re-graded pm:blocked → pm:queue

    Re-graded to pm:queue. No dependency exists to backfill. Verified the finding is still live on current main: grep -n invokedDirectly scripts/pm/dispatch-gates.mjs returns nothing, so the CLI still dispatches at module top level. ⚠️ Note the sibling shape in scripts/pm/check-governed-merges.mjs was fixed (PR #9897 added the guard at its line 1016, citing that file's own line 810 as precedent) — so this card now has a landed in-repo precedent to copy, which it did not have when it was filed. Closing #9839 as fixed did not fix this one; different file.


    Why there was nothing to backfill. #10021 asked for either a machine-readable Blocked-by: #<n> line or a re-grade. Auditing all five cards it names, none carries a real issue dependency — pm:blocked was applied as a holding state, not as an edge. That is the honest answer to the escalation: the label was wrong, not the Blocked-by: line missing. A backfill would have invented edges to satisfy a scan.

    ⚠️ Worth recording: the unlock scan's grep did report hits on two of the five — but they were the literal template text Blocked-by: #N / Blocked-by: #<n> inside the triage comment asking for one. A scan that matches the instruction to add an edge, and reports it as an edge, is the same false-green family this lane has worked all day.


    Generated by Claude Code

  2. claude commented on Aug 20, 2026

    @claude
    Contributor

    Claim: PM loop round 27
    Session: session_01XqDQYVU5smx29ts9pAErja
    Branch: claude/issue-9757-dispatch-gates-invoked-directly
    Worktree: objectstack-issue-9757
    Domain: domain:devx
    File surface: scripts/pm/dispatch-gates.mjs (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (node scripts/pm/dispatch-gates.mjs --tier scripts/pm/dispatch-gates.mjs run this round: "no path-derived mandate … This line is a FLOOR, never a clearance")
    Clause-②: no
    Serial constraints cleared: swept all open PRs for any touching dispatch-gates — none. PR #9939 (#9883), which added the unreachable verdict to this file, is MERGED.

    Re-graded from pm:blocked → pm:queue under #10021 (seat action): the card carried no Blocked-by: edge and no dependency in its body. Verified still live before claiming — grep -n invokedDirectly scripts/pm/dispatch-gates.mjs returns nothing on current main.


    Generated by Claude Code

  3. os-steve commented on Aug 20, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9757,
      "status": "done",
      "branch": "claude/issue-9757-dispatch-gates-invoked-directly",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10084",
      "premise_still_valid": true,
      "summary": "The card's premise holds exactly as written: dispatch-gates.mjs dispatched its CLI at module top level and importing it ran the tool against the importer's argv/cwd. Repaired with ONE entry guard wrapping the single dispatch chain, plus an exported invokedAs predicate pinned by ten new self-test cases. H1: reproduced both damage shapes, but the defect is LATENT — a tree-wide search for importers (static and dynamic) returns ZERO; the only in-tree consumer, check-dispatch-gates.mjs, spawns the tool as a child process, which the defect never touched. Severity is 'the advertised surface does not exist', not 'CI is wrong'. H2: the module's entry structure is a SINGLE if/else chain at end of file after every declaration incl. selfTest, so it needs one guard site, not the sibling's two. H3: byte-identical, md5 9d31764e84a1efaae6a668ab50396cd3 in both worktrees, watch-hint set unchanged. H4: deliberate break still reds. Ruling 3 held — no verdict, population, tier answer or exit code moved. The card's request to harden check-dispatch-gates.mjs in the same PR was NOT done: that file's own header forbids it ('Nothing else belongs in this file. Assertions go in the tool's own self-test'), it is a second file outside the declared surface, and the new self-test cases close the same hole where the assertions belong. The guard-spelling convergence the card mentions in passing turned out to be a much larger measured defect and is filed as #10086.",
      "tests": "All at final HEAD 6ad9cf454c (blob 330f89ad04fbb74ad9128673b8b3d95d36b1ac2e); exit codes captured by redirect-then-capture, never through a pipe.\n\nLOCAL GATE UNION — derived, not recalled, by `node scripts/pm/dispatch-gates.mjs` with NO paths (exit 0), which named check:cross-package-test-inputs [lint.yml + ci.yml] and check:pm-dispatch-gates [lint.yml]; check:nul-bytes added because any edit implicates it. Each gate's own verdict line:\n  check:pm-dispatch-gates          EXIT=0  '✓ dispatch-gates self-test: 344 cases pass.'\n  check:cross-package --self-test  EXIT=0  'All 57 self-test cases passed.'\n  check:cross-package-test-inputs  EXIT=0  'OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'\n  check:nul-bytes --self-test      EXIT=0  '✓ check-nul-bytes --self-test: 75 assertions over a temp git repo (real scan() path)'\n  check:nul-bytes                  EXIT=0  'check-nul-bytes: OK (scanned 6367 text file(s) ... no raw ASCII control bytes).'\nPlus a direct control-byte self-scan of the changed file: grep -naP over the C0 set, no hits.\n\nH1 REPRODUCTION (against the file at origin/main, in a second worktree):\n  `node consumer.mjs`             -> exit 2, stdout is THIS TOOL's 'this branch changes nothing against origin/main (merge base b1457eeca) — nothing to derive.'; the consumer's own console.log NEVER RAN.\n  `node consumer.mjs --self-test` -> 338 lines: all 334 of this file's assertions ran INSIDE the importer, printing '✓ dispatch-gates self-test: 334 cases pass.' immediately above the consumer's own '✓ consumer self-test: 2 cases pass.' Same shape PR #9897 fixed at 77 assertions.\n  After the fix, same two invocations: exit 0, '[consumer] imported fine; maskComments is a function', and the self-test run shows ONLY the consumer's 2 cases. The export surface is now measurable without defeating the CLI: 46 names (45 pre-existing + invokedAs).\n\nH3 BYTE-IDENTICAL PROOF, two worktrees (one at origin/main b1457eecaf, one on this branch): 146 sampled paths through `--tier` individually (deterministic every-48th of `git ls-files` = 6372 files, plus 16 hand-picked paths covering every tier glob and both re-export subjects), 3 multi-path `--tier` invocations, the full derivation over the whole sample, `--residue` over the whole sample, derivation + residue over the tool's own two-file surface, and the `--changed`-with-paths refusal. Result: 1242 lines each, md5 9d31764e84a1efaae6a668ab50396cd3 in BOTH trees, `diff -q` clean. WATCH-HINT CHECK: this file's own hint set is 6 hints before and the same 6 after ('.claude/skills/pm-dispatch/**', '.github/workflows', 'packages/drivers', 'packages/plugins', 'packages/services', 'packages/spec/src/**'), so no other card's MATCHED column moves; and check:pm-dispatch-gates resolves to check-dispatch-gates.mjs (2 hints), which this PR does not touch. Mechanical corroboration: `git diff -w origin/main` removes exactly TWO lines — the two rewritten import statements — so not one line of the dispatch chain is removed under whitespace-insensitive diff.\n\nH4 DELIBERATE BREAK: flipped a PRE-EXISTING assertion (`t('extracts plain pnpm check', false)`) from the committed state. `node scripts/pm/dispatch-gates.mjs --self-test` -> EXIT=1, '✗ dispatch-gates self-test: 1 of 344 case(s) failed.'; `node scripts/pm/check-dispatch-gates.mjs` -> EXIT=1 with the same verdict line. The guard did NOT make the self-test unreachable — no third #9898-class dead self-test was created.\n\nABLATION of my own defense: removed the realpath half, leaving the precedent's one-comparison spelling. Direction observed = RED, as predicted: '✗ invoked through a symlink to this file, --tier still answers' and '✗ and it answers the SAME thing as the direct invocation', EXIT=1, '✗ dispatch-gates self-test: 2 of 344 case(s) failed.' REBUILD STATEMENT: no build step exists in this loop and none was needed — the script is executed by path from source (`node scripts/pm/dispatch-gates.mjs`), and the self-test's child spawns point at that same source path, so there is no package `exports` or `dist/` between the edit and the run that could serve a stale tree. The mutation's arrival was proved directly on the executed file (`grep -c 'realpathSync(entry)'` = 0, `grep -c 'return entry === self;'` = 1). Both legs — break and ablation — were taken from the COMMITTED state and restored with `git checkout HEAD --`; `git hash-object` matched `git rev-parse HEAD:<path>` at 330f89ad04fbb74ad9128673b8b3d95d36b1ac2e after each restore, and the restore leg re-ran green at 344 cases.\n\nEDIT-LANDED-ON-DISK VERIFICATION (per the #10003 clause): each of the three edits asserted its own match count == 1 before writing, then was confirmed by independent grep of BOTH the injected and the removed text — imports (injected 1/1, removed form 0), guard (injected 1/1/1, `^const argvPaths` at column 0 now 0, indented form 1), self-test block (3 markers present, old adjacency 0). `node --check` clean.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10086: the invokedDirectly entry guard is spelled ~8 ways across scripts/, and EVERY spelling makes its script silently inert (exit 0, zero output) when reached through a symlink — measured on main: check-governed-merges.mjs --self-test goes 81 assertions -> 0 lines exit 0, js-comment-mask.mjs --self-test goes 15 cases -> 0 lines exit 0. Matters beyond tidiness because check-governed-merges.mjs is the Prime Directive #14 register and its --test pre-arm predicate returns EXIT_TEST_NOT_GOVERNED = 0, the same code as an inert run. Two further scripts (check-half-states.mjs:4149, qa-rollup.mjs:944) match on BASENAME alone, which can fire on import as well as go inert. Unassigned, no label, left for PM triage."
      ]
    }

    Notes for the PM seat, outside the JSON

    • Ruling 3 held. Comment/guard only. The byte-identical corpus and the unchanged watch-hint set are the evidence; no verdict, population, tier answer or exit code moved. The one number that changes is the self-test's case count (334 → 344), which is not a verdict, a population, a tier answer or an exit code.
    • The card's second ask was declined on the card's own evidence, not on convenience. scripts/pm/check-dispatch-gates.mjs's header says: "Nothing else belongs in this file. Assertions go in the tool's own self-test, beside the code they judge; this is the CI invocation and its reason." Hardening it would also be a second file outside the declared surface and would move that gate's exit-code behaviour, which the dispatch ruled out. The hole the card was worried about is closed where that header says it belongs — ten cases that spawn real child processes.
    • Do not read "latent" as "not worth having fixed". Nothing imports the module today, so nothing is broken right now; what was broken is that the two re-export blocks' stated purpose — consumers driving the same predicates instead of copying them — was unavailable to anyone who tried, which is exactly the pressure that produces the copies those comments exist to prevent.
    • PR fix(pm): dispatch-gates runs its CLI only when invoked directly (#9757) #10084 is draft, skip-changeset applied (labels read back as ["size/m","skip-changeset"] — the bot's size/m preserved by writing the union, not a bare set). Not armed, not flipped ready. CI convergence is yours per the 2026-08-10 ruling.

    Generated by Claude Code


    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