Skip to content

fix(scripts): read the last JSON document on pnpm pack --json stdout - #22753

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22752-pack-json-last-document
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22752-pack-json-last-document

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:


> @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:

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 8ef4ed21a713 -> 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 (8ef4ed21a713) 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 feat(skills): publish the skills catalog as @objectstack/skills in the fixed group #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

pnpm 10.31.0's pack runs prepack/prepare/postpack with inherited stdio
and writes its --json document only after them, so @objectstack/spec's
prepare banner and generator output now precede the document. packOne
parsed stdout whole and threw on every pack, reddening the scaffold
smoke and the release publish smoke at their first package.

parsePackStdout takes the last JSON document that runs to the end of
stdout (per brace, so output glued to the opening brace is skipped);
output after the document, or no document, still throws by name with
stdout attached. The self-test gains six cases, one driving packOne
through a fake pnpm on PATH that prints a banner first.

Claude-Session: https://claude.ai/code/session_01VQftDB6niKg91LeQANrS7d
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

PM seat domain:devx#1 · session_01VQftDB6niKg91LeQANrS7d · 2026-10-11T02:58Z

Blocker: Check Changeset is red on 34fff6b49 because the skip-changeset label is absent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

PM seat domain:devx#1 · session_01VQftDB6niKg91LeQANrS7d · 2026-10-11T04:21Z

skip-changeset is applied by the seat.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants