Skip to content

[finding] The same "self-test runs nowhere" class in root scripts/**: 6 unwired, including ts-parse.mjs — whose unrun pin is what check:parse-guard's design argument rests on #10608

Closed

Description

@os-zhuang

Filed from the PR that closes #10574 / #10575, which is itself instance N of this class one tree over: scripts/check-parse-guard.mjs landed in #10573 with a 13-case --self-test and nothing ran it, and that took a separately filed card plus a dispatch round for a human to notice.

Sub-issue of #10196, which measured this class in scripts/pm/** and proposed the detector. This card is the same detector pointed at root scripts/**, with the measurement done.

The measurement — main at d5e7b9f5a1

Derived by resolving pnpm check:* aliases rather than grepping workflows for filenames, exactly as #10196 argues: 87 root scripts/*.mjs ship a --self-test, and 6 are not reachable from any workflow, directly or through an alias a workflow runs.

script root alias exists reached by a workflow
scripts/ts-parse.mjs no no
scripts/js-comment-mask.mjs no no
scripts/check-optional-error-sink-contract.mjs yes (check:optional-error-sink) no
scripts/setup-git-hooks.mjs yes (setup:git-hooks) no
scripts/ablation-dist-preflight.mjs no no
scripts/measure-test-shard-timings.mjs no no

Three of those are worth separating out; the bottom three are developer tools where "nobody runs the self-test in CI" may well be the right answer, and are listed for completeness rather than as a claim.

1. check:optional-error-sink is #10574's defect, second instance, live now

The alias is in root package.json. No workflow invokes it. The gate landed 2026-08-21 in PR #10555 (for #9754) and has never run in CI — the same shape as the card this was found from, five hours apart. It is a working gate (pnpm check:optional-error-sink is green locally: 13 self-test cases, 36 sink types, 15 baselined) that enforces nothing.

Not fixed in the closing PR: its own file surface was already claimed there for a different reason (two raw ts.createSourceFile calls, converted), and wiring a second gate into lint.yml is a different acceptance with a different blast radius.

2. ts-parse.mjs --self-test runs nowhere — and it is the pin the parse-guard's DESIGN rests on

This is the one that is more than bookkeeping. check-parse-guard.mjs is a spelling gate, and its header argues explicitly why that is enough:

The behavioural evidence lives once, at the module: ts-parse.mjs --self-test spawns real children and pins that every measured wreck refuses, names its file, and cannot be swallowed by a caller's try/catch. Pinning the refusal once and enforcing that everyone routes through it covers the same ground for fifteen callers.

That argument is sound and the rejected alternative (a gate that RUNS every entry point) is correctly rejected. But the pin it delegates to runs in no workflow. So CI enforces "everybody calls parseSourceFile" and never once checks that parseSourceFile still refuses. A regression inside ts-parse.mjs — someone downgrading process.exit to a throw, or dropping the parseDiagnostics read — is caught by nothing, and its symptom is the same green line this whole family exists to distrust.

It is now 28 cases (grown in the closing PR to cover createProgramChecked and transpileChecked), spawns real child processes, and takes ~4s.

3. js-comment-mask.mjs --self-test runs nowhere

The masker both check:parse-guard and check:entry-guard depend on to tell code from prose. Its own header records that it exists because two private stripComments families drifted apart in two different directions. A mask that silently stops masking makes both gates fabricate findings out of their own documentation — loud — but a mask that starts over-masking makes both go quietly green, which is the direction that hides.

Shape

#10196's proposal already covers it: one gate asserting that every scripts/** tool shipping a --self-test is reachable from some workflow, directly or through a package.json alias, with a reasoned opt-out list for the deliberate exclusions (the bottom three rows above are candidates). The alias-resolution detector is the ~25 lines used to produce the table here.

⚠️ Worth measuring before building, same caveat as #10196: CI affordability per self-test. ts-parse.mjs --self-test spawns ~20 node children; that is a real cost, though small next to the ~70 steps already in Lint & Repo Gates.

Refs: #10196 (the parent, scripts/pm/** half) · #10574 (the instance this was found from) · #10573 / #10133 (the family) · #8110, #8170, #8162, #8528, #9348 (the five prior one-at-a-time fixes #10196 counts)

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-10608-unwired-selftests
    Worktree: objectstack-issue-10608
    Domain: domain:devx
    File surface: .github/workflows/lint.yml (steps only) + possibly the affected scripts/*.mjs

    ⭐ PM ruling carried into the brief, so it is on the record before work starts: ⛔ do not take a second line in root package.json. The #9465 fence granted this lane one line for check:parse-guard (PR #10612, announced at 9465#issuecomment-5365936055); an allowance for one is not an allowance for two. Wire via a lint.yml step invoking the script directly — that file already carries three steps in exactly that node scripts/… form, with a comment naming this fence as the reason.

    ⛔ 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).
    ⭐ Changeset: AGENTS.md:943 — "Pure bug fixes do not require a changeset"; a diff that publishes nothing takes the skip-changeset label via the additive POST /labels (⛔ never a whole-set PUT — see #5533). Precedent: PR #10502.
    ⚠️ This repo has no formatter of record (verified: no format/prettier script, prettier not in devDeps, no .prettierrc/.editorconfig) — ⛔ do not run prettier --write; its defaults reject files already on main (#10622).


    Generated by Claude Code

  2. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10608,
      "status": "done",
      "branch": "claude/issue-10608-unwired-selftests",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10649",
      "premise_still_valid": true,
      "summary": "The premise holds — unwired self-tests are real and three of them were load-bearing — but the card's TABLE is wrong in both columns, and I report that rather than adopting it. Re-derived by resolving `pnpm check:*` aliases transitively and matching them plus direct `node scripts/…` invocations against all 26 workflows with YAML comment lines stripped: today's origin/main (6b0be02209) has 83 root scripts/*.mjs shipping a --self-test and 8 unreached, not 87/6. Running the SAME derivation against the card's own commit d5e7b9f5a1 gives 82/9 — so the card under-counted at its own baseline, this is not tonight's landings moving it. The gap is the predicate: `--self-test` APPEARING in a file is not the same as a file SHIPPING one; adr-anchors.mjs, eslint-fatal-guard.mjs and regen-artifacts.mjs only mention the flag in prose with no process.argv branch, inflating the denominator, while counting only real argv branches surfaces TWO unreached scripts the card omits — scripts/invoked-as.mjs and scripts/checklist-select.mjs. Wired 4 as lint.yml steps in the `Lint & Repo Gates` job (the required context): ts-parse.mjs, js-comment-mask.mjs, invoked-as.mjs (one grouped step, `node scripts/…`) and `pnpm check:optional-error-sink` (its own step; alias already existed, no fence question). invoked-as.mjs is the bounded in-place addition — same defect class, same file surface, same gate family, no new verification surface, and lint.yml's OWN comment one step above the entry-guard delegates that gate's design argument to it verbatim, exactly the ts-parse shape; it is named with its evidence in the PR body. Left 4 deliberately with reasons in the PR body: setup-git-hooks (its self-test asserts a property of the CURRENT CLONE, and its header says the registrar exits 0 on CI images installing with --ignore-scripts, so wiring it reds a required context on an environment fact no PR changed), checklist-select (reachable only via check:platform-checklist, which lint.yml documents as excluded BY MAINTAINER DECISION), measure-test-shard-timings (a by-hand generator whose consumer side is already pinned — partition-test-shards.mjs --self-test runs at lint.yml:1367 and loadTimings() refuses a missing/malformed dataset), and ablation-dist-preflight (the closest call: cost is not the argument at 0.07s; it is left because no CI gate consumes it and no gate's design argument delegates to it). Diff is 61 lines in one file; no script changed and no second line in root package.json.",
      "tests": "All evidence at HEAD 2fd31188fd; the gate union was derived and run AFTER the final commit. WIRING PROOF (parsed, not grepped): yaml.safe_load of lint.yml puts both new steps in job id 'lint', job name 'Lint & Repo Gates' — the required-status-check context registered for lint.yml/lint in scripts/check-required-contexts.mjs — with job if: None, step if: None, and no paths: filter on any of the three triggers (push/pull_request/merge_group all None). 81 steps in the job, no duplicate step names (ci-failure.mjs diagnoses by name). ABLATIONS — 4 of 4 went red, each mutation confirmed ON DISK by anchor count before the run (never an editor exit code), each restore proved by `git diff --exit-code`. No build/dist is involved: node resolves these modules straight from scripts/*.mjs source, so there is no dist/ that can go stale between mutation and measurement — the rebuild clause does not apply and is not being skipped. (1) ts-parse.mjs, dropped the parseDiagnostics read, anchor '?.parseDiagnostics' 1->0 and injected 1: RED, gate's own line `x ts-parse self-test: 10 of 28 case(s) failed.`, and the detail is the defect itself — `a caller's try/catch cannot downgrade the refusal into a skip — {\"status\":0,\"out\":\"NOT REACHED\",\"err\":\"\"}`, i.e. the parse silently succeeded. (2) invoked-as.mjs, deleted the realpath comparison (the pre-#10086 symlink-blind spelling), anchor 1->0 injected 1: RED, `✗ invoked-as self-test: 2 of 11 case(s) failed.` with detail `a probe reached through a SYMLINK runs -- {\"out\":\"\",\"status\":0}` — the inert-child exit-0 shape. (3) js-comment-mask.mjs, `if (c === '/' && next === '/')` -> `if (c === '/')` (the over-mask direction), anchor 1->0 injected 1: RED, `✗ js-comment-mask self-test: 1 of 15 case(s) failed.`, failing case `regex literal containing an escaped // -- mask: BLINDS (real code removed)`. (4) check-optional-error-sink-contract.mjs, FALLBACK_CHANNEL 'warn'->'info' (the answer its header explicitly rejects), anchor 1->0 injected 1: step `pnpm check:optional-error-sink` EXIT=1, `✗ optional-error-sink-contract self-test: 3 case(s) failed`. js-comment-mask.mjs has #10427/PR #10632 in flight against it — it was mutated transiently and restored, sha256 5bd5ff8bb852bd1fa336ed67f392808c63045358c9999d4b0277a9e57fa74676 identical before and after; nothing in this PR edits that file. POSITIVE CONTROL on the unmodified tree, both steps exactly as written: grouped module step EXIT=0 in 10.26s (✓ ts-parse 28 cases, ✓ invoked-as 11 cases, ✓ js-comment-mask 15 cases); sink step EXIT=0 in 3.82s (✓ self-test 13 case(s) both directions; ✓ every sink declaring an optional `error` guarantees a `warn` channel, 15 baselined). ADDED CI COST ~14s/PR, ~10s of it ts-parse's ~20 real node children — the card budgets ~4s for it, so the true cost is ~2.5x the card's estimate; small against the 81 steps now in the job. GATE UNION derived with `node scripts/pm/dispatch-gates.mjs` and NO paths passed (it takes the change set from the merge base itself: '1 path(s) vs merge base 47aff0938 … three-dot semantics'). All 10 families run, all green: check:node-version 0, check:pm-half-states 0, check:required-contexts 0, check:shard-attestation 0, check:workflow-status-functions 0, check:type-check-coverage 0, check:type-check-debt 0; plus check:nul-bytes 0 (`check-nul-bytes: OK (scanned 6211 text file(s) … no raw ASCII control bytes)`). Per-gate exit codes captured directly, never through a pipe to tail. check:type-check-debt genuinely belongs to this diff rather than matching by accident — observed() reads .github/workflows/lint.yml to derive ciInvokesTask/ciInvokesRoot and evaluate() asserts on both, both on the unconditional main path — and it first refused correctly for want of a built closure, so I built it (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70 tasks successful, 4m07s) and re-ran: `check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 273.9s, 1913 raw tsc error(s) total, none above its recorded number.` I first considered declaring a narrowing here and did not need to — it ran green in full. Heavy work routed through scripts/pm/os-verify-lock.sh throughout: `VERDICT command-exit 0 · held the lock 9s · waited 0s` (7-gate sweep), `VERDICT command-exit 0 · held the lock 248s (4m08s) · waited 0s` (closure build), `VERDICT command-exit 0 · held the lock 275s (4m35s) · waited 297s (4m57s)` (--re-measure). CI on the PR head: `Lint & Repo Gates` in_progress at report time — per the dispatch contract I report at draft-PR time and do not wait for CI convergence.",
      "open_questions": [],
      "out_of_scope_findings": [
        "not filed as a new card, deliberately — the two card-omissions (scripts/invoked-as.mjs, scripts/checklist-select.mjs) and the four reasoned opt-outs are this card's own subject, so filing a fifth card would fragment it. Instead posted to the PARENT #10196 (open, pm:queue, and it explicitly asks for 'an explicit, reasoned opt-out list'): objectstack-ai/objectstack#10196 comment 5366746488. It carries the three things that would otherwise die with #10608 — the predicate correction (mention-matching gives 92 root scripts, argv-branch matching gives 83; the 9-file gap is why the card's table is off in both columns), the four opt-outs with their reasons, and the affordability number this card flags as unmeasured (~14s for the four wired, ~10s of it ts-parse, vs the ~4s the sub-card budgeted).",
        "standing, pre-existing, NOT filed (already tracked upstream at #6376 and printed by the gate itself): check:type-check-debt reports 11 raw tsc errors across 1 ledger entry sitting BELOW their recorded ceiling, closable with `pnpm check:type-check-debt --lower`. Untouched — not this card's surface, and lowering a ratchet is its own decision."
      ]
    }

    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