Repository navigation
Commit c77a8fe
fix(scripts): read the last JSON document on pnpm pack --json stdout (#22753)
Fixes #22752
Clause-②: no
## What changed
`scripts/publish-smoke-pack.mjs` `packOne` parsed the WHOLE stdout of
`pnpm pack --json --pack-destination DEST`. Since `@objectstack/spec`
declared `prepare: pnpm gen:migration-registry`, pnpm prints that
lifecycle banner and the generator's output above the JSON document. The
parse therefore threw on every pack of the full population, so the
scaffold smoke (`os-create-smoke.yml`) and the release smoke
(`scripts/publish-smoke.sh`) both died at their first pack step.
The parse moves into an exported pure helper, `parsePackStdout(stdout,
name)`, and `packOne` now calls it. The helper returns the LAST JSON
document on stdout: it walks the `{` offsets from the end and takes the
first suffix that `JSON.parse` accepts whole. These stay unchanged: the
`filename` check, the set of packages packed, the overrides map,
`assertPinSetTotal`, and everything `publish-smoke.sh` /
`create-scaffold-smoke.sh` judge. The spec's `prepare` is untouched. The
diff is one file, +120 / -26.
## Measurements (pnpm 10.31.0, worktree at `bf515e724`)
**1. The banner precedes the document (confirmed).** Here is the stdout
head of `pnpm pack --json --pack-destination D` in `packages/spec`,
verbatim except the worktree path, written WT:
```text
> @objectstack/spec@17.7.0 prepare WT/packages/spec
> pnpm gen:migration-registry
> @objectstack/spec@17.7.0 gen:migration-registry WT/packages/spec
> tsx scripts/build-migration-registry.ts
build-migration-registry --self-test: ok
✓ src/migrations/registry.ts is current (420 semantic, 258 retired-key, 222 retired-def)
{
"name": "@objectstack/spec",
"version": "17.7.0",
```
- The pack exits 0 with empty stderr and 974 stdout lines. The document
starts on line 11.
- Parsing that stdout whole throws `Unexpected token` at the first
banner character, which is the base behaviour.
- `parsePackStdout` on the same bytes returns the tarball filename.
**2. The document is always last.**
- pnpm 10.31.0's own pack handler (`dist/pnpm.cjs`) runs `prepack` and
`prepare` before the tarball and `postpack` right after it. All three
run inside the pack API with inherited stdio. The CLI writes the
returned JSON string only after the handler returns, and its update
notifier runs for `install`/`add` only.
- I measured this on a throwaway package that declares all three hooks.
Every banner and echo line (PREPACK, PREPARE, POSTPACK) lands above the
document, and nothing lands after it.
- Census of all 81 workspace members: no `prepack` or `postpack`
anywhere. `prepare` appears only on `@objectstack/spec` (publishable)
and on the private root. `prepublishOnly` appears on
`@objectstack/console`, and pack does not run it.
- A second measurement shaped the selector. A lifecycle script whose
last write has no newline glues onto the document's opening brace. For
example, `printf "{ not json"` as `postpack` leaves a line reading `{
not json{`. A line-start selector would refuse that valid pack, and the
per-brace scan reads it.
**3. No flag keeps lifecycle output off stdout.**
- I tried `--silent`, `--reporter=silent`, `--loglevel=error`,
`--loglevel=silent`, `--reporter=ndjson` and `--reporter=append-only`,
each before and after `pack`. In every case the banner and the script
output stay on stdout. Lifecycle stdio is inherited, so no reporter
setting reaches it.
- pnpm 10.31.0's pack refuses `--ignore-scripts` outright: `ERROR
Unknown option: 'ignore-scripts'`, exit 1.
- The env spelling `npm_config_ignore_scripts=true` is accepted, but it
skips `prepare`, which `gen:migration-registry` needs (its output,
`src/migrations/registry.ts`, is gitignored). It was not measured equal,
so it is not used.
## Why the last document rather than the document carrying `filename`
pnpm writes its document strictly last (measurement 2), so output after
it is a shape nobody has observed. The last-document selector keeps that
case loud: no suffix parses, and the named error prints stdout. A search
keyed on `filename` would skip such output silently. The `filename`
check still runs on the selected document.
## Self-test
The battery `publish-smoke-pack self-test` grows from 4 to 10 cases, and
its pin is raised to 10.
| # | case | expected |
|---|---|---|
| 5 | banner (the measured spec head), then the document | filename read
|
| 6 | the document alone (the shape before `prepare` existed) | read |
| 7 | script output glued to the opening brace | read |
| 8 | output AFTER the document | throws, names the package, carries
stdout |
| 9 | stdout with no JSON document | throws, names the package, carries
stdout |
| 10 | `packOne` itself against a fake `pnpm` | the fake's document is
read |
The fake `pnpm` in case 10 is a `#!/bin/sh` script in a `mkdtemp` dir,
prepended to PATH. It prints the banner, then a document whose
`filename` echoes its `--pack-destination` argument, so the filename
proves that the fake answered, with packOne's argv.
`selfTest()` is now async, because case 10 awaits `packOne`. The
handshake keeps its kind: the dispatch awaits the self-test and still
refuses anything but `SELF_TEST_VERDICT`.
**How case 10 fits the header's "pure fixtures, no workspace, no
network".** It spawns one process: a fake pnpm that the self-test writes
into an OS temp dir and removes in a `finally`, where PATH is restored
too. It reads no workspace, no network and no tracked file, and it never
runs the real pnpm. The `no-path-population` declaration therefore still
holds. The header and the marker's reason now say "fixtures plus one
fake pnpm in a temp dir" instead of "in-memory fixtures".
## Ablation (fix committed first, at `34fff6b49`)
The mutation makes the helper parse stdout whole:
```text
node scripts/ablation-replace.mjs --file REPO/scripts/publish-smoke-pack.mjs \
--anchor 'parsed = JSON.parse(stdout.slice(at));' --replacement 'parsed = JSON.parse(stdout);' \
-- node REPO/scripts/publish-smoke-pack.mjs --self-test
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 8ef4ed2 -> a4e5fc75a5b6
FAIL — a lifecycle banner above the document is skipped
FAIL — script output glued to the opening brace is skipped
FAIL — packOne reads the document past a fake pnpm that prints a banner first
SELF-TEST FAILED
ablation-replace: command exited 1
ablation-replace: ok restored: blob == HEAD (8ef4ed2) and `git diff HEAD` is empty
```
The other seven cases stay ok under the mutation. The re-run after the
restore prints `SELF-TEST PASSED (10 cases)`.
## Real pnpm
- `node scripts/publish-smoke-pack.mjs D` is the scaffold smoke's step
1. I ran it with real pnpm 10.31.0 over the full population, under the
shared verify lock. It printed `Packing 69 publishable package(s)`, then
`packed 69/69`, then `Wrote 69 override(s)`, with `VERDICT command-exit
0`. At base this step throws on `@objectstack/spec` (the card's CI run).
- `bash scripts/create-scaffold-smoke.sh` was **not run locally**. It
refuses without `packages/cli/dist`, and it needs a full monorepo build
plus registry installs of every template. That does not fit the
container's ~10-minute foreground cap on a shared box. The end-to-end
proof is this PR's own `os-create-smoke` run, whose path filter names
this file.
## Gates (all at `34fff6b49`)
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` took the change set from git (1 path) and
derived 31 commands. All 31 ran and exited 0. `--ran` reconciles them:
`✓ dispatch-gates --ran: 31 derived famil(ies) accounted for — 31 run, 0
NOT-MEASURED`.
- `pnpm check:publish-smoke-pin` prints `SELF-TEST PASSED (10 cases)`.
- `check-self-test-wired` and `check-self-test-workflow-commands` exit
0, and so do their self-tests.
- `pnpm check:lockstep-package-count` prints `all 3 occurrence(s) agree
with the true count (69)`. The docblock count line is untouched.
- `pnpm check:pm-dispatch-gates` prints `1828 cases pass`.
- **ESLint, narrowed to this file, with the three proofs:**
- `eslint --print-config` puts this file in scope with 2 rules:
`no-restricted-imports` and
`comment-swallow/no-code-inside-block-comment`.
- `eslint --no-inline-config --format json
scripts/publish-smoke-pack.mjs` reports 1 file, 0 errors, 0 warnings.
- Invariance: the config enables no type-aware linting (no
`parserOptions.project` / `projectService`, as `eslint.config.mjs`
states near line 327), and both rules are per-file. This diff cannot
move any untouched file's verdict.
- Repo-wide `pnpm lint` belongs to CI.
No changeset: root `scripts/` ships in no package's `files[]`. The PR
carries the `skip-changeset` label.
## Acceptance notes
- The refusal text changed from "returned non-JSON" to "printed no JSON
document ending its stdout". No script or workflow parses it: grep finds
zero hits.
- `listPublicPackages` also parses `pnpm -r list --depth -1 --json`
whole. pnpm list runs no lifecycle script, and its stdout parsed cleanly
in this worktree (81 members, 69 publishable). It is not touched.
- PR #22691 edits this file's docblock count line (69 → 70), which this
diff does not touch. Whichever PR lands later merges main. Its red
`os-create-smoke` is this defect.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VQftDB6niKg91LeQANrS7d)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9859e4e commit c77a8fe
1 file changed
Lines changed: 120 additions & 26 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
49 | | - | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
50 | 51 | | |
51 | 52 | | |
52 | 53 | | |
53 | | - | |
| 54 | + | |
54 | 55 | | |
55 | 56 | | |
56 | | - | |
57 | | - | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
58 | 60 | | |
59 | 61 | | |
60 | 62 | | |
| |||
118 | 120 | | |
119 | 121 | | |
120 | 122 | | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
121 | 157 | | |
122 | 158 | | |
123 | 159 | | |
124 | 160 | | |
125 | 161 | | |
126 | 162 | | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
133 | | - | |
134 | | - | |
135 | | - | |
136 | | - | |
| 163 | + | |
137 | 164 | | |
138 | 165 | | |
139 | 166 | | |
| |||
180 | 207 | | |
181 | 208 | | |
182 | 209 | | |
183 | | - | |
184 | | - | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
185 | 215 | | |
186 | 216 | | |
187 | 217 | | |
| |||
215 | 245 | | |
216 | 246 | | |
217 | 247 | | |
218 | | - | |
| 248 | + | |
219 | 249 | | |
220 | 250 | | |
221 | 251 | | |
| |||
233 | 263 | | |
234 | 264 | | |
235 | 265 | | |
236 | | - | |
| 266 | + | |
237 | 267 | | |
238 | 268 | | |
239 | 269 | | |
| |||
249 | 279 | | |
250 | 280 | | |
251 | 281 | | |
252 | | - | |
| 282 | + | |
253 | 283 | | |
254 | 284 | | |
255 | | - | |
| 285 | + | |
256 | 286 | | |
257 | 287 | | |
258 | 288 | | |
| |||
263 | 293 | | |
264 | 294 | | |
265 | 295 | | |
266 | | - | |
| 296 | + | |
267 | 297 | | |
268 | 298 | | |
269 | 299 | | |
| |||
281 | 311 | | |
282 | 312 | | |
283 | 313 | | |
284 | | - | |
| 314 | + | |
285 | 315 | | |
286 | 316 | | |
287 | 317 | | |
288 | | - | |
| 318 | + | |
289 | 319 | | |
290 | 320 | | |
291 | 321 | | |
| |||
299 | 329 | | |
300 | 330 | | |
301 | 331 | | |
302 | | - | |
| 332 | + | |
303 | 333 | | |
304 | 334 | | |
305 | 335 | | |
| |||
310 | 340 | | |
311 | 341 | | |
312 | 342 | | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
313 | 407 | | |
314 | 408 | | |
315 | 409 | | |
| |||
369 | 463 | | |
370 | 464 | | |
371 | 465 | | |
372 | | - | |
| 466 | + | |
373 | 467 | | |
374 | 468 | | |
375 | 469 | | |
| |||
0 commit comments