Skip to content

fix(create-objectstack): make the package-manager verdict hermetic and honest (#11616) - #12041

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11616-hermetic-pm-detection
Aug 25, 2026
Merged

fix(create-objectstack): make the package-manager verdict hermetic and honest (#11616)#12041
os-zhuang merged 1 commit into
mainfrom
claude/issue-11616-hermetic-pm-detection

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11616

A merge-queue red on a diff that could not reach this package. The queue is a
shared serial resource, so the tax landed on lanes that had nothing to do with
the flake. Both halves of triage's fix shape are here, because a hermetic test
over a detector that still lies would pass deterministically while asserting
something false.

The seam, re-derived at current origin/main

Measured at 3954fb7df, not recalled:

whatmeasured
detectPackageManagerpackages/create-objectstack/src/index.ts:125 — triage's line number still holds
its only consumerindex.ts:440, const pm = detectPackageManager();

The premise re-verified, same machine, one binary, two directories:

$ (cd packages/create-objectstack && pnpm --version) -> 10.31.0 # the repo's pinned packageManager
$ (cd "$(node -e 'console.log(require("os").tmpdir())')" && pnpm --version) -> 10.33.0 # Corepack's own default

The scaffold child runs in an mkdtemp under os.tmpdir(), so it was the
second leg — a resolution, and potentially a fetch, of a version nothing in
this repo pins — that decided the assertion.

Half 1 — the product stops collapsing "I failed" into "npm"

detectPackageManager() was try { execSync('pnpm --version') } catch { return 'npm' }. npm install in a transcript meant either no pnpm here or the
probe threw
, and nothing downstream could tell which. The probe now returns a
verdict record (packages/create-objectstack/src/detect-package-manager.ts, a
new module because index.ts calls program.parse() at import and cannot be
imported by a test — the same reason pkg-utils.ts and rewrite-identity.ts
already live outside it):

verdictmeaningoutput
{ pm: 'pnpm', probe: 'ok' }the probe answeredunchanged, silent
{ pm: 'npm', probe: 'absent' }no pnpm on PATH — a real choiceunchanged, silent
{ pm: 'npm', probe: 'failed', detail }pnpm is on PATH, probe still threwnew: says so, and names why

Clause ②: re-affirmed against what was built — nothing a user gets changes

Which package manager a run uses is still pnpm if and only if the probe
succeeded, in all three rows. The PATH lookup that separates absent from
failed runs only after the decision is already made and feeds the message
alone, so a miss there can change a warning's wording and never the tool's
behaviour. That invariant is itself pinned (regression guard: pm is pnpm if and only if the probe succeeded, asserted across both PATH-lookup outcomes).
The only output that moves is one warning in a case that was previously silent
and wrong.

stdio moved from 'ignore' to ['ignore', 'ignore', 'pipe'] so the failure
reason is available to report; stdout and stderr are still never forwarded, so
a succeeding and a failing probe both print exactly what they printed before.

Half 2 — the test stops measuring the runner

Every leg now runs under a PATH holding a stub pnpm whose exit status the
test chooses, mirroring the npm-fallback leg's existing makePnpmlessBin().
The real Corepack resolution never runs, so the branch under test is chosen by
the test rather than by the network.

⛔ Not done by retrying, widening a timeout, adding a network guard, or making
the test conditional — each of those keeps the test measuring the runner and
just makes it complain less.

The vacuity guard is not loosened — it is re-aimed and made stricter. The
old guard ran command -v pnpm in the test process, whose cwd is inside the
repo; that mismatch with the child's cwd was the bug, so the guard passed while
the thing it guarded failed. It now runs under the PATH the child receives
and pins resolution to the exact stub on disk (expect(resolved).toBe(path.join(bin, 'pnpm'))),
so a leg whose fixture silently failed to take effect fails loudly instead of
going vacuous.

Both branches are asserted deterministically, and a third leg pins the new
distinction end to end: probe failed prints the fallback warning, absent
must not. Either direction of a future collapse turns one of that pair red.

Ablation — every pin shown failing without the behaviour

Source reverted, pins kept. Each mutation confirmed on disk with anchored greps
in both directions before the run, and every leg self-restores via
trap ... EXIT INT TERM. Full log: 4 legs + a canary, 111s.

legmutationresult
0 as-builtnone177/177 green
B1 collapse the distinction in the modulecatch always returns probe: 'absent'3 red — both unit distinction pins + the e2e warning pin
B2 drop the product's reportingif (detected.probe === 'failed') -> if (false)1 red — the e2e warning pin, independently of B1
B3 re-hardcode npm (the original #10322 defect)const pm = detected.pm -> 'npm'2 redwith pnpm on PATH: prints pnpm consistently + both branches ... validate step
4 restorednone177/177 green, diff byte-identical to pristine

B3 is the anti-vacuity proof that mattered most: stubbing pnpm did not
neuter the pnpm leg. It still catches the original #10322 regression — and now
catches it deterministically instead of whenever Corepack cooperates.

Each leg produced a distinct, predicted red set, which is what rules out
the "both legs errored identically so the diff read as no breakage" failure.

Canary control — the instrument produces a positive before its negative is trusted

The warning marker was replaced with CANARY9137, confirmed on disk in both
directions (real marker 1 -> 0, canary 0 -> 1), and the run reported it exactly
where expected — src/scaffold-next-steps-pm.test.ts:238:

AssertionError: expected '\n ╔══...' to contain 'using npm as a fallback'
- using npm as a fallback
+ ⚠ pnpm is installed but `pnpm --version` failed (corepack: Network request to https://registry.npmjs.org/pnpm failed); using npm as a CANARY9137. ...

That line is the spawned child's real stdout, so it proves three things at
once: the assertion reads real output rather than mere absence; the source
mutation reaches the spawned process; and the whole detail chain works (stub
exits 1 -> execSync throws with stderr captured -> PATH lookup finds the stub
-> probe: 'failed' -> reason printed).

No rebuild is involved on either ablation leg, and that is measured rather
than assumed
: the test spawns tsx src/index.ts and BUNDLED_TEMPLATES_DIR
resolves to src/templates, so nothing in this pin reads dist/. The canary
above is the empirical proof that a source edit reaches the child.

Green in both states — regression guards, NOT red-before evidence

Named rather than counted: regression guard: pm is pnpm iff the probe succeeded (the Clause ② guard), pnpm simply absent: NO fallback warning
(green in B1 only because B1 collapsed towardabsent — it is the pin that
catches a collapse in the other direction), the PATH lookup is not consulted when the probe succeeds, a succeeding probe never claims a fallback, and the
pre-existing install-failure-remedy source pin.

Checks — each gate's own verdict line, at 60317c537

Gate family re-derived from the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, and
again after the changeset grew it to 5 paths (13 matched families, all run).

create-objectstack test -> Test Files 14 passed (14) / Tests 177 passed (177)
create-objectstack typecheck -> tsc --noEmit, exit 0
pnpm lint (FULL repo, eslint . --no-inline-config) -> exit 0, 142s
check:published-files ✓ 69 publishable package(s) ... admits no test, test-harness config or build script
check:slot-lookup ✓ slot-lookup ratchet holds: 107 unswept site(s) ... none new
check:test-source-alias check-test-source-alias OK — 72 packages with tests scanned
check:type-source-resolution check-type-source-resolution OK — 93 tsc program(s) across 77 packages
check:plugin-teardown-shape ✓ 63 Plugin implementation(s) across 4650 source(s)
check:nul-bytes OK (scanned 6670 text file(s) ... no raw ASCII control bytes)
check:query-options-erasure ✓ ratchet holds: 67 unswept non-test site(s), none new
check:engine-double-contract OK — 405 pinned, 133 in the DEBT ledger, 2 exempt
check:cross-package-test-inputs OK: 16 package(s) read outside themselves, all declared
check:where-matcher ✓ 297 matcher(s) discovered, 297 answer ... correctly; none new
check:type-check-coverage OK — 65/78 workspace packages type-checked
check:changeset-gate-self-tests ✓ (118 + 212 + 116 assertions)
check:objectui-changeset ✓ objectui-range --self-test: all checks passed
check-adr-0087-registration ✓ this PR adds no declared-breaking changeset
check-changeset-no-major ✓ This diff introduces no `major` bump
check-empty-changeset ✓ No empty-frontmatter changeset (1 declaring changeset(s) added)
release-rehearsal-clone --self-test ✓ self-test passed
check-affected-docs exit 0
check-drift-comment ✓ 56 cases pass across 5 fixture diff(s)

Every one of those ran against tree 60317c537, which is this PR's head — the
ablation and canary legs both restored byte-identically (diff empty, tree
clean) before the gate sweep was read.

check:type-check-debt — declared not run, with the reason. Its ratchet
half needs the whole workspace closure built, and create-objectstack carries
no ledger row for it to move: no test-typecheck-debt.json, no DEBT entry,
and check:type-check-coverage reports it among the 65/78 fully covered. Both
new files are inside the tsc program typecheck runs (verified with
tsc --listFiles; only src/templates is excluded), and that program is
green. CI runs the ratchet regardless.

Declared narrowing — the ablation legs ran outside the shared verify lock.
Two consecutive acquisitions returned VERDICT queue-timeout (exit 99) · never acquired · waited 540s, ~18 minutes with no progress, behind another lane's
multi-minute turbo build. The narrowing is measured, not asserted:
create-objectstack has zero workspace dependencies (no build closure at
all) and the earlier superset run — all 14 files, 177 tests — held the lock
for 17 seconds. The whole 5-leg matrix took 111s of single-package vitest.
The full-repo pnpm lint above was not narrowed; it ran whole.

Changeset

.changeset/create-objectstack-honest-pm-probe.md, patch. Required rather
than skip-changeset: the product's reporting changes — a user whose pnpm
probe fails now gets a warning naming the failure instead of silent npm advice.

Files

Declared surface was the detectPackageManager seam plus
scaffold-next-steps-pm.test.ts. Two files were added to it, both new and so
held by no other claim: detect-package-manager.ts (the seam, extracted so it
can be injected) and detect-package-manager.test.ts (its hermetic pins).
packages/cli/src/commands/init.ts (#11598) and compile.ts (#11772) were
read only, never edited — the former's npm_config_user_agent detector
answers a different question ("which PM invoked me") and was deliberately not
adopted here, since switching strategies would change what the tool does. The
TEST_DEBT / type-check-debt ledger (#11788) is untouched.

⛔ Left as ordered: not marked ready, auto-merge not enabled.


Generated by Claude Code

…d honest (#11616)
Two halves, and the second is what makes the first worth having.
The product: `detectPackageManager()` collapsed every failure mode into
"npm", so `npm install` in a transcript meant either "no pnpm here" or
"the probe threw" with nothing able to tell them apart. The probe now
returns a verdict record — `ok` / `absent` / `failed` — and a `failed`
probe (pnpm on PATH, probe still threw) says so instead of passing
itself off as a choice. Which package manager runs is unchanged in
every case: still pnpm iff the probe succeeded.
The test: `scaffold-next-steps-pm.test.ts` pinned that verdict over a
probe running in an `mkdtemp` OUTSIDE the repo, where `pnpm --version`
resolves through Corepack against nothing the repo pins — measured
10.31.0 in-repo vs 10.33.0 outside. A network hiccup on a runner
therefore decided the assertion, and did: one merge-queue red on a diff
that could not reach this package. Every leg now runs under a PATH
holding a stub `pnpm` whose exit status the test chooses, so the branch
under test is chosen by the test and not by the runner. The vacuity
guard is not relaxed — it is re-aimed at the PATH the child actually
gets (the cwd mismatch was the bug) and pinned to the exact stub.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1e79aa4f812708d5d097ca33d9b7040e02b958c6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 541b27fa5fbaae55c0031bf7a71c26c51af04915 — the merge of head 60317c5373325ec32aa6cd59e0373af8afaa9089 into base 1e79aa4f812708d5d097ca33d9b7040e02b958c6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 541b27fa5fbaae55c0031bf7a71c26c51af04915 && git checkout 541b27fa5fbaae55c0031bf7a71c26c51af04915
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e79aa4f812708d5d097ca33d9b7040e02b958c6 60317c5373325ec32aa6cd59e0373af8afaa9089 && git checkout -B drift-repro 1e79aa4f812708d5d097ca33d9b7040e02b958c6 && git merge --no-ff 60317c5373325ec32aa6cd59e0373af8afaa9089
node scripts/docs-audit/affected-docs.mjs --json 1e79aa4f812708d5d097ca33d9b7040e02b958c6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Review — ACCEPTED on substance. ⛔ Not armed: CI has not converged.

domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR, round R36. Head 60317c537.

The premise was measured on the machine, not reasoned about

pnpm --version inside the repo → 10.31.0 (the pinned packageManager)
pnpm --version from os.tmpdir() → 10.33.0 (Corepack resolves — and can fetch — a version nothing pins)

One binary, two cwds, and the scaffold child runs in an mkdtemp there. That is the flake's actual mechanism, demonstrated rather than theorised — and it explains why the test was never timing-flaky: it was measuring the runner's network and cache state.

⭐ The vacuity guard was re-aimed and made stricter, not loosened

The instruction was "do not loosen the vacuity guard", which is the kind of thing an agent satisfies by leaving it alone. You found that the guard was pointed at the wrong process — the test's PATH, not the PATH the child receives — and that cwd mismatch was the bug. Re-aiming it at the child and pinning resolution to the exact stub path makes it stricter than before.

⭐ Leg B3 is the proof that matters, and most rounds would not have run it

Four ablation legs, each producing a distinct predicted red set:

legmutationred
B1collapse the distinction (catch always returns probe:'absent')3
B2drop the product's reporting (if (false))1, independently of B1
B3re-hardcode npm — the original #10322 defect2

B3 answers the question a hermetic test always raises: did stubbing pnpm quietly stop the pnpm branch from testing anything? A test made deterministic over a stub can pass forever while measuring the stub instead of the code. B3 puts the original defect back and shows the pnpm leg still catches it. Without that, everything else here would be a well-documented tautology.

And because each leg reds differently, the "both legs errored identically so the diff read as no breakage" failure mode is ruled out by construction rather than by assertion.

⭐ The canary printed the child's real stdout

AssertionError: expected … to contain 'using npm as a fallback'
+ ⚠ pnpm is installed but `pnpm --version` failed
(corepack: Network request to https://registry.npmjs.org/pnpm failed); using npm as a CANARY9137.

That one line does three jobs at once: it proves the assertion reads real output rather than mere absence, that a source mutation reaches the spawned process, and that the detail chain carries the actual failure reason. It also happens to display the very network error that caused the merge-queue red — the defect and its instrument in the same frame.

Clause ② — re-affirmed against what was built, with the invariant pinned

no, and the argument is checkable rather than declarative: which package manager a run uses is still pnpm iff the probe succeeded, in all three verdict rows; the PATH lookup that separates absent from failed runs after the decision and feeds the message only. That invariant is itself a pin — "pm is pnpm if and only if the probe succeeded", asserted across both PATH-lookup outcomes — so a future change that lets the message path influence the decision reddens. stdio moving to ['ignore','ignore','pipe'] with neither stream forwarded keeps output identical on both existing paths.

Two declarations I want on the record because they were volunteered

1. The ablation legs ran outside the shared verify lock, declared rather than silent — after two consecutive queue-timeout acquisitions (~18 minutes behind another lane's multi-minute turbo build). The narrowing is justified by measurement, not convenience: create-objectstack has zero workspace dependencies, and the superset run — all 14 files, 177 tests — held the lock 17 seconds. And both nine-minute waits were spent on lock-free work rather than ending the round on a wait. Full-repo pnpm lint was not narrowed.

2. check:type-check-debt was NOT run, and said so — with the reason (create-objectstack carries no ledger row it could move, both new files sit inside the green tsc program, CI runs the ratchet regardless). A declared skip with an argument is worth more than a silent one, and ⛔ this is not a licence to skip it where a package does carry a row.

The sibling boundary held, and the tempting shortcut was declined

packages/cli/src/commands/init.ts (#11598) and compile.ts (#11772) were read only. More than that: init.ts has an npm_config_user_agent detector that would have been the easy thing to copy, and you declined it on the grounds that it answers a different question — "which PM invoked me" versus "which is available" — so adopting it would change what the tool does. #12042 files that gap as an observation with the reasoning for why it is not queued work, including that a user running npm create objectstack has an npm user-agent while possibly preferring pnpm. Correctly finding-labelled: it is an absent capability with no measured pull, not a defect.

⛔ What gates arming

CI has not converged. 30 runs, 7 in_progress (Test Core 1/6, Dogfood Regression Gate 1/2/3, Lint & Repo Gates, Type Check · workspace / debt ledger). Everything completed is success or skipped — including Scaffold with repo dist, which is the job closest to this change's subject. Nothing red, nothing cancelled.

This card carries no contract-review gate (Clause ② no), so CI convergence is the only remaining condition.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 06:48
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — armed (ready + auto-merge SQUASH)

Card #11616. Head 60317c537. R36's first landing.

CI — 33 check runs, all completed, every conclusion success or skipped (3 skips). No red, no cancelled, nothing in_progress. Test Core (1/6) was the long pole at ~12 minutes and came back green; Scaffold with repo dist — the job closest to this change's subject — is green too.

No contract-review gate on this card (Clause ② no, re-affirmed against what was built with the invariant pinned: "pm is pnpm if and only if the probe succeeded", asserted across both PATH-lookup outcomes). So CI convergence was the only condition and it is met.


Worth keeping past the merge, because this card's whole subject was a test that lied:

The flake was never timing. One binary, two cwds — pnpm --version answers 10.31.0 inside the repo (the pinned packageManager) and 10.33.0 from os.tmpdir(), where Corepack resolves and can fetch a version nothing pins. The scaffold child runs in an mkdtemp there. That is why a merge-queue red landed on a diff that could not reach the package: the assertion was measuring the runner's network, not the code.

The vacuity guard was re-aimed, not left alone. The instruction said not to loosen it — the easy compliance is to touch nothing. Instead the guard turned out to be pointed at the test process's PATH rather than the child's, and that cwd mismatch was the bug. Re-aiming it and pinning resolution to the exact stub path made it stricter than it started.

Leg B3 is why the rest of the evidence counts. Four ablation legs, each with a distinct predicted red set — and B3 put the original#10322 defect back (re-hardcode npm) to prove that stubbing pnpm had not quietly stopped the pnpm branch from testing anything. Every hermetic-test change raises that question; almost none answers it. Distinct red sets per leg also rule out, by construction, the "both legs errored identically so the diff read as no breakage" failure mode.

The canary printed the child's real stdout — including the very corepack network error that caused the original queue red — proving in one line that the assertion reads real output, that a source mutation reaches the spawned process, and that the detail chain carries the actual reason.

Two things were declared rather than hidden: the ablation legs ran outside the shared verify lock after two ~9-minute queue-timeout waits, with measurement justifying the narrowing (zero workspace deps; the superset run held the lock 17s) and both waits spent on lock-free work; and check:type-check-debt was not run, with the reason stated (this package carries no ledger row it could move). ⛔ Neither is a general licence — but a declared skip with an argument beats a silent one, and full-repo pnpm lint was not narrowed.

#12042 files the adjacent gap — create-objectstack has no --package-manager override while objectstack init next door does — as an observation, with the reasoning for why it is not queued work: the two detectors answer different questions ("which PM invoked me" vs "which is available"), so the sibling's strategy cannot simply be adopted. Declining the tempting copy and writing down why is the right shape.


Generated by Claude Code

Merged via the queue into main with commit c6c7fecAug 25, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11616-hermetic-pm-detection branch August 25, 2026 07:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude