# PLAN — gstack-playwright-lib (feat) — REVISION 3 Contract: `.claude/tasks/contracts/2026-09-13-gstack-playwright-lib-2220.md` Revision 3 (2026-09-15). Rev 1 → 3-lens challenge → rev 2 → confirmation pass → rev 3. The conflict-RECOVERY branch is WITHDRAWN at the human gate: it concentrated 3 BLOCKERs and 4 MAJORs, and its worst case regressed a working browser into BLK-008, which the pre-existing behavior never did. ## Context - Chromium is not an apt package. It is the browser revision pinned by the installed Playwright (`gstack/setup:483`). gstack: playwright 1.61.1 → chromium rev 1228 (`Chrome for Testing 149`). - `~/.cache/ms-playwright/.links/` registers 3 playwright installs: gstack 1.61.1 (1228), gsd-pi nvm 1.61.0 (1228), gsd-pi ~/.local 1.63.0 (1243). Every dir on disk is referenced → 0 bytes reclaimable. Playwright already prunes correctly on every `install` (`_deleteStaleBrowsers`). No pruner is written here. - The gstack submodule is intentionally dirty: `package.json` + `bun.lock` carry the BDR-029 bump; `.gitmodules` sets `ignore = dirty`. It also carries an untracked `?? bin/bin`. - `update-all.sh:87` calls `git submodule update --remote` bare, swallows stderr, and never re-applies the bump afterwards. THAT is the gap. ## Hard constraints the code must respect **Inherited errexit.** All three callers run `set -euo pipefail` and source the lib. `gstack_bump_playwright_if_unsupported` and `gstack_browsers_report` are called as bare statements, so they MUST `return 0` on every path and every capture inside them takes `|| true`. `gstack_submodule_update_with_bump` is the ONE exception: it returns non-zero on failure and is therefore called ONLY as an `if` condition, keeping `update-all.sh:87`'s existing `if / else warn` shape. An offline update stays non-fatal, exactly as today. **Printer names.** The lib defines `_gspw_ok`, `_gspw_warn`, `_gspw_info` and NEVER a bare `ok`/`warn`/`info`/`pass`/`fail`. `doctor.sh:22` sources the lib before every check, so bare names would override `doctor.sh:12-15` and silently disconnect its `ERRORS`/`WARNS` counters. **No destructive command in the lib, at all.** No `rm`, `rmdir`, `unlink`, `truncate`, `mv`, and no `git checkout`/`reset`/`clean`/`stash`. Contract criteria 4 and 6 both grep for this. **macOS-safe.** No `timeout` without a `command -v` guard (absent from stock macOS), no `readlink -f` (absent before Monterey 12.3), no `md5sum`, no `sed -i` without a suffix, no bash-4-only expansions (`${x,,}`), no `grep -P`. ## Files 1. `lib/gstack-playwright.sh` — NEW. Sourceable lib + verb dispatcher. No `set -euo pipefail` at top level (mirrors `lib/detect-plugins.sh`). Dispatcher guarded by `[ "${BASH_SOURCE[0]}" = "${0}" ]`, exposing `browsers-report` ONLY. Any other argument → `usage:` on stderr, exit 2. The write functions stay sourced-only: a CLI verb would expose `bun add playwright@latest` as a command-line entry point. - `_gspw_ok` / `_gspw_warn` / `_gspw_info ` — fixed-prefix printers. - `gstack_pw_ostag [os_release_path]` — prints `ubuntu` for Ubuntu, nothing otherwise. The capture takes `|| true`: the moved line exits 1 on every non-Ubuntu host and would abort the caller under inherited errexit. `return 0` always. - `gstack_pw_supports ` — 0/1 by grep. - `gstack_bump_playwright_if_unsupported ` — BDR-029 logic, parameterized. Prepends `$HOME/.bun/bin` to PATH when `bun` is not resolvable (LRN-036). Wraps ALL THREE bun invocations (`bun install --frozen-lockfile`, the `bun install` fallback, `bun add playwright@latest`) in `timeout 300` when `command -v timeout` succeeds, plain otherwise. Exit 124 from any of them → `_gspw_warn` and `return 0` WITHOUT attempting the bump: a TERM'd install leaves `node_modules` half-written, and the support grep would then read a truncated tree. One `_gspw_info` line before the network work so a stalled registry is visible. `return 0` on every path (BDR-029 non-fatal). - `gstack_submodule_update_with_bump [sub_path]`: 1. `git -C "$repo" submodule update --remote "$sub"`, stderr captured. 2. exit 0 → `gstack_bump_playwright_if_unsupported "$repo/$sub"` → `return 0`. 3. exit != 0 → `_gspw_warn` with git's own message, verbatim and unparsed. Then, when `git -C "$sub" status --porcelain -- package.json bun.lock` is non-empty, one extra `_gspw_info` hint line naming the local Playwright bump and pointing at `make plugin`. `return 1`. NOTHING in the working tree is touched. No locale pin is needed: git's message is displayed, never parsed for a decision. The hint is advisory, so its constant-true condition is correct here, unlike the withdrawn recovery branch where it gated a destructive step. - `_gspw_browser_referenced ` — does that install require this cache directory? Splits `` into name + revision on the LAST `-`, then normalizes `_` → `-` on the name (Playwright writes `chromium_headless_shell-1228` while `browsers.json` says `chromium-headless-shell`; without this, two live directories are reported unreferenced forever). Matches the base `revision` OR any value under that browser's `revisionOverrides` (webkit and ffmpeg carry them for mac and debian11 and ubuntu20.04 hosts). awk only: no jq, no python3, no fallback ladder. - `_gspw_install_label ` — ` `, e.g. `gstack 1.61.1`, `gsd-pi 1.63.0`. - `gstack_browsers_report [cache_dir]` — read-only. Resolves the cache as `${1:-${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}}`; the documented value `0` means "bundle into node_modules", so `0` and any non-directory degrade to the silent no-cache path. Prints the header `Playwright browsers`, the total from `du -sh … || true`, one line per cache dir matching `*-` with the installs requiring it, then ` unreferenced, broken link(s)`. When N or M > 0, one `_gspw_warn` naming them and the remedy, phrased without the words `rm` or `mv` (criterion 6 word-greps the source): "re-run `playwright install`, which prunes stale revisions". A dir whose NAME is listed by some install but at another revision counts as `unknown revision`, not unreferenced. `return 0` on every path. 2. `install-plugins.sh` — delete the inline function (294-321), source the lib next to detect-plugins (line 30), call site at ~370 becomes `gstack_bump_playwright_if_unsupported "$GSTACK_DIR"`. 3. `update-all.sh` — source the lib next to detect-plugins (line 19). Line 87 becomes `if gstack_submodule_update_with_bump "$REPO"; then` and the existing `else warn …` arm is KEPT verbatim. No other structural change. 4. `doctor.sh` — source the lib next to detect-plugins (line 22). Add its own `── Playwright browsers ──` section (NOT nested under gstack: 2 of the 3 registered installs are gsd-pi), called as `gstack_browsers_report || true`. 5. `lib/tests/gstack-playwright.test.sh` — NEW, auto-globbed by `make test`. ## Edge cases - Every public function except the update returns 0 under the callers' `set -euo pipefail`, including the all-zero-counts case, which is this machine's nominal state and would otherwise kill `doctor.sh` before its summary and take `update-all.sh:519` down with it. - `.links` entry whose target is gone or whose `browsers.json` is unreadable → counted as a broken link, never dereferenced further. - Two installs of the same tool at different versions → both labels listed. - gstack submodule absent → bump and update both no-op 0. - Sourcing the lib prints nothing and does not change the caller's options. ## Tests (`lib/tests/gstack-playwright.test.sh`) Shape of `lib/tests/fast-libs.test.sh` (`check` helper, `PASS=n FAIL=n` last line, `mktemp -d` + trap). git 2.53 defaults `protocol.file` to `user`, which blocks submodule clone and fetch. The fixture git calls are not enough: the `git submodule update --remote` under test runs INSIDE the lib, in a fresh process. So the test exports, for the whole test process, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=protocol.file.allow GIT_CONFIG_VALUE_0=always`, which the lib's own git inherits. Fixtures use `git init -b main` with `submodule..branch = main` set explicitly, so `--remote` resolves the way production does. - `T1-ostag-ubuntu` / `T2-ostag-other`: fixture os-release files. - `T3-errexit-safe`: the bump called as a bare statement under `set -euo pipefail` with a non-Ubuntu os-release → the script reaches the next line. Regression test for the latent abort. - `T4-supports-hit` / `T5-supports-miss`: fixture lib dir with and without the tag. Proves idempotence both ways without invoking bun. - `T6-update-success-bumps`: fixture superproject + submodule, an upstream commit, bump stubbed by redefining it after sourcing → update succeeds, the stub ran once, returns 0. - `T7-update-conflict-nondestructive`: local edit to the submodule's `package.json` plus a conflicting upstream commit → returns non-zero, BOTH bump-owned files are byte-identical to before the call, and the hint line was printed. Carries the literal `update-conflict` (contract criterion 4). - `T8-no-destructive-command`: greps the lib source for `git checkout|reset|clean|stash` and for `rm|rmdir|unlink|truncate|mv` outside comments. Stronger than criterion 6 alone. - `T9-report-referenced`, `T10-report-underscore-dir` (`chromium_headless_shell-1228` against a `chromium-headless-shell` entry), `T11-report-unreferenced`, `T12-report-broken-link`, `T13-report-revision-override`: fixture cache + `.links` → fixture playwright-core dirs with hand-written `browsers.json`. - `T14-report-zero-counts-exit-0`: everything referenced → exit 0. The nominal case, not covered by the absent-dir case. - `T15-report-no-cache` / `T16-report-browsers-path-zero`: exit 0, nothing on stderr. - `T17-source-safe`: sourcing emits nothing. ## Disposition (STEP 0.6) - honors **BDR-029** by keeping the bump OS-gated, idempotent, non-fatal, and by closing its stated caveat: the bump is now re-applied after every successful update, not only at the next `make plugin`. - honors **LRN-024** by extracting a helper and refactoring the existing caller before adding the other callers. Deviations from "code MOVED not changed" are named: `|| true` on the ostag capture (a latent abort on every non-Ubuntu host, reproduced), and the `timeout` guard. - honors **LRN-070** by never touching the submodule working tree at all. The revision that did (discard, retry, restore) was withdrawn at the gate. - honors **LRN-071** (recurrent 3x) by returning the update's real status, not a non-fatal helper's 0. - honors **LRN-040** by touching layer 1 only; `GSTACK_CHROMIUM_NO_SANDBOX` is untouched. - honors **LRN-085** by keeping the update idempotent, presence-guarded, no `--force`. - honors **LRN-036** by putting `$HOME/.bun/bin` on PATH inside the lib. - honors **LRN-002** by grepping the moved function name repo-wide, readers included. - **LRN-038** already seen: the host-platform override is a dead end. - BDR-029's reference line (`decisions.md:544`) and BLK-008's caveat (`blockers.md:118`) describe behavior this plan changes. Registries are append-only, so the plan does NOT edit them: the /feat CAPITALIZE step owns the superseding entry.