Skip to content

check:parse-guard is not wired into CI — the gate that stops a 16th raw ts.createSourceFile enforces nothing today #10574

Closed

Description

@claude

scripts/check-parse-guard.mjs landed in #10573 (for #10133) and is not wired into CI. Nothing runs it, so it is a gate that exists and enforces nothing.

What it guards, and what its absence costs

ts.createSourceFile never throws — a syntax error, or the wrong ScriptKind, returns a recovered partial tree with the errors on parseDiagnostics. #10573 routed all 32 call sites across 15 gates through scripts/ts-parse.mjs, which refuses instead. The guard is the half that stops a sixteenth raw call being typed into a new gate, and that matters more than it sounds: the symptom of a missing refusal is a green line, so an unguarded gate looks exactly like a guarded one.

This is the same reason check-entry-guard.mjs exists next to invoked-as.mjs. The one-time sweep of 33 files did not stop a twelfth hand-typed spelling of "was I run?"; the gate did.

Why #10573 did not do it

Two claimed surfaces, both live at the time:

Both are mechanical and each is one line plus a step. The gate itself needs no changes: it already carries --self-test (13 cases) and exits 0 on today's tree with 120 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.

Acceptance

  • pnpm check:parse-guard exists and runs the self-test before the scan, matching the shape of the other check:* entries
  • a step in lint.yml's gate job invokes it
  • re-introducing a raw ts.createSourceFile into any scripts/** file turns that job red and names the file and line — verified, not assumed

Refs: #10133 (the defect class) · #10573 (where the guard landed) · #9465 (the package.json fence) · #8331 (the lint.yml claim)


Generated by Claude Code

Activity

  1. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 21, 2026
  2. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor

    Claim: /pm-dispatch domain:devx execution seat (bug-first, batched round).
    Session: session_01DdCnBGcHeufjrq7drTD3wt
    Branch: claude/issue-10574-wire-parse-guard
    Worktree: objectstack-issue-10574
    Domain: domain:devx
    File surface: root package.json (one script alias) + .github/workflows/lint.yml (one step in Lint & Repo Gates)

    Batched with #10575 (the wider blind spot the guard's own header scopes out) — one PR.

    ⭐ Both surfaces this card called blocked are now clear, verified rather than assumed:

    ⛔ Governed surface out of scope for the diff (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**) — propose wording in the report instead.
    ⭐ Changeset: this lane's dispatch template was wrong about this and is now fixed. AGENTS.md:943 — "Pure bug fixes do not require a changeset". For a scripts/**-only diff that publishes nothing, the repo's own gate prescribes the skip-changeset label (applied via the additive POST /labels, never a whole-set PUT); scripts/check-empty-changeset.mjs rejects the empty-frontmatter route. Precedent: PR #10502, merged 2026-08-21.


    Generated by Claude Code

  3. added a commit that references this issue on Aug 21, 2026
    54b90ee
  4. claude commented on Aug 21, 2026

    @claude
    ContributorAuthor
    {
      "issue": 10574,
      "status": "done",
      "branch": "claude/issue-10574-wire-parse-guard",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10612",
      "premise_still_valid": true,
      "summary": "Wired check:parse-guard (one line in root package.json next to check:entry-guard, one step in lint.yml's `Lint & Repo Gates`) and closed both scope caveats its own header carried, so #10574 and #10575 move together. Axis 1 taken fully: ts-parse.mjs gains createProgramChecked (refuses on getSyntacticDiagnostics() over EVERY file the Program pulled in, not just roots — pinned by a case whose root parses and whose import does not) and transpileChecked (forces reportDiagnostics: true, not a knob); check-published-readme-exports.mjs:836 and check-where-matcher-conformance.mjs:493 converted, and the guard now bans all three raw spellings. Axis 2 per the ruling: no cross-tree helper — the out-of-tree population is walked, counted and NAMED on every run (28 parses in 20 files: 13 shipped/gate, 15 test), deliberately not ratcheted, with the shape decision filed as #10606. The gate was RED on current main before I touched it (see brief_corrections) and the two offending calls are converted here, because wiring a red gate into Lint & Repo Gates turns main red for everyone.",
      "package_json_fence_line": {
        "flag": "⭐ ONE line added inside the #9465 epic fence — the PM's announcement on #9465 with the revert offer is still owed before arming this PR.",
        "line": "\"check:parse-guard\": \"node scripts/check-parse-guard.mjs --self-test && node scripts/check-parse-guard.mjs\",",
        "placement": "immediately after \"check:entry-guard\" (its sibling gate), not alphabetical — that map is grouped thematically",
        "proof": "git diff --stat package.json => `1 file changed, 1 insertion(+)`; package.json still parses; nothing in .changeset/, cut-rc.yml, release.yml or pr-automation.yml touched"
      },
      "brief_corrections": [
        "FALSE: 'the gate needs no changes; it exits 0 on today's tree with 120 scripts/ file(s)'. It exits 0 no longer. On origin/main d5e7b9f5a1 it exits 1: scripts/check-optional-error-sink-contract.mjs:492 and :659 carry raw ts.createSourceFile calls. That gate merged in #10555 at 04:00:16Z; the guard merged in #10573 at 04:00:21Z — five seconds apart, so #10555 was authored before the guard existed and landed just under it. This is the sixteenth raw call the guard was built to stop, arriving within the hour of it landing. Both sites converted here under the bounded in-place-fix rule (same defect class; mechanical, form pinned by ts-parse.mjs's documented canonical call; no other claim on the file — searched open PRs and issues, #9754 which produced it is closed; same gate family). Named with evidence in the PR body, section 2.",
        "FALSE (minor): 'the branch is empty, ahead=0, nothing to recover'. ahead=0 was correct, but the worktree at /home/user/objectstack-issue-10574 had FOUR uncommitted modified files (check-optional-error-sink-contract.mjs, check-published-readme-exports.mjs, check-where-matcher-conformance.mjs, ts-parse.mjs — i.e. the dead agent had started exactly this work). I ran `git reset --hard` onto current main on the strength of the brief and discarded them; `git fsck` shows no recoverable blobs (they were unstaged, never in the index). No work was lost in outcome — the same three files plus more are covered here — but the brief's 'nothing to recover' was about commits, not the working tree, and a future dispatch should say which.",
        "STALE LINE NUMBERS (as the brief warned): check-published-readme-exports.mjs's ts.createProgram is at :830 on current main, not :775 (#10596 moved it); check-where-matcher-conformance.mjs's ts.transpileModule is at :482, not :481.",
        "OVER-COUNT in #10575's own table: check-skill-examples.ts has ONE createSourceFile site (:308), not 2 — the second was :692, a comment naming ts.createProgram, which the gate masks. And #10575 missed the four ts.createProgram sites in packages/spec/scripts/** entirely (build-api-surface.ts, build-export-origins.ts, check-dual-source-exports.ts, check-exported-any.ts) — two of them gates. So Axis 2 is 13 shipped/gate sites, not 10. Both corrections carried in #10606."
      ],
      "tests": "All at final HEAD 4fe892fec5, worktree clean. Gate union re-derived with `node scripts/pm/dispatch-gates.mjs` (no paths passed — it takes its own changeset from the merge base): 15 families. Heavy ones under `bash scripts/pm/os-verify-lock.sh -c ...` => `os-verify-lock: VERDICT command-exit 0 · held the lock 38s · waited 213s (3m33s)`. 13 of 15 exit 0. Verdict lines as the gates printed them: `✓ check:parse-guard self-test: 28 cases pass (every spelling of all three parser entry points is caught, their checked replacements are not, prose and payloads are not, only ts-parse.mjs is exempt, and the out-of-tree census counts what this gate does not govern).` / `✓ check:parse-guard: 121 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.` / `✓ ts-parse self-test: 28 cases pass (…across all three parser entry points, both ScriptKind directions, a Program's transitive import included, and a caller's try/catch cannot swallow any of it).` / `✓ optional-error sink contract: every sink declaring an optional `error` guarantees a `warn` channel (15 baselined, shrink-only).` / `✓ where-matcher conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly (162 refuse). 0 silently-wrong and 0 unjudged; none new.` Plus check:entry-guard, check:nul-bytes, check:cross-package-test-inputs, check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:pm-half-states, check:type-check-coverage all exit 0. ESLint clean on all five changed scripts. THE TWO NON-ZERO EXITS ARE ENVIRONMENT, NOT VERDICTS — unbuilt local tree, each gate saying so itself: check:published-readme-exports prints `✗ … 32 package(s) are not built, so this run measured nothing there` (its own self-test passed; in CI it runs in `Type Check · consumer gates`, which builds first — confirmed by parsing lint.yml), and check:type-check-debt throws `--re-measure cannot run: 7 workspace dependenc(ies) … have no built type entry point on disk … Build the closure first, exactly as lint.yml does before this step`. Exit codes captured before any pipe (redirect-then-capture), never `$?` after a `tail`. ABLATION, both directions, three legs; each mutation proved ON DISK by anchor count on the specific text (injected spelling counted AND removed spelling counted), never a bare `git diff --stat`. No build/dist is involved (plain .mjs, nothing bundles scripts/), so there is no rebuild leg and no dist preflight to report — stated rather than skipped. Leg A: raw ts.createSourceFile back into check-route-envelope.mjs (injected text count 1; canonical `parseSourceFile(fileName, source)` 3 → 2) => EXIT=1, `scripts/check-route-envelope.mjs:953  ts.createSourceFile  — a raw parse whose parseDiagnostics nobody reads`, the exact injected line. Leg B: createProgramChecked → ts.createProgram (injected 1, checked 1 → 0) => EXIT=1, `scripts/check-published-readme-exports.mjs:837  ts.createProgram  — a Program whose getSyntacticDiagnostics() nobody calls`. Leg C: transpileChecked → ts.transpileModule (injected 1, checked 1 → 0) => EXIT=1, 2 findings, `scripts/check-where-matcher-conformance.mjs:493  ts.transpileModule  — a transpile that reports NOTHING without reportDiagnostics: true`, and the remediation block adapted to the APIs found (printed `import { createProgramChecked, transpileChecked } from './ts-parse.mjs';` plus both canonical calls, not a generic createSourceFile paragraph). RESTORE leg, proved on disk: injected counts back to 0, canonical counts back to 3/1/1, `git status --porcelain` empty => EXIT=0 green. POSITIVE CONTROL on the unmodified tree => EXIT=0. Direction predicted before running and observed: plain red, no reversal. WIRING PROVEN not assumed: yaml.safe_load of lint.yml shows the new step sits in the job whose `name:` is `Lint & Repo Gates` (the required-status-check context, unchanged) with `run: pnpm check:parse-guard`; the alias resolves and `pnpm check:parse-guard` runs both halves end to end; the workflow declares no `paths:` filter so it runs on every PR to main. BOTH NEW CALL SITES PROVEN TO EXECUTE (not inferred from a one-line substitution) via OS_TOOLING_PARSE_CENSUS=1 on the real gates: check-published-readme-exports => `[ts-parse census] 41 parse(s) over 41 distinct file name(s) (1 program(s), 0 transpile(s)); 0 refusal(s)`; check-where-matcher-conformance => `[ts-parse census] 1123 parse(s) over 1123 distinct file name(s) (0 program(s), 275 transpile(s)); 0 refusal(s)` — one real Program over 41 real .d.ts entries and 275 real transpiles of lifted matchers, all clean, with check:where-matcher still green at its baseline, so no candidate was turned into a refusal.",
      "open_questions": [
        {
          "question": "scripts/ts-parse.mjs's own 28-case --self-test runs in NO workflow — and check-parse-guard.mjs's header explicitly delegates its whole design argument to it ('the behavioural evidence lives once, at the module … pinning the refusal once and enforcing that everyone routes through it covers the same ground'). So after this PR, CI enforces 'everybody calls parseSourceFile' and never once checks that parseSourceFile still refuses. Wiring it is one more line in root package.json — which the #9465 fence authorised for exactly ONE line, so I did not take it.",
          "options": [
            "A. Leave it to #10608 (filed, sub-issue of #10196, which proposes the general detector). Costs: the guard ships with its justification resting on an unrun pin, for however long that card queues.",
            "B. Ask for a second fence-line allowance on this PR and add `\"selftest:ts-parse\": \"node scripts/ts-parse.mjs --self-test\"` plus a lint.yml step (~4s, spawns ~20 node children). Costs: a second line in a fenced file, and it widens this card's declared surface after the fact.",
            "C. Wire it with no new alias — a lint.yml step running `node scripts/ts-parse.mjs --self-test` directly, touching only lint.yml, which is already this PR's surface. Costs: breaks the `pnpm check:*` convention every sibling step follows, so it is the shape a reader would not expect."
          ],
          "recommendation": "A, unless the PM wants it now — in which case C, not B. C keeps root package.json at the one authorised line while still closing the hole today, and the convention break is visible in one place rather than buried. B is the tidiest result and the worst process: it spends a second unit of a fence allowance that was granted for one."
        }
      ],
      "out_of_scope_findings": [
        "filed as #10606: the 28 TypeScript parses outside scripts/** need a shape decision, not a copy of ts-parse.mjs — carries the re-measurement, both corrections to #10575's table, the two try/catch wrappers that turn a throw into a silent skip (validate-react-page-props.ts `catch { continue; }`, lint-startup-registry-verdict.ts `catch { return []; }`, both verified still present) and four options with a recommendation. Labelled finding, unassigned.",
        "filed as #10608 (sub-issue of #10196): six root scripts/*.mjs ship a --self-test that no workflow reaches (measured: 87 ship one, 6 unreachable). Two matter — ts-parse.mjs (see open_questions) and js-comment-mask.mjs, the masker both check:parse-guard and check:entry-guard depend on. A third is this card's defect live a second time: check:optional-error-sink has an alias in root package.json and NO workflow invokes it, so the gate that landed in #10555 five hours ago has never run in CI. Not wired here — a second gate in lint.yml is a different acceptance with a different blast radius. Labelled finding, unassigned."
      ]
    }
    

    Generated by Claude Code

  5. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
  6. added 2 commits that reference this issue on Aug 23, 2026
    af94015
    c700fc9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seattoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions