Skip to content

The 28 TypeScript parses outside scripts/** need a shape decision, not a copy of ts-parse.mjs — now counted by check:parse-guard, governed by nothing #10606

Closed

Description

@os-zhuang

Filed from the PR that closes #10574 / #10575. Nothing is red, and nothing here is a request to copy a helper.

#10575's Axis 1 (ts.createProgram, ts.transpileModule, both inside scripts/**) is done in that PR: both now route through ts-parse.mjs (createProgramChecked / transpileChecked) and check-parse-guard.mjs fails on all three raw spellings. Axis 2 — the parses outside scripts/** — is deliberately left, because it needs a decision about shape first. This card carries the decision and a re-measurement.

What is actually out there — re-measured on main at d5e7b9f5a1

check:parse-guard now prints this population on every run, so the number moves when somebody adds one. 28 parses in 20 files — 13 in shipped/gate code, 15 in tests:

file sites api note
packages/lint/src/validate-react-page-props.ts 1 createSourceFile wrapped in try { … } catch { continue; }
packages/lint/src/lint-startup-registry-verdict.ts 1 createSourceFile wrapped in try { … } catch { return []; }
packages/lint/src/validate-hook-body-writes.ts 1 createSourceFile the only site anywhere passing setParentNodes: false, and it parses a synthesised async function __body(ctx) { … } wrapper
packages/lint/scripts/check-doc-formula-expressions.mjs 3 createSourceFile a gate
packages/spec/scripts/check-skill-examples.ts 1 createSourceFile a gate
packages/spec/scripts/lib/strictness-ledger.ts 1 createSourceFile generator/gate library
packages/cli/src/utils/detect-free-identifiers.ts 1 createSourceFile runtime, not a gate
packages/spec/scripts/build-api-surface.ts 1 createProgram generator
packages/spec/scripts/build-export-origins.ts 1 createProgram generator
packages/spec/scripts/check-dual-source-exports.ts 1 createProgram a gate
packages/spec/scripts/check-exported-any.ts 1 createProgram a gate
9 test files 15 mixed listed by the gate, marked [test]

Two corrections to #10575's table, both measured:

  • it said check-skill-examples.ts has 2 sites; it has 1 (:308). The second was :692, a comment naming ts.createProgram — masked by the gate, which is the whole reason the gate masks prose.
  • it missed the four ts.createProgram sites in packages/spec/scripts/** entirely. Two of them (check-dual-source-exports.ts, check-exported-any.ts) are gates, and a Program parks syntax errors behind getSyntacticDiagnostics() — a call none of the four makes. Same class as the scripts/** half, one tree over.

Why this is not "copy ts-parse.mjs into packages/"

The reason invoked-as.mjs gives for its own packages/cli sibling: scripts/ runs as plain .mjs against a possibly unbuilt tree, and making a published package depend on repo tooling to answer "did this parse?" trades one bug for a worse one.

More importantly the contract may genuinely differ per row. A scripts/** gate audits a tree its author controls, so ending the process is right. A publish-time lint validator in packages/lint is handed metadata by somebody else and may legitimately want to report an unparseable source as a finding rather than abort. Those are different answers and the rows above do not all want the same one.

The trap any Axis-2 shape has to survive

validate-react-page-props.ts and lint-startup-registry-verdict.ts wrap their parse in try { … } catch { continue; } / catch { return []; }. Both are dead code today — createSourceFile cannot throw — but they are written as a skip, so the moment a parse did throw they become a silent skip, which is this defect class pre-installed one layer up. That is exactly why ts-parse.mjs exits 3 instead of throwing: an exit cannot be downgraded by a caller that meant well.

So a package-side answer that throws is not equivalent to the scripts/** one. It has to either return a finding the caller must handle, or the two catch blocks have to go first. validate-react-page-props.ts's own comment says "the syntax gate reports unparseable sources" — a claim worth verifying rather than inheriting.

The options, so the decision is a decision

  1. A packages/lint-local checked parse that RETURNS a finding. Publish-time validators report rather than abort; the two catch blocks are deleted as part of it. Costs: a second refusal shape in the repo; buys: a contract that fits how a validator is actually called.
  2. A shared package (@objectstack/…) both trees import. One shape everywhere; costs a published dependency for repo tooling, which is what invoked-as.mjs argued against.
  3. Per-row, no shared shape. The four packages/spec/scripts/** Programs are gates over a controlled tree and could simply call getSyntacticDiagnostics() and fail; the packages/lint/src/** validators get option 1; detect-free-identifiers.ts is runtime and needs its own answer.
  4. Ratchet the census check:parse-guard already prints, so a new out-of-tree parse cannot land unnoticed while the shape question is open. Cheap, orthogonal to 1–3, and deliberately NOT done in the closing PR: a ratchet forces an answer by making the next unrelated PR red.

Recommendation: 3 + 4. The rows do not share a contract, so a single shape would be invented rather than found; the four packages/spec/scripts/** gates are the cheapest and least arguable half and can move first. 4 keeps the population from growing while 1–3 are decided.

Refs: #10133 (the class) · #10573 (the scripts/** implementation) · #10574 / #10575 (wiring and widening; where Axis 1 landed)

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
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 21, 2026
  2. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim: /pm-dispatch domain:devx execution seat.
    Session: session_01DdCnBGcHeufjrq7drTD3wt
    Branch: claude/issue-10606-out-of-tree-parse-shape
    Worktree: objectstack-issue-10606
    Domain: domain:devx
    File surface: a decision + whatever implements it; likely packages/lint/**, packages/spec/scripts/**, packages/cli/src/utils/** and/or scripts/check-parse-guard.mjs

    ⚠️ This card is a shape decision, not a copy-the-helper task — scripts/ts-parse.mjs cannot be imported from a published package (see the card's own reasoning, and invoked-as.mjs's note about its packages/cli sibling). ⛔ If no option is clearly right, report the comparison rather than picking one to have shipped something.

    ⛔ 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

  3. removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 21, 2026
  4. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10606,
      "status": "done",
      "branch": "claude/issue-10606-out-of-tree-parse-shape",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10651",
      "premise_still_valid": true,
      "summary": "Re-derived the population on 47aff09388: 28 parses in 20 files — totals IDENTICAL to the card's measurement at d5e7b9f5a1, no drift. But the card's 2-way split (13 shipped/gate + 15 tests) hides a third population, and that is the finding: 9 of the 13 non-test sites live in `<pkg>/scripts/**`, which their own package.json packs into NO tarball (@objectstack/spec packs dist/json-schema/liveness/prompts/llms.txt/README/src-zod/CHANGELOG/api-surface/spec-changes; @objectstack/lint packs dist/README/CHANGELOG). They are scripts/** in everything but their path. So the sentence check:parse-guard PRINTS over all 28 rows on every run — 'They cannot import scripts/ts-parse.mjs' — is false for 9 of them: packages/spec/scripts/check-browser-reachable-entries.ts:122 ALREADY imports scanSource from ../../../scripts/js-comment-mask.mjs, four more spec tools import ../../../scripts/check-regen-pending.mjs, and the hand-written .d.mts mirrors that make that typecheck are already a governed corpus (check-declaration-mirrors.mjs, discovered not listed). A real measurement printed under a reason that does not apply to most of what it counted is this gate's own subject matter, one block below where it is argued. DECISION: option 3 (per-row) is the right conclusion but its ROW GROUPING is wrong — the rows split by what the parse is OVER, not by published/unpublished or gate/runtime. Over a tree the tool controls (6 sites: the 4 spec Programs, strictness-ledger.ts:169, check-doc-formula-expressions.mjs:619) refusal is right and needs no new shape. Over an extracted prose snippet parsed SPECULATIVELY (3 sites) refusal would be a REGRESSION: check-doc-formula-expressions.mjs:266,272 deliberately re-reads a top-level block statement as a parenthesized expression, so a failing parse is ordinary control flow; check-skill-examples.ts:308 documents that a block too broken to parse is 'caught by the tsc pass that follows' — I verified that end to end (spawnSync of a real tsc --noEmit at :436-439, and its header at :30 records that tsc reports syntactic diagnostics and then STOPS). Option 4 (ratchet) I recommend AGAINST: the gate's own header already argues that way and is right for a reason the card's option-4 text elides — 15 of 28 are tests, and a ratchet makes an unrelated PR red with no remedy its author can apply; the repo's established shape if it is ever wanted is a shrink-only hand-edited baseline (AGENTS.md cites startup-registry-verdict.baseline.json). IMPLEMENTED only the unarguable part: the census now TIERS its rows and prints each under the sentence true of it, tier derived by walking up to the nearest package.json and reading its `files` — never a listed directory. No shipped source touched, nothing converted, no ratchet. Both try/catch wrappers verified present and quoted below.",
      "tests": "All gates run at final commit beec0e46be, tree clean. Gate union derived with `node scripts/pm/dispatch-gates.mjs` NO paths passed (it self-derived 'change set derived from git — 1 path(s) vs merge base 47aff0938', naming 3 families); all 3 ran, plus check:nul-bytes for the any-edit rule and eslint on the changed file. Both heavy runs via scripts/pm/os-verify-lock.sh, quoting its own VERDICT lines: 'os-verify-lock: VERDICT command-exit 0 · held the lock 1s · waited 248s (4m08s)' and 'os-verify-lock: VERDICT command-exit 0 · held the lock 3s · waited 366s (6m06s)'. One earlier attempt returned 'VERDICT queue-timeout (exit 99) · never acquired · waited 540s' — I spent the interval on the PR body and re-acquired in-turn rather than ending the turn. Gate verdict lines as the gates themselves printed them: 'check-nul-bytes: OK (scanned 6211 text file(s) -- 6211 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).'; 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; 'check:parse-guard self-test: 41 cases pass' (was 30; +11 new); PARSE_GUARD_TREE_OK and ESLINT_OK sentinels both printed under `set -o pipefail`. Tiered census on the real tree reproduces my independently-computed arithmetic exactly: 4 shipped / 9 tooling / 15 tests = 28. ABLATIONS — three, no build/dist involved (check-parse-guard.mjs is plain .mjs run from source, so no dist preflight applies); every mutation confirmed ON DISK by anchor count before the run via a helper that ABORTS at exit 9 when the old-text count is not 1, never by an editor's exit code. (1) packsScripts -> constant false: 'on-disk anchor count: OLD 1 -> 0 | NEW 0 -> 1 / mutation CONFIRMED on disk', predicted red — observed red but only 1 of the 2 expected cases. That miss was REAL and is the control's whole value: three fixtures handed a manifest to EVERY directory, which makes <pkg>/scripts a package in its own right, so the row stopped there, never reached the scripts/ test, and passed without exercising the code under test — phantom assertions. Repaired in commit beec0e46be; the re-run then failed both as predicted ('a package that PACKS scripts/ makes its tooling row shipped again', 'a glob that packs everything counts as packing scripts/ too'). (2) tierOf's tooling branch -> always 'shipped': predicted red, observed red on exactly the 4 tooling cases. (3) leak the shipped-only phrase into the tooling tier's prose: predicted red, observed red on 1 case with detail [\"shipped\",\"tooling\"]. RESTORE leg verified by anchor rather than assumed: zero ABLATION residue, all three original anchors present (packsScripts=1 tierBranch=1 whyLine=1), git status clean, self-test 41/41 green.",
      "open_questions": [
        {
          "question": "The 4 shipped-source rows (packages/lint/src/** x3, packages/cli/src/utils/detect-free-identifiers.ts) are the only ones the invoked-as.mjs objection actually covers, and they are still undecided. Card options 1 and 2 apply to them alone.",
          "options": [
            "A — packages/lint-local checked parse that RETURNS a finding, deleting the two dead catch blocks with it (card option 1), scoped to the 3 lint validators; detect-free-identifiers.ts is CLI runtime and answers separately",
            "B — a shared published @objectstack/... package both trees import (card option 2)",
            "C — leave all 4 alone; the tiered census now states the reason honestly and no green line lies about them"
          ],
          "recommendation": "A for the 3 lint validators, but as its own card with its own measurement — not folded in here. Rationale on the four axes: real business need is demonstrated (a publish-time validator IS handed metadata by someone else, which is what makes a returned finding the fitting contract rather than an invented one); long-term soundness favours a contract that fits the call shape over a uniform refusal borrowed from a tree with a different owner; it makes AI-authored metadata hard to get wrong because an unparseable page source becomes a reported finding at author time instead of a silent skip; and startup scope discipline argues against B, which builds a published dependency for 4 call sites. C is a legitimate cheaper answer and is what this PR leaves standing. B I recommend against outright: it is the shape invoked-as.mjs already argued down, for 4 sites."
        },
        {
          "question": "Residual, UNMEASURED: validate-react-page-props.ts's catch comment says 'the syntax gate reports unparseable sources'. I verified the claim holds, but not by the route the card implies — validate-jsx-pages.ts (the obvious 'syntax gate') explicitly does NOT lint kind:'react'; the real cover is validate-react-pages.ts, which uses SUCRASE, not TypeScript. Both reach the CLI path (validateReactPages is an AUTHORING_RULES entry, gating/ALL/CLI_ONLY; validateReactPageProps rides validateReferenceIntegrity). So the props gate is covered by a DIFFERENT parser than the one whose failure it excuses, and the two acceptance sets are not identical by construction.",
          "options": [
            "A — measure the Sucrase/TypeScript acceptance divergence on real page sources and file only if a gap is found",
            "B — file it now as a finding on the strength of the reasoning alone"
          ],
          "recommendation": "A. I did not measure it, so I am not filing it — an unmeasured divergence filed as a finding is exactly the borrowed authority this card is about. Recorded here so it is not lost."
        }
      ],
      "out_of_scope_findings": []
    }
    

    Both try/catch wrappers verified present on 47aff09388

    packages/lint/src/validate-react-page-props.ts:985-990

        let sf: ts.SourceFile;
        try {
          sf = tsc.createSourceFile('page.tsx', source, tsc.ScriptTarget.Latest, true, tsc.ScriptKind.TSX);
        } catch {
          continue; // the syntax gate reports unparseable sources
        }

    packages/lint/src/lint-startup-registry-verdict.ts:615-620

      let sf: ts.SourceFile;
      try {
        sf = t.createSourceFile(fileLabel, source, t.ScriptTarget.Latest, true, t.ScriptKind.TS);
      } catch {
        return [];
      }

    Both are dead code today, exactly as the card says. Worth adding: at these two sites the catch is not where the false-clean verdict comes from — createSourceFile cannot throw, so the live path is the unread parseDiagnostics, and a recovered partial tree is walked and scored clean with no catch involved. The wrappers are a silent skip pre-installed for the day a parse does throw; the defect is already live one layer down.

    Generated by Claude Code


    Generated by Claude Code

  5. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
  6. claude commented on Aug 21, 2026

    @claude
    Contributor

    Closing — the shape decision is made and the unarguable half is on main (PR #10651, merged 2026-08-21T07:55:04Z).

    ⚠️ This did not auto-close: #10651's body carried no closing keyword. That is an omission in the PR body, not a judgement that scope remained — recording the difference so the next reader does not have to guess, and so the decision this card asked for is findable from the card itself.

    The decision

    The card asked for a shape decision, not a copy of ts-parse.mjs. The answer, with the measurement behind it:

    Rows split by what the parse is OVER — not by published/unpublished, and not by gate/runtime:

    population verdict
    over a tree the tool controls (6 sites) refusal is right; needs no new shape
    over an extracted prose snippet, parsed speculatively (3 sites) ⛔ refusal would be a regression
    tests (15 sites) out of scope for a refusal contract
    shipped package source (4 sites) the only rows the invoked-as.mjs objection actually reaches → #10653

    The speculative group is the one a uniform "make it refuse" would have broken: check-doc-formula-expressions.mjs:266,272 deliberately re-reads a top-level block statement as a parenthesized expression, so a failing parse is ordinary control flow; and check-skill-examples.ts:308 documents that a block too broken to parse is "caught by the tsc pass that follows" — verified end to end, a real tsc --noEmit spawned at :436-439.

    ⛔ Option 4 (a ratchet) was declined, for a reason the card's own option-4 text elides: 15 of 28 rows are tests, so a ratchet makes an unrelated PR red with no remedy its author can apply. If it is ever wanted, the repo's established shape is a shrink-only hand-edited baseline (AGENTS.md cites startup-registry-verdict.baseline.json).

    What landed

    check-parse-guard.mjs now tiers its out-of-tree census and prints each row under the sentence true of it, with the tier derived by walking up to the nearest package.json and reading its files — never a hand-kept list. Verified on origin/main: packsScripts() at :263, and the header at :115 states the reason — "a hand-kept list of tooling directories fails by quietly leaving a new one in the wrong tier".

    ⭐ Why the tiering was the necessary part

    The census printed one sentence over all 28 rows — "They cannot import scripts/ts-parse.mjs" — and it was false for 9 of them. Those live in <pkg>/scripts/**, which their own manifests pack into no tarball (@objectstack/spec packs 10 entries, @objectstack/lint 3 — neither includes scripts), and one already imports root tooling: packages/spec/scripts/check-browser-reachable-entries.ts:123 → import { scanSource } from '../../../scripts/js-comment-mask.mjs'.

    A real measurement printed under a reason that does not apply to most of what it counted is this gate's own subject matter, one block below where it is argued.

    The count was never wrong — 28 in 20 files reproduced exactly. The sentence attached to it was.

    Remainder

    #10653 carries the four shipped-source rows, with the PM ruling (option A for the three packages/lint validators; ⛔ not B) and the correction that matters for whoever takes it: the defect is the unread parseDiagnostics, not the try/catch — createSourceFile cannot throw, so a recovered partial tree is walked and scored clean with no catch involved.

    Also recorded there, deliberately unfiled because unmeasured: validate-react-page-props.ts's catch comment credits "the syntax gate", but validate-jsx-pages.ts does not lint kind:'react' — the real cover is validate-react-pages.ts, which uses Sucrase, not TypeScript.


    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

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