From be402d695c41bc30c134bc329a50ae6df78ffed0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 23:41:22 -0700 Subject: [PATCH] v1.91.231.0 fix(setup): install production dependencies only (~520 MB smaller install) (GSTA-97) (#3426) * fix(setup): install production dependencies only; contributors and CI keep the full install (GSTA-97) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): production-only install (GSTA-97) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GSTA-97) - setup/gstack-browser-ensure: Bun treats --production as frozen, so the old fallback could never recover a stale lockfile; refresh it with --lockfile-only, then install production-only again. - gstack-browser-ensure failure line and the README tip no longer suggest a full bun install to users. - Tests: real-Bun stale-lockfile and keep-devDeps contracts, behavioral browser-ensure install tests, relative-import closure scan, multi-line and re-export negative controls; CHANGELOG matches. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, pass 2 (GSTA-97) - gstack-browser-ensure's failure line and the README tip suggest the frozen production install (./setup skips the install when the build is current, and a bare --production install is frozen anyway). - setup install guard catches quoted and unquoted $BUN_CMD spellings. - Replace the exact-text gstack-browser-ensure grep with an invariant: no full bun install; behavior is covered by gstack-browser-ensure.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup): keep a contributor checkout's dev packages on bun.lock's versions (GSTA-97) /ship Step 11: a production install over a full install reports "no changes" and leaves devDependencies at old versions after bun.lock moves, so the session updater's ./setup left contributors on stale TypeScript, Prettier and Agent SDK. A checkout with node_modules/typescript gets the full frozen install. The import-closure scan now follows shipped code into test/helpers (gstack-model-benchmark loads the providers). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, ship invocation 2 (GSTA-97) - Tests: contributor-branch failure row; bun.lock production closure never reaches the dev-toolchain marker; real-Bun rows isolate HOME, XDG_CONFIG_HOME and BUN_CONFIG_*; the import walker follows side-effect imports; file header matches the two install branches. - BROWSER.md quick start uses the frozen production install. - CHANGELOG: the reclaim recipe also drops the Agent SDK's cache entries (Bun hardlinks node_modules into its cache); Tests list is current. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): stub-bun wording matches what the fixtures check (GSTA-97) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.231.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- BROWSER.md | 2 +- CHANGELOG.md | 20 ++ CONTRIBUTING.md | 2 + README.md | 2 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/dev-setup | 10 +- bin/gstack-browser-ensure | 2 +- bun.lock | 6 +- package.json | 12 +- setup | 32 +- test/gstack-browser-ensure.test.ts | 47 +++ test/helpers/claude-overlay-fixture.ts | 2 +- test/helpers/install-fixture.ts | 4 +- test/setup-production-deps.test.ts | 388 +++++++++++++++++++++++++ test/team-mode.test.ts | 2 +- 16 files changed, 505 insertions(+), 30 deletions(-) create mode 100644 test/setup-production-deps.test.ts diff --git a/BROWSER.md b/BROWSER.md index 3047da8f5..071bd5ce3 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -275,7 +275,7 @@ reach for it. ```bash # One-time: build the binary (browse/dist/browse, ~58MB) -bun install && bun run build +bun install --production --frozen-lockfile && bun run build # Set $B once and forget about it B=./browse/dist/browse # or ~/.claude/skills/gstack/browse/dist/browse diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e635d98..92512f1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.231.0] - 2026-10-26 + +**A fresh install is about 520 MB smaller: `./setup` installs production dependencies only.** Setup ran `bun install` without `--production`, so every user also got the test and eval toolchain from `devDependencies`, mostly the Claude Agent SDK's native binaries (both the glibc and musl builds on Linux, ~440 MB). Measured on a clean Ubuntu 24.04 container with a cold Bun cache (4 vCPU, two runs each): `node_modules` goes from 1,094 MB to 572 MB, the checkout from 1.74 GB to 1.22 GB, and the whole install with Chromium from 2.42 GB to 1.90 GB. `./setup` took 24.6 s and 20.0 s before, 21.1 s and 18.9 s after. + +### Changed +- **`setup`**: every dependency install (before the build, the Codex render and each host render) goes through one `install_runtime_deps` function: `bun install --production --frozen-lockfile`. Bun treats `--production` as frozen, so when `bun.lock` is behind `package.json` (a fork or a local edit) it refreshes the lockfile with `bun install --lockfile-only` and installs production-only again. **`bin/gstack-browser-ensure`** installs the same way when `node_modules` is missing, and its failure line suggests the production install rather than a full `bun install`. +- **`package.json`**: `@anthropic-ai/sdk` (loaded by `gstack-distill-free-text`, /plan-tune's dream cycle) and `xterm` and `xterm-addon-fit` (copied into the browser extension by `bun run build`) move to `dependencies`, because a user install runs them. The Claude Agent SDK, TypeScript, Prettier and `@types/bun` stay dev-only. +- **Contributors and CI keep the full install.** `./setup` treats a checkout that already has the dev toolchain (`node_modules/typescript`) as a contributor's and runs the full `bun install --frozen-lockfile` there, so dev packages keep following `bun.lock` after a pull (a production install would report "no changes" and leave them at old versions). `bin/dev-setup` now always runs `bun install` (it skipped it when `node_modules` existed, which would have left a checkout first set up by `./setup` without the dev toolchain). CI workflows already run `bun install --frozen-lockfile` themselves. CONTRIBUTING.md says which command installs what. + +### If you already have gstack installed +- An existing install keeps its dev dependencies and keeps getting the full install, because it already has the dev toolchain. To get the space back: `cd ~/.claude/skills/gstack && rm -rf node_modules && bun install --production --frozen-lockfile && rm -rf "$(bun pm cache)"/@anthropic-ai/claude-agent-sdk*`. The last step matters: Bun hardlinks `node_modules` into its global cache, so the Agent SDK's binaries stay on disk until their cache entries go. Don't do this in a checkout you develop gstack in. + +### Tests +- `test/setup-production-deps.test.ts`: `install_runtime_deps`, sliced out of `setup` and run against a recording `bun` that models Bun's frozen `--production`, passes `--production` on the frozen-lockfile call, refreshes a stale lockfile before retrying, and stops setup when the refresh fails. A checkout with the dev toolchain gets the full frozen install, and a failed full install stops setup too. A walk of `bun.lock`'s production closure (with required-peer and optional-peer controls) proves no production dependency brings in `typescript`, the marker setup checks. The real-Bun rows run with `HOME`, `XDG_CONFIG_HOME` and `BUN_CONFIG_*` isolated, so a developer's global `.bunfig.toml` cannot change them. Run with the real Bun on local `file:` packages: a fresh install leaves out devDependencies, a later production install keeps the ones a full install added, and a stale lockfile is refreshed while the install stays production-only. No other line in `setup` calls `bun install` (the pre-change line is the negative control). `bin/dev-setup`'s install block, run over an existing `node_modules`, still calls plain `bun install`. A scan of every shipped file (`bin`, `lib`, `browse/src`, `design/src`, `make-pdf/src`, `hosts`, `extension`, the resolvers, and the scripts `setup` and the build call by path) finds no import or `require` of a package outside `dependencies`. Modules the shipped code reaches by relative import (gen-skill-docs' `scripts/` helpers, and the `test/helpers/providers` modules `gstack-model-benchmark` loads) are scanned too. Negative controls: a planted Agent SDK import is caught, including one split over several lines and a re-export; a type-only import is not; and moving `@anthropic-ai/sdk` back to dev flags `gstack-distill-free-text`. The packages `bun run build` copies out of `node_modules` are production dependencies. +- `test/gstack-browser-ensure.test.ts`: with `node_modules` missing, the preflight runs the frozen production install, refreshes a stale lockfile before retrying, and ends a failed install in `BROWSER_UNAVAILABLE` with a production-install fix line. +- The install fixtures' stub `bun` (`test/helpers/install-fixture.ts`, `test/helpers/claude-overlay-fixture.ts`, `test/team-mode.test.ts`) now matches `install --production --frozen-lockfile`. The team-mode stub fails on any other argv; the two helper stubs hand other commands to the real `bun`, so for them the new argv is a match, not a regression check. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion was weakened. The four stub patterns (in three files) changed to the new argv only. + ## [1.91.230.0] - 2026-10-26 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee101e0eb..3f33e91dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,8 @@ bun install # install dependencies bin/dev-setup # activate dev mode ``` +`./setup` on its own installs production dependencies only. `bun install` (and `bin/dev-setup`, which runs it) adds the dev toolchain the tests and evals need: the Claude Agent SDK, TypeScript and Prettier. Once they are installed, a later `./setup` runs the full install too, so they stay at `bun.lock`'s versions. + > **Full clone vs shallow.** The README's user-facing install uses `--depth 1` for speed. As a contributor, use a full clone (no `--depth` flag) — you'll need history for `git log`, `git blame`, `git bisect`, and reviewing PRs against earlier versions. If you already have a `--depth 1` clone from following the README, promote it to a full clone with `git fetch --unshallow`. ### First free check (no API key, no browser) diff --git a/README.md b/README.md index 213da088e..055a992ee 100644 --- a/README.md +++ b/README.md @@ -790,7 +790,7 @@ A project install lives in the project's `.claude/skills/gstack` or **`/browse` (or `/qa`, `/design-review`) says `NEEDS_ASIDE` or `ASIDE_NOT_RUNNING`?** That's the probe telling you it's about to use the fallback browser. Want Aside? Open the app and sign in — `aside --version` should print a version and `aside repl 'console.log("ok")'` should print `ok` — then re-run. gstack never installs it for you. Want the fallback on purpose while Aside is open? `GSTACK_SKIP_ASIDE=1` makes every skill, the renderer, and `./setup` treat Aside as absent. When Aside is absent the probe prints `NEEDS_ASIDE: ` and skills trust that line for the macOS-only download pitch; `GSTACK_PLATFORM` overrides the OS it names, for tests and unusual hosts (set it in your shell — gstack never reads it from a project `.env`). -**`/browse` fails on the fallback browser?** `cd ~/.claude/skills/gstack && bun install && bun run build` +**`/browse` fails on the fallback browser?** `cd ~/.claude/skills/gstack && bun install --production --frozen-lockfile && bun run build` **`/make-pdf` or `/diagram` can't render?** Same two paths: with Aside open they print through Aside (`bun run ~/.claude/skills/gstack/bin/gstack-render.ts some.html --screenshot /tmp/out.png` tests it directly, and its first line, `ENGINE=aside` or `ENGINE=browse`, names the browser that actually rendered); without it they use the bundled browser, so `bun run build` is the fix. diff --git a/VERSION b/VERSION index 2b6fc7540..6b0b2b443 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.230.0 +1.91.231.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e920a894c..b26059b48 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.230.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.231.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/dev-setup b/bin/dev-setup index 4079e5cc4..f77026715 100755 --- a/bin/dev-setup +++ b/bin/dev-setup @@ -22,11 +22,11 @@ if [ ! -f "$REPO_ROOT/.env" ]; then fi fi -# 2. Install dependencies -if [ ! -d "$REPO_ROOT/node_modules" ]; then - echo "Installing dependencies..." - (cd "$REPO_ROOT" && bun install) -fi +# 2. Install dependencies, devDependencies included. Always run: a checkout +# first installed by ./setup has production dependencies only, and an +# up-to-date install is a no-op. +echo "Installing dependencies..." +(cd "$REPO_ROOT" && bun install) # 3. Create .claude/skills/ inside the repo mkdir -p "$REPO_ROOT/.claude/skills" diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index 764995fbb..5f8605f4e 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -74,7 +74,7 @@ if [ ! -d "$ROOT/node_modules/playwright" ]; then [ "$CHECK" -eq 1 ] && fail "playwright is not installed under $ROOT/node_modules" "cd $ROOT && ./setup" command -v bun >/dev/null 2>&1 || fail "playwright is not installed under $ROOT/node_modules and bun is not on PATH" "install Bun, then cd $ROOT && ./setup" log "gstack-browser-ensure: installing node modules (bun install)..." - (cd "$ROOT" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install, then re-run $0" + (cd "$ROOT" && { bun install --production --frozen-lockfile >/dev/null 2>&1 || { bun install --lockfile-only >/dev/null && bun install --production --frozen-lockfile >/dev/null; }; }) || fail "bun install failed under $ROOT" "cd $ROOT && bun install --production --frozen-lockfile, then re-run $0" fi probe diff --git a/bun.lock b/bun.lock index 1ae228532..dc2f348d0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "gstack", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@huggingface/transformers": "^4.3.1", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", @@ -15,15 +16,14 @@ "playwright": "^1.62.1", "sharp": "^0.35.4", "socks": "^2.8.10", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.141", - "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.3", "prettier": "3.9.10", "typescript": "7.0.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0", }, }, }, diff --git a/package.json b/package.json index 7c5eb415d..f2cf08342 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.230", + "version": "1.91.231", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", @@ -69,6 +69,7 @@ "eval:bg:release": "bun run scripts/eval-bg.ts release" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@huggingface/transformers": "^4.3.1", "@ngrok/ngrok": "^1.7.0", "cross-spawn": "^7.0.6", @@ -78,7 +79,9 @@ "marked": "^18.1.1", "playwright": "^1.62.1", "sharp": "^0.35.4", - "socks": "^2.8.10" + "socks": "^2.8.10", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "engines": { "bun": ">=1.4.2" @@ -95,12 +98,9 @@ ], "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.141", - "@anthropic-ai/sdk": "^0.78.0", "@types/bun": "1.4.3", "prettier": "3.9.10", - "typescript": "7.0.2", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "typescript": "7.0.2" }, "overrides": { "@modelcontextprotocol/sdk": "1.31.0", diff --git a/setup b/setup index 50225c258..fae7c6796 100755 --- a/setup +++ b/setup @@ -1712,6 +1712,24 @@ bun_cmd() { "$BUN_CMD" "$@" } +# Users get production dependencies only. devDependencies are the test and eval +# toolchain (the Claude Agent SDK's native binaries alone are ~440 MB on Linux), and +# nothing setup, the build or an installed skill runs needs them. Bun never +# prunes packages that are already installed, so a contributor's full +# `bun install` (CONTRIBUTING.md, bin/dev-setup, CI) survives a later ./setup. +# Bun's --production implies a frozen lockfile, so when bun.lock is out of date +# (a fork or a local edit) refresh it first, then install production-only again. +# A checkout that already has the dev toolchain (node_modules/typescript) is a +# contributor's: a production install would leave its dev packages at old +# versions after bun.lock moves, so it gets the full install instead. +install_runtime_deps() { + if [ -d node_modules/typescript ]; then + bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + return + fi + bun_cmd install --production --frozen-lockfile 2>/dev/null || { bun_cmd install --lockfile-only && bun_cmd install --production --frozen-lockfile; } +} + cleanup_copied_bun() { local rc=$? if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then @@ -1929,7 +1947,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then log "Building browse binary..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then export GSTACK_SETUP_SKIP_CSO_BUILD=1 fi @@ -2080,7 +2098,7 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then log "Generating .agents/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) _prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$CODEX_SKILLS" @@ -2093,7 +2111,7 @@ if [ "$INSTALL_FACTORY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO log "Generating .factory/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2103,7 +2121,7 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_M log "Generating .opencode/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2113,7 +2131,7 @@ if [ "$INSTALL_CURSOR" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MOD log "Generating .cursor/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2123,7 +2141,7 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MO log "Generating .copilot/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi @@ -2133,7 +2151,7 @@ if [ "$INSTALL_AGY" -eq 1 ] && { [ "$NEEDS_BUILD" -eq 0 ] || [ -n "$_PLAN_MODE_A log "Generating .agy/ skill docs..." ( cd "$SOURCE_GSTACK_DIR" - bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + install_runtime_deps bun_cmd run gen:skill-docs --host agy ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ) fi diff --git a/test/gstack-browser-ensure.test.ts b/test/gstack-browser-ensure.test.ts index 8a03c0299..31f2a684e 100644 --- a/test/gstack-browser-ensure.test.ts +++ b/test/gstack-browser-ensure.test.ts @@ -136,6 +136,53 @@ describe('gstack-browser-ensure with a winget-shaped Bun (#3131)', () => { }); }); +// Generated by /ship Step 7 coverage audit (GSTA-97). +// Value: protects=a missing node_modules is installed production-only, a stale lockfile is refreshed before the retry, and a failed install ends in BROWSER_UNAVAILABLE; +// fails_when=--production leaves either install, the lockfile refresh is lost, or a failed install no longer reports the fix line; +// why_new=setup-production-deps.test.ts matches the line's text only, and the --check case never installs; +// seam=none; no_claim=a real bun install (a stub bun records argv, models Bun's frozen --production rule and restores a stub playwright) +describe('gstack-browser-ensure installs a missing node_modules production-only (GSTA-97)', () => { + function installing(frozenRc: number, fallbackRc: number) { + const f = fixture(); + fs.writeFileSync(f.marker, ''); + fs.renameSync(path.join(f.root, 'node_modules/playwright'), path.join(f.base, 'playwright-stub')); + fs.writeFileSync(path.join(f.base, 'stub/bun'), [ + '#!/usr/bin/env bash', + '[ "$1" = install ] || exit 9', + `echo "$*" >> ${JSON.stringify(path.join(f.base, 'install-calls'))}`, + `refreshed=${JSON.stringify(path.join(f.base, 'lockfile-refreshed'))}`, + `case "$*" in\n *--lockfile-only*) rc=${fallbackRc}; [ "$rc" -eq 0 ] && : > "$refreshed"; exit "$rc" ;;\n *--frozen-lockfile*) rc=${frozenRc}; [ -f "$refreshed" ] && rc=0 ;;\n *) exit 9 ;;\nesac`, + `[ "$rc" -eq 0 ] && mkdir -p node_modules && cp -R ${JSON.stringify(path.join(f.base, 'playwright-stub'))} node_modules/playwright`, + 'exit "$rc"', + ].join('\n') + '\n', { mode: 0o755 }); + const r = f.run(); + const calls = fs.existsSync(path.join(f.base, 'install-calls')) ? fs.readFileSync(path.join(f.base, 'install-calls'), 'utf8').trim().split('\n') : []; + return { f, r, calls }; + } + + test('the frozen production install, then BROWSER_OK', () => { + const { r, calls } = installing(0, 0); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(calls).toEqual(['install --production --frozen-lockfile']); + expect(lastLine(r.stdout)).toBe('BROWSER_OK node 141.0.7390.37'); + }); + + test('a stale lockfile is refreshed, then installed production-only again', () => { + const { r, calls } = installing(1, 0); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(calls).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only', 'install --production --frozen-lockfile']); + expect(lastLine(r.stdout)).toBe('BROWSER_OK node 141.0.7390.37'); + }); + + test('a failed install is BROWSER_UNAVAILABLE with the fix line', () => { + const { f, r, calls } = installing(1, 1); + expect(r.status).toBe(1); + expect(calls).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only']); + expect(lastLine(r.stdout)).toBe(`BROWSER_UNAVAILABLE bun install failed under ${f.root}`); + expect(r.stderr).toContain(`fix: cd ${f.root} && bun install --production --frozen-lockfile, then re-run`); + }); +}); + describe('browse preflight wiring (scripts/resolvers/browse.ts)', () => { test('every browser skill\'s $B detection runs gstack-browser-ensure from the install root before READY', () => { for (const host of ['claude', 'codex'] as const) { diff --git a/test/helpers/claude-overlay-fixture.ts b/test/helpers/claude-overlay-fixture.ts index 17a3c32a0..ded474ef6 100644 --- a/test/helpers/claude-overlay-fixture.ts +++ b/test/helpers/claude-overlay-fixture.ts @@ -21,7 +21,7 @@ export const MARKER = 'Effort-match the step'; export function failableBun(f: Fixture) { put(join(f.commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;; *gen:skill-docs*--out-dir*) if [ -f "$HOME/fail-render" ]; then echo 'render failed (fixture)' >&2; exit 7; fi ;; esac diff --git a/test/helpers/install-fixture.ts b/test/helpers/install-fixture.ts index 7d25bee85..2c08c54b5 100644 --- a/test/helpers/install-fixture.ts +++ b/test/helpers/install-fixture.ts @@ -172,7 +172,7 @@ export function makeFixture(): Fixture { mkdirSync(home, { recursive: true }); put(join(commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;; *) exec ${quote(process.execPath)} "$@" ;; esac @@ -248,7 +248,7 @@ export function fixture(layout: string) { }; write(join(commands, 'bun'), `#!/usr/bin/env bash case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;; 'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;; *) exec ${quote(process.execPath)} "$@" ;; diff --git a/test/setup-production-deps.test.ts b/test/setup-production-deps.test.ts new file mode 100644 index 000000000..9664d568f --- /dev/null +++ b/test/setup-production-deps.test.ts @@ -0,0 +1,388 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { builtinModules } from 'module'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBashScript } from './helpers/bash-script'; + +// ./setup installs production dependencies only (GSTA-97). devDependencies are +// the test and eval toolchain, chiefly the Claude Agent SDK's per-platform +// native binaries (~440 MB on Linux). This file pins the two halves of that: +// a user install (no dev toolchain in node_modules) is production-only, while a +// checkout that already has the dev toolchain gets the full frozen install; and +// nothing setup, the build or an installed skill loads is a devDependency. + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'); +const PKG = JSON.parse(read('package.json')); +const bases: string[] = []; +afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); + +/** process.env without the developer's global Bun config: HOME and XDG_CONFIG_HOME move into the temp base, BUN_CONFIG_* is dropped. */ +function isolatedBunEnv(base: string): NodeJS.ProcessEnv { + const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('BUN_CONFIG_'))); + for (const dir of ['home', 'xdg']) fs.mkdirSync(path.join(base, dir), { recursive: true }); + return { ...env, HOME: path.join(base, 'home'), XDG_CONFIG_HOME: path.join(base, 'xdg') }; +} + +function tempBase(): string { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prod-deps-')); + bases.push(base); + return base; +} + +/** A bash function sliced out of a script, by its `name() {` line through the closing `}`. */ +function sliceFunction(source: string, name: string): string { + const start = source.indexOf(`\n${name}() {\n`); + if (start === -1) throw new Error(`${name}() not found`); + const end = source.indexOf('\n}\n', start); + return source.slice(start + 1, end + 3); +} + +/** + * A bun that records each argv line. Like Bun, a --frozen-lockfile install fails + * ($FROZEN_RC) until `install --lockfile-only` has refreshed the lockfile; any other + * call exits $FALLBACK_RC. + */ +function stubBun(base: string): { bun: string; calls: () => string[] } { + const log = path.join(base, 'calls'); + const bun = path.join(base, 'bun'); + const fresh = path.join(base, 'lockfile-refreshed'); + fs.writeFileSync(bun, `#!/usr/bin/env bash\necho "$*" >> ${JSON.stringify(log)}\ncase "$*" in\n *--frozen-lockfile*) [ -f ${JSON.stringify(fresh)} ] && exit 0; exit "\${FROZEN_RC:-0}" ;;\n *--lockfile-only*) rc="\${FALLBACK_RC:-0}"; [ "$rc" -eq 0 ] && : > ${JSON.stringify(fresh)}; exit "$rc" ;;\nesac\nexit "\${FALLBACK_RC:-0}"\n`, { mode: 0o755 }); + return { bun, calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').trim().split('\n') : []) }; +} + +describe.skipIf(process.platform === 'win32')('setup installs production dependencies only', () => { + const setup = read('setup'); + const script = (bun: string, call: string) => + `set -e\nBUN_CMD=${JSON.stringify(bun)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`; + + test('install_runtime_deps passes --production with the frozen lockfile, and refreshes a stale lockfile before installing production-only again', () => { + const base = tempBase(); + const { bun, calls } = stubBun(base); + expect(runBashScript(script(bun, 'install_runtime_deps'), { cwd: base, env: { ...process.env, FROZEN_RC: '0' } }).status).toBe(0); + expect(calls()).toEqual(['install --production --frozen-lockfile']); + + const base2 = tempBase(); + const stub2 = stubBun(base2); + expect(runBashScript(script(stub2.bun, 'install_runtime_deps'), { cwd: base2, env: { ...process.env, FROZEN_RC: '1' } }).status).toBe(0); + expect(stub2.calls()).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only', 'install --production --frozen-lockfile']); + + // Generated by /ship Step 7 coverage audit (GSTA-97). + // Value: protects=a failed fallback install stops setup's install step instead of building on a missing node_modules; + // fails_when=install_runtime_deps swallows the fallback's failure (an added `|| true` or a trailing command); + // why_new=both rows above end in a successful install; seam=none; no_claim=setup's own subshell wiring around the call + const base3 = tempBase(); + const stub3 = stubBun(base3); + const failed = runBashScript(script(stub3.bun, 'install_runtime_deps\necho AFTER'), { cwd: base3, env: { ...process.env, FROZEN_RC: '1', FALLBACK_RC: '3' } }); + expect(failed.status).toBe(3); + expect(failed.stdout).not.toContain('AFTER'); + expect(stub3.calls()).toEqual(['install --production --frozen-lockfile', 'install --lockfile-only']); + }); + + // Value: protects=a contributor checkout (dev toolchain present) keeps its dev packages at bun.lock's versions after ./setup; + // fails_when=setup runs the production install there, which Bun reports as "no changes" and leaves old dev versions; + // why_new=every other row starts without node_modules; seam=none; no_claim=Bun's own update behavior (stub bun) + test('a checkout with the dev toolchain gets the full frozen install, falling back to a plain install', () => { + const base = tempBase(); + fs.mkdirSync(path.join(base, 'node_modules', 'typescript'), { recursive: true }); + const { bun, calls } = stubBun(base); + expect(runBashScript(script(bun, 'install_runtime_deps'), { cwd: base, env: { ...process.env, FROZEN_RC: '0' } }).status).toBe(0); + expect(calls()).toEqual(['install --frozen-lockfile']); + + const base2 = tempBase(); + fs.mkdirSync(path.join(base2, 'node_modules', 'typescript'), { recursive: true }); + const stub2 = stubBun(base2); + expect(runBashScript(script(stub2.bun, 'install_runtime_deps'), { cwd: base2, env: { ...process.env, FROZEN_RC: '1' } }).status).toBe(0); + expect(stub2.calls()).toEqual(['install --frozen-lockfile', 'install']); + + // Generated by /ship Step 7 coverage audit (GSTA-97). + // Value: protects=a contributor checkout whose full install fails stops setup's install step instead of building on stale packages; + // fails_when=the dev branch swallows the plain install's failure (an added `|| true`, or a warn-and-continue fallback); + // why_new=both rows above succeed, and the failure row in the test above runs the production branch; seam=none; + // no_claim=setup's own subshell wiring around the call (stub bun) + const base3 = tempBase(); + fs.mkdirSync(path.join(base3, 'node_modules', 'typescript'), { recursive: true }); + const stub3 = stubBun(base3); + const failed = runBashScript(script(stub3.bun, 'install_runtime_deps\necho AFTER'), { cwd: base3, env: { ...process.env, FROZEN_RC: '1', FALLBACK_RC: '4' } }); + expect(failed.status).toBe(4); + expect(failed.stdout).not.toContain('AFTER'); + expect(stub3.calls()).toEqual(['install --frozen-lockfile', 'install']); + }); + + test('every bun install in setup goes through install_runtime_deps', () => { + const body = setup.replace(sliceFunction(setup, 'install_runtime_deps'), ''); + const directInstall = /(?:^|[\s;&|(])(?:bun_cmd|"?\$\{?BUN_CMD\}?"?|bun)\s+install\b/; + const direct = body.split('\n').filter((l) => directInstall.test(l) && !l.trim().startsWith('#')); + expect(direct).toEqual([]); + // The build, the Codex render and each host render install first. + expect(setup.match(/^\s+install_runtime_deps$/gm)?.length).toBeGreaterThanOrEqual(7); + // Negative controls: the pre-GSTA-97 line and every other spelling of a direct install are caught. + for (const line of [ + ' bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install', + ' "$BUN_CMD" install --frozen-lockfile', + ' $BUN_CMD install', + ' "${BUN_CMD}" install', + ' (cd x && bun install)', + ]) expect(directInstall.test(line), line).toBe(true); + expect(directInstall.test(' bun_cmd x playwright install chromium')).toBe(false); + }); + + test('gstack-browser-ensure never runs a full bun install', () => { + const fullInstalls = (text: string) => text.split('\n') + .filter((l) => !l.trim().startsWith('#') && !/^\s*log /.test(l)) + .flatMap((l) => [...l.matchAll(/\bbun install\b(?! --production| --lockfile-only| failed)/g)].map(() => l.trim())); + const ensure = read('bin/gstack-browser-ensure'); + expect(ensure).toContain('bun install --production --frozen-lockfile'); + expect(fullInstalls(ensure)).toEqual([]); + // Negative control: the pre-GSTA-97 install line is caught. + expect(fullInstalls(' (cd "$ROOT" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null; })')).toHaveLength(2); + }); + + test('bin/dev-setup installs the full set even over a production-only node_modules', () => { + const devSetup = read('bin/dev-setup'); + const start = devSetup.indexOf('# 2. Install dependencies'); + const block = devSetup.slice(start, devSetup.indexOf('# 3.', start)); + const base = tempBase(); + const { bun, calls } = stubBun(base); + fs.mkdirSync(path.join(base, 'node_modules')); + const r = runBashScript(`set -e\nREPO_ROOT=${JSON.stringify(base)}\nbun() { ${JSON.stringify(bun)} "$@"; }\n${block}`); + expect(r.status).toBe(0); + expect(calls()).toEqual(['install']); + }); +}); + +// Generated by /ship Step 7 coverage audit (GSTA-97). +// Value: protects=with the real Bun, install_runtime_deps omits devDependencies on a fresh install and keeps the ones a full install already put there; +// fails_when=Bun starts pruning devDependencies on --production (a later ./setup strips a contributor's toolchain) or --production stops excluding them; +// why_new=every other test here stubs bun, so the CHANGELOG and CONTRIBUTING claim that ./setup never removes packages was untested; +// seam=none; no_claim=registry packages or the repo's own bun.lock (two local file: packages stand in; offline) +describe.skipIf(process.platform === 'win32')('install_runtime_deps with the real Bun', () => { + test('a fresh install leaves out devDependencies; a later production install keeps the ones a full install added', () => { + const base = tempBase(); + const project = path.join(base, 'project'); + for (const name of ['runtime-pkg', 'dev-pkg']) { + fs.mkdirSync(path.join(project, 'pkgs', name), { recursive: true }); + fs.writeFileSync(path.join(project, 'pkgs', name, 'package.json'), JSON.stringify({ name, version: '1.0.0' })); + } + fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ + name: 'prod-deps-fixture', version: '0.0.0', + dependencies: { 'runtime-pkg': 'file:./pkgs/runtime-pkg' }, + devDependencies: { 'dev-pkg': 'file:./pkgs/dev-pkg' }, + })); + const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') }; + const setup = read('setup'); + const run = (call: string) => runBashScript( + `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`, + { cwd: project, env, timeout: 90_000 }); + const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json')); + + const lockfile = run('bun_cmd install'); + expect(lockfile.status, lockfile.stderr).toBe(0); + const lock = fs.readFileSync(path.join(project, 'bun.lock'), 'utf-8'); + fs.rmSync(path.join(project, 'node_modules'), { recursive: true, force: true }); + + const fresh = run('install_runtime_deps'); + expect(fresh.status, fresh.stderr).toBe(0); + expect([has('runtime-pkg'), has('dev-pkg')]).toEqual([true, false]); + + const full = run('bun_cmd install'); + expect(full.status, full.stderr).toBe(0); + expect(has('dev-pkg')).toBe(true); + + const later = run('install_runtime_deps'); + expect(later.status, later.stderr).toBe(0); + expect([has('runtime-pkg'), has('dev-pkg')]).toEqual([true, true]); + expect(fs.readFileSync(path.join(project, 'bun.lock'), 'utf-8')).toBe(lock); + }); + + // Value: protects=a checkout whose bun.lock is behind package.json (a fork, a local edit) still installs, production-only; + // fails_when=the fallback reruns a --production install, which Bun treats as frozen, so setup aborts on the stale lockfile; + // why_new=the stub-bun rows cannot model Bun's rule that --production implies a frozen lockfile; + // seam=none; no_claim=registry resolution (local file: packages; offline) + test('a stale lockfile is refreshed, then the install stays production-only', () => { + const base = tempBase(); + const project = path.join(base, 'project'); + for (const name of ['runtime-pkg', 'late-pkg', 'dev-pkg']) { + fs.mkdirSync(path.join(project, 'pkgs', name), { recursive: true }); + fs.writeFileSync(path.join(project, 'pkgs', name, 'package.json'), JSON.stringify({ name, version: '1.0.0' })); + } + const manifest = (deps: Record) => fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ + name: 'prod-deps-fixture', version: '0.0.0', dependencies: deps, devDependencies: { 'dev-pkg': 'file:./pkgs/dev-pkg' }, + })); + const env = { ...isolatedBunEnv(base), BUN_INSTALL_CACHE_DIR: path.join(base, 'cache') }; + const setup = read('setup'); + const run = (call: string) => runBashScript( + `set -e\nBUN_CMD=${JSON.stringify(process.execPath)}\n${sliceFunction(setup, 'bun_cmd')}\n${sliceFunction(setup, 'install_runtime_deps')}\n${call}\n`, + { cwd: project, env, timeout: 90_000 }); + const has = (name: string) => fs.existsSync(path.join(project, 'node_modules', name, 'package.json')); + + manifest({ 'runtime-pkg': 'file:./pkgs/runtime-pkg' }); + expect(run('bun_cmd install --lockfile-only').status).toBe(0); + manifest({ 'runtime-pkg': 'file:./pkgs/runtime-pkg', 'late-pkg': 'file:./pkgs/late-pkg' }); + + const stale = run('install_runtime_deps'); + expect(stale.status, stale.stderr).toBe(0); + expect([has('runtime-pkg'), has('late-pkg'), has('dev-pkg')]).toEqual([true, true, false]); + expect(fs.readFileSync(path.join(project, 'bun.lock'), 'utf-8')).toContain('late-pkg'); + }); +}); + +/** Bare package names a source file loads (import/require/dynamic import), type-only imports excluded. */ +function importedPackages(text: string): string[] { + const out = new Set(); + const patterns = [ + /^\s*(?:import|export)\s+(?!type\b)[^'";]*?from\s+['"]([^'"]+)['"]/gm, + /^\s*import\s+['"]([^'"]+)['"]/gm, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, + ]; + for (const re of patterns) { + for (const m of text.matchAll(re)) { + const spec = m[1]; + if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:') || spec === 'bun' || spec.startsWith('bun:')) continue; + const name = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]; + if (builtinModules.includes(name)) continue; + out.add(name); + } + } + return [...out]; +} + +/** Every shipped-runtime file that loads a package which is not a production dependency. */ +function nonProductionImports(pkg: { dependencies?: Record }, files: Record): string[] { + const deps = pkg.dependencies ?? {}; + const bad: string[] = []; + for (const [file, text] of Object.entries(files)) { + for (const name of importedPackages(text)) if (!(name in deps)) bad.push(`${file}: ${name}`); + } + return bad.sort(); +} + +/** The code setup, the build and installed skills run: shipped dirs plus the scripts setup and the build call by path. */ +function runtimeFiles(): Record { + const roots = ['bin/', 'lib/', 'browse/src/', 'design/src/', 'make-pdf/src/', 'hosts/', 'extension/', 'scripts/resolvers/']; + const tracked = spawnSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout + .split('\n').filter((rel) => roots.some((r) => rel.startsWith(r))); + const ownPackage = (rel: string) => rel.startsWith('lib/diagram-render/'); + const invoked = new Set(['scripts/gen-skill-docs.ts']); + for (const src of ['setup', 'scripts/build.sh']) for (const m of read(src).matchAll(/scripts\/[A-Za-z0-9_./-]+\.ts/g)) invoked.add(m[0]); + const files: Record = {}; + for (const rel of [...tracked, ...invoked]) { + if (ownPackage(rel) || /(^|\/)(test|node_modules|dist)\//.test(rel) || /\.(test\.ts|md|json|png|svg|css|html|c|h)$/.test(rel)) continue; + const abs = path.join(ROOT, rel); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile() || fs.statSync(abs).size > 2_000_000) continue; + const text = fs.readFileSync(abs, 'utf-8'); + if (text.includes('\u0000')) continue; + files[rel] = text; + } + return files; +} + +describe('setup and runtime need only production dependencies', () => { + test('no shipped code loads a devDependency or an undeclared package', () => { + const files = runtimeFiles(); + expect(Object.keys(files).length).toBeGreaterThan(300); + expect(nonProductionImports(PKG, files)).toEqual([]); + }); + + // Generated by /ship Step 7 coverage audit (GSTA-97). + // Value: protects=modules the shipped entry points load by relative import (gen-skill-docs' scripts/ helpers) use production dependencies only; + // fails_when=a scripts/ module gen-skill-docs imports adds a devDependency import, breaking every user render; + // why_new=runtimeFiles() scans fixed roots, and scripts/discover-skills.ts, gen-agents-digest.ts and the rest are outside them; + // seam=none; no_claim=dynamic import paths built at runtime, or packages loaded by a spawned subprocess + test('every module the shipped code reaches by relative import loads only production dependencies', () => { + const files = runtimeFiles(); + const reached: Record = {}; + const queue = Object.keys(files); + const relSpec = /^\s*(?:import|export)\s+(?!type\b)[^'";]*?from\s+['"](\.\.?\/[^'"]+)['"]|\b(?:import|require)\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)|^\s*import\s+['"](\.\.?\/[^'"]+)['"]/gm; + while (queue.length) { + const rel = queue.pop()!; + const text = files[rel] ?? reached[rel]; + for (const m of text.matchAll(relSpec)) { + const spec = path.posix.normalize(path.posix.join(path.posix.dirname(rel), m[1] ?? m[2] ?? m[3])); + const target = [spec, `${spec}.ts`, `${spec}.js`, `${spec}/index.ts`].find((c) => fs.existsSync(path.join(ROOT, c)) && fs.statSync(path.join(ROOT, c)).isFile()); + if (!target || target.startsWith('..') || target in files || target in reached || /(^|\/)node_modules\//.test(target) || (/(^|\/)test\//.test(target) && !target.startsWith('test/helpers/')) || !/\.(ts|js|mjs)$/.test(target)) continue; + reached[target] = fs.readFileSync(path.join(ROOT, target), 'utf-8'); + queue.push(target); + } + } + expect(Object.keys(reached)).toContain('scripts/discover-skills.ts'); + expect(Object.keys(reached)).toContain('test/helpers/providers/claude.ts'); + expect(nonProductionImports(PKG, reached)).toEqual([]); + }); + + test('negative controls: a devDependency import and a moved-back runtime package are caught', () => { + expect(nonProductionImports(PKG, { 'bin/x': "import { query } from '@anthropic-ai/claude-agent-sdk';" })).toEqual(['bin/x: @anthropic-ai/claude-agent-sdk']); + expect(nonProductionImports(PKG, { 'bin/x': "import type { Q } from '@anthropic-ai/claude-agent-sdk';\nimport fs from 'node:fs';" })).toEqual([]); + expect(nonProductionImports(PKG, { 'lib/x.ts': "export { query } from '@anthropic-ai/claude-agent-sdk';" })).toEqual(['lib/x.ts: @anthropic-ai/claude-agent-sdk']); + expect(nonProductionImports(PKG, { 'lib/x.ts': "export * from 'typescript';" })).toEqual(['lib/x.ts: typescript']); + expect(nonProductionImports(PKG, { 'lib/x.ts': "export type { Q } from '@anthropic-ai/claude-agent-sdk';" })).toEqual([]); + // Generated by /ship Step 7 coverage audit (GSTA-97). + // Value: protects=the scan sees an import statement split over several lines; + // fails_when=importedPackages only matches one-line imports, so a multi-line devDependency import ships unflagged; + // why_new=both existing controls are one-line imports; seam=none; no_claim=none beyond protects + expect(nonProductionImports(PKG, { 'bin/x': "import {\n query,\n type SDKMessage,\n} from '@anthropic-ai/claude-agent-sdk';" })).toEqual(['bin/x: @anthropic-ai/claude-agent-sdk']); + const { ['@anthropic-ai/sdk']: _sdk, ...rest } = PKG.dependencies; + const files = runtimeFiles(); + expect(nonProductionImports({ dependencies: rest }, files)).toContain('bin/gstack-distill-free-text: @anthropic-ai/sdk'); + }); + + test('packages the build copies out of node_modules are production dependencies', () => { + const scripts: Record = PKG.scripts; + const run = [...read('scripts/build.sh').matchAll(/"\$BUN_CMD" run ([A-Za-z0-9:_-]+)/g)].map((m) => m[1]); + expect(run).toContain('vendor:xterm'); + const copied = run.flatMap((name) => [...(scripts[name] ?? '').matchAll(/node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g)].map((m) => m[1])); + expect(copied.sort()).toEqual(['xterm', 'xterm', 'xterm-addon-fit']); + for (const name of copied) expect(PKG.dependencies).toHaveProperty([name]); + }); + + // Generated by /ship Step 7 coverage audit (GSTA-97). + // Value: protects=a production-only install never creates the dev-toolchain marker install_runtime_deps reads, so a user's next ./setup stays production-only; + // fails_when=a production dependency, or a package it pulls in (required peers included), starts depending on the marker package, so every repeat ./setup goes full; + // why_new=the real-Bun rows use local fixture packages, and nothing walks the repo's own bun.lock production closure; + // seam=none; no_claim=Bun's actual hoisting or platform filtering (a static, name-based walk of bun.lock that counts every candidate entry) + test('the production closure in bun.lock never installs the dev-toolchain marker setup checks', () => { + const marker = sliceFunction(read('setup'), 'install_runtime_deps').match(/\[ -d node_modules\/([^\s\]]+) \]/)?.[1]; + expect(marker).toBeDefined(); + expect(PKG.devDependencies).toHaveProperty([marker!]); + type Lock = { workspaces: Record }>; packages: Record }; + const closure = (lock: Lock): Set => { + const seen = new Set(); + const queue = Object.keys(lock.workspaces[''].dependencies ?? {}); + while (queue.length) { + const name = queue.pop()!; + if (seen.has(name)) continue; + seen.add(name); + for (const [key, entry] of Object.entries(lock.packages)) { + if (key !== name && !(key.endsWith(`/${name}`) && key.slice(0, -name.length - 1) in lock.packages)) continue; + const meta = (entry.find((e) => e && typeof e === 'object' && !Array.isArray(e)) ?? {}) as Record; + const optionalPeers = new Set(meta.optionalPeers ?? []); + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const dep of Object.keys(meta[field] ?? {})) if (field !== 'peerDependencies' || !optionalPeers.has(dep)) queue.push(dep); + } + } + } + return seen; + }; + const lock: Lock = JSON.parse(read('bun.lock').replace(/,(\s*[}\]])/g, '$1')); + const production = closure(lock); + expect(production.has('playwright-core')).toBe(true); + expect(production.has(marker!)).toBe(false); + // Negative controls: a required peer on the marker is caught, through a nested entry too; an optional peer is not. + const synthetic = (meta: Record): Lock => ({ + workspaces: { '': { dependencies: { a: '1' } } }, + packages: { a: ['a@1', '', { dependencies: { b: '1' } }, 'sha'], b: ['b@1', '', {}, 'sha'], 'a/b': ['b@2', '', meta, 'sha'] }, + }); + expect(closure(synthetic({ peerDependencies: { [marker!]: '*' } })).has(marker!)).toBe(true); + expect(closure(synthetic({ peerDependencies: { [marker!]: '*' }, optionalPeers: [marker!] })).has(marker!)).toBe(false); + }); + + test('the dev toolchain stays out of the user install', () => { + for (const name of ['@anthropic-ai/claude-agent-sdk', 'typescript', 'prettier', '@types/bun']) { + expect(PKG.devDependencies).toHaveProperty([name]); + expect(PKG.dependencies).not.toHaveProperty([name]); + } + }); +}); diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index efd4ff0f0..ad36946a2 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -485,7 +485,7 @@ describe('setup --team / --no-team / -q', () => { fs.writeFileSync(path.join(commands, 'bun'), `#!/usr/bin/env bash [ "$1 $2" = '--no-env-file --config=/dev/null' ] && shift 2 case "$*" in - 'install --frozen-lockfile') exit 0 ;; + 'install --production --frozen-lockfile') exit 0 ;; 'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;; *'/bin/gstack-migrate-claude-code --install-dir '*) [[ "$#" -eq 5 && "$2" = --install-dir && "$4" = --skills-dir ]] || exit 90