Repository navigation
Description
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.tshas 2 sites; it has 1 (:308). The second was:692, a comment namingts.createProgram— masked by the gate, which is the whole reason the gate masks prose. - it missed the four
ts.createProgramsites inpackages/spec/scripts/**entirely. Two of them (check-dual-source-exports.ts,check-exported-any.ts) are gates, and a Program parks syntax errors behindgetSyntacticDiagnostics()— a call none of the four makes. Same class as thescripts/**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
- A
packages/lint-local checked parse that RETURNS a finding. Publish-time validators report rather than abort; the twocatchblocks 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. - A shared package (
@objectstack/…) both trees import. One shape everywhere; costs a published dependency for repo tooling, which is whatinvoked-as.mjsargued against. - Per-row, no shared shape. The four
packages/spec/scripts/**Programs are gates over a controlled tree and could simply callgetSyntacticDiagnostics()and fail; thepackages/lint/src/**validators get option 1;detect-free-identifiers.tsis runtime and needs its own answer. - Ratchet the census
check:parse-guardalready 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
claude commentedon Aug 21, 2026
Claim:
/pm-dispatchdomain:devx execution seat.Session:
session_01DdCnBGcHeufjrq7drTD3wtBranch:
claude/issue-10606-out-of-tree-parse-shapeWorktree:
objectstack-issue-10606Domain:
domain:devxFile surface: a decision + whatever implements it; likely
packages/lint/**,packages/spec/scripts/**,packages/cli/src/utils/**and/orscripts/check-parse-guard.mjsscripts/ts-parse.mjscannot be imported from a published package (see the card's own reasoning, andinvoked-as.mjs's note about itspackages/clisibling). ⛔ If no option is clearly right, report the comparison rather than picking one to have shipped something.⛔ Governed surface out of scope (
⚠️ This repo has no formatter of record (verified: no
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md,content/docs/releases/**) and the #9465 epic fence (.changeset/tooling,cut-rc.yml,release.yml, rootpackage.json).⭐ Changeset:
AGENTS.md:943— "Pure bug fixes do not require a changeset"; a diff that publishes nothing takes theskip-changesetlabel via the additivePOST /labels(⛔ never a whole-set PUT — see #5533). Precedent: PR #10502.format/prettierscript, prettier not in devDeps, no.prettierrc/.editorconfig) — ⛔ do not runprettier --write; its defaults reject files already onmain(#10622).Generated by Claude Code
claude commentedon Aug 21, 2026
{ "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/catchwrappers verified present on47aff09388packages/lint/src/validate-react-page-props.ts:985-990packages/lint/src/lint-startup-registry-verdict.ts:615-620Both are dead code today, exactly as the card says. Worth adding: at these two sites the
catchis not where the false-clean verdict comes from —createSourceFilecannot throw, so the live path is the unreadparseDiagnostics, and a recovered partial tree is walked and scored clean with nocatchinvolved. 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
claude commentedon Aug 21, 2026
Closing — the shape decision is made and the unarguable half is on
main(PR #10651, merged 2026-08-21T07:55:04Z).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:
invoked-as.mjsobjection actually reaches → #10653The speculative group is the one a uniform "make it refuse" would have broken:
check-doc-formula-expressions.mjs:266,272deliberately re-reads a top-level block statement as a parenthesized expression, so a failing parse is ordinary control flow; andcheck-skill-examples.ts:308documents that a block too broken to parse is "caught by the tsc pass that follows" — verified end to end, a realtsc --noEmitspawned 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.mdcitesstartup-registry-verdict.baseline.json).What landed
check-parse-guard.mjsnow 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 nearestpackage.jsonand reading itsfiles— never a hand-kept list. Verified onorigin/main:packsScripts()at:263, and the header at:115states 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/specpacks 10 entries,@objectstack/lint3 — neither includesscripts), and one already imports root tooling:packages/spec/scripts/check-browser-reachable-entries.ts:123→import { scanSource } from '../../../scripts/js-comment-mask.mjs'.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/lintvalidators; ⛔ not B) and the correction that matters for whoever takes it: the defect is the unreadparseDiagnostics, not thetry/catch—createSourceFilecannot throw, so a recovered partial tree is walked and scored clean with nocatchinvolved.Also recorded there, deliberately unfiled because unmeasured:
validate-react-page-props.ts's catch comment credits "the syntax gate", butvalidate-jsx-pages.tsdoes not lintkind:'react'— the real cover isvalidate-react-pages.ts, which uses Sucrase, not TypeScript.Generated by Claude Code