12 KiB
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 everyinstall(_deleteStaleBrowsers). No pruner is written here.- The gstack submodule is intentionally dirty:
package.json+bun.lockcarry the BDR-029 bump;.gitmodulessetsignore = dirty. It also carries an untracked?? bin/bin. update-all.sh:87callsgit submodule update --remotebare, 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
-
lib/gstack-playwright.sh— NEW. Sourceable lib + verb dispatcher. Noset -euo pipefailat top level (mirrorslib/detect-plugins.sh). Dispatcher guarded by[ "${BASH_SOURCE[0]}" = "${0}" ], exposingbrowsers-reportONLY. Any other argument →usage:on stderr, exit 2. The write functions stay sourced-only: a CLI verb would exposebun add playwright@latestas a command-line entry point._gspw_ok/_gspw_warn/_gspw_info <msg>— fixed-prefix printers.gstack_pw_ostag [os_release_path]— printsubuntu<VERSION_ID>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 0always.gstack_pw_supports <playwright_core_lib_dir> <ostag>— 0/1 by grep.gstack_bump_playwright_if_unsupported <gstack_dir>— BDR-029 logic, parameterized. Prepends$HOME/.bun/binto PATH whenbunis not resolvable (LRN-036). Wraps ALL THREE bun invocations (bun install --frozen-lockfile, thebun installfallback,bun add playwright@latest) intimeout 300whencommand -v timeoutsucceeds, plain otherwise. Exit 124 from any of them →_gspw_warnandreturn 0WITHOUT attempting the bump: a TERM'd install leavesnode_moduleshalf-written, and the support grep would then read a truncated tree. One_gspw_infoline before the network work so a stalled registry is visible.return 0on every path (BDR-029 non-fatal).gstack_submodule_update_with_bump <repo> [sub_path]:git -C "$repo" submodule update --remote "$sub", stderr captured.- exit 0 →
gstack_bump_playwright_if_unsupported "$repo/$sub"→return 0. - exit != 0 →
_gspw_warnwith git's own message, verbatim and unparsed. Then, whengit -C "$sub" status --porcelain -- package.json bun.lockis non-empty, one extra_gspw_infohint line naming the local Playwright bump and pointing atmake 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 <playwright_core_path> <dir_name>— does that install require this cache directory? Splits<dir_name>into name + revision on the LAST-, then normalizes_→-on the name (Playwright writeschromium_headless_shell-1228whilebrowsers.jsonsayschromium-headless-shell; without this, two live directories are reported unreferenced forever). Matches the baserevisionOR any value under that browser'srevisionOverrides(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 <playwright_core_path>—<dir-before-node_modules> <version>, 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 value0means "bundle into node_modules", so0and any non-directory degrade to the silent no-cache path. Prints the headerPlaywright browsers, the total fromdu -sh … || true, one line per cache dir matching*-<digits>with the installs requiring it, then<N> unreferenced, <M> broken link(s). When N or M > 0, one_gspw_warnnaming them and the remedy, phrased without the wordsrmormv(criterion 6 word-greps the source): "re-runplaywright install, which prunes stale revisions". A dir whose NAME is listed by some install but at another revision counts asunknown revision, not unreferenced.return 0on every path.
-
install-plugins.sh— delete the inline function (294-321), source the lib next to detect-plugins (line 30), call site at ~370 becomesgstack_bump_playwright_if_unsupported "$GSTACK_DIR". -
update-all.sh— source the lib next to detect-plugins (line 19). Line 87 becomesif gstack_submodule_update_with_bump "$REPO"; thenand the existingelse warn …arm is KEPT verbatim. No other structural change. -
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 asgstack_browsers_report || true. -
lib/tests/gstack-playwright.test.sh— NEW, auto-globbed bymake 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 killdoctor.shbefore its summary and takeupdate-all.sh:519down with it. .linksentry whose target is gone or whosebrowsers.jsonis 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.<name>.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 underset -euo pipefailwith 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'spackage.jsonplus 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 literalupdate-conflict(contract criterion 4).T8-no-destructive-command: greps the lib source forgit checkout|reset|clean|stashand forrm|rmdir|unlink|truncate|mvoutside comments. Stronger than criterion 6 alone.T9-report-referenced,T10-report-underscore-dir(chromium_headless_shell-1228against achromium-headless-shellentry),T11-report-unreferenced,T12-report-broken-link,T13-report-revision-override: fixture cache +.links→ fixture playwright-core dirs with hand-writtenbrowsers.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:
|| trueon the ostag capture (a latent abort on every non-Ubuntu host, reproduced), and thetimeoutguard. - 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_SANDBOXis untouched. - honors LRN-085 by keeping the update idempotent, presence-guarded, no
--force. - honors LRN-036 by putting
$HOME/.bun/binon 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.