diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index f1c0bd5..a1eba46 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -97,6 +97,8 @@ rules: | BDR-085 | 2026-08-25 | User permanent rules: writing-style always-on in rules/, web build+security path-scoped | accepted | | BDR-086 | 2026-08-26 | darwin: threshold gates full loops; verified defects fixed regardless of unit score (paired-validated, batched checkpoint) | accepted | | BDR-087 | 2026-09-03 | Stop hook = attention signal only, never control flow; one script for Notification + Stop | accepted | +| BDR-088 | 2026-09-15 | gstack Playwright bump shared via lib, re-applied after submodule update; update helper never touches the submodule tree | accepted | +| BDR-089 | 2026-09-15 | No Playwright browser-cache pruner; read-only doctor report — .links proved 0 bytes reclaimable | accepted | --- @@ -1123,3 +1125,24 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate). - **Guard vs prior refusal**: [[BDR-083]] (unlazy review, GATE 0) REFUSED a Stop hook using `decision:"block"` (forces continuation, inverts human gates). THIS Stop hook returns `terminalSequence` + `suppressOutput` only, exit 0, zero control-flow effect. Signal ≠ control. Do not read the refusal as banning Stop outright. - **Status**: accepted. - **Reference**: [[LRN-146]] event-coverage gap, [[BLK-020]] client-side faults, [[LRN-145]] terminalSequence pattern. Verified live: turn-end + AskUserQuestion both ring; `permission_prompt` unexercisable under `defaultMode: auto`. + +--- + +## BDR-088 — gstack Playwright bump shared via lib; update helper never touches submodule tree +- **Date**: 2026-09-15 +- **Decision**: `gstack_bump_playwright_if_unsupported` moved out of `install-plugins.sh` into `lib/gstack-playwright.sh`, sourced by install-plugins + update-all + doctor. update-all's submodule block now calls `gstack_submodule_update_with_bump`: re-applies bump after successful `submodule update --remote`; on failure prints git's own message, hints `make plugin`, returns 1. Never touches submodule worktree. +- **Why**: [[BDR-029]] caveat open — bump survived only till next `make plugin`, update path never re-checked OS support. Real gap, user-reported. +- **Alternatives rejected**: conflict-RECOVERY branch (discard package.json+bun.lock → retry → backup/restore). Withdrawn at human gate after 4-agent challenge: concentrated 3 BLOCKER + 4 MAJOR. Worst case = bump discarded, re-apply silently no-ops (bun absent / registry down — bump returns 0 on every path), `./setup` rebuilds browse against unsupported Playwright → [[BLK-008]] returns. Pre-existing behavior just failed the update and kept bump intact, so the "improvement" could regress a working install. +- **Deviations carried from "code MOVED not changed"**: `|| true` on ostag capture (line exited 1 on every non-Ubuntu host → aborted caller under inherited errexit, reproduced); `timeout` on all 3 bun calls, exit 124 → warn + no bump (TERM'd install leaves node_modules half-written, poisons the support grep). +- **Status**: accepted. +- **Reference**: commit 2cebecb, `lib/gstack-playwright.sh`. Links [[BDR-029]], [[LRN-070]], [[LRN-071]], [[LRN-150]], [[BLK-008]]. + +--- + +## BDR-089 — No Playwright browser-cache pruner; read-only doctor report instead +- **Date**: 2026-09-15 +- **Decision**: `doctor.sh` gains own `── Playwright browsers ──` section — cache size, per-revision the installs requiring it, counts of unreferenced dirs + broken links. Zero deletion anywhere in the lib. +- **Why**: measured, not assumed. `~/.cache/ms-playwright/.links/` registers 3 installs — gstack 1.61.1 → rev 1228, gsd-pi nvm 1.61.0 → 1228, gsd-pi ~/.local 1.63.0 → 1243. Every dir on disk referenced → 0 bytes reclaimable. Playwright's own `_deleteStaleBrowsers` already unions across all registered installs on every `install`. +- **Alternatives rejected**: hand-rolled pruner guarded on "revision resolved by gstack's local playwright" (the originally requested shape) — that guard keeps 1228 and DELETES 1243, breaking gsd-pi. The guard was wrong, not just its implementation. +- **Status**: accepted. +- **Reference**: commit 2cebecb. Links [[LRN-151]], [[BDR-088]]. diff --git a/.claude/memory/evals.md b/.claude/memory/evals.md index 814446a..f1c382d 100644 --- a/.claude/memory/evals.md +++ b/.claude/memory/evals.md @@ -39,6 +39,7 @@ rules: | EVAL-025 | 2026-07-17 | opening seo/geo inventory (subagents): 7/7 verifiable claims false or overstated; real contact corrected all, 6 plan corrections + 4 features killed at measurement | keep | | EVAL-027 | 2026-08-24 | contract-gates behavioral RED: 16/16 fresh unprimed runs followed new doctrine (GATE 0 order, vacuous oracle, ABANDONED routing, scope temptation resisted) | keep | | EVAL-028 | 2026-08-26 | darwin v2.1 paired run 54 units: 60 paired verdicts 0 revert/tie; skeptics found 3 real residuals — engaged, not rubber-stamp | keep | +| EVAL-029 | 2026-09-15 | 4-agent plan challenge: 6 BLOCKER; 3 of 3 confirmation-pass BLOCKERs came from the fixes themselves; caught a false 654 MB orphan claim | keep | --- @@ -267,3 +268,14 @@ Dogfood: 3 blind lenses attacked the v1 plan for the plan-challenge feature itse - **Method**: paired same-judge 3-majority per round (v2.1); judges live-exec where artifact executable (5 units: skills-perso, profile, plugin-pair, status-reporter, gitflow). Absolute scores triage-only. Totals main-thread (LRN-018 applied). - **Anomalies**: (1) 0 reverts/ties in 60 verdicts — homogeneous-better checked: skeptic lens found real residuals 3x (doctor.sh cost source, hotfix RULES leftover restore, FILE(S) new-marker) → judges engaged. (2) census lock RED on line-rewrap, make test caught → LRN-144. (3) head-pipe masked grep exit 2x → LRN-143. - **Action**: v2.1 paired = standard. Post-run absolute rescore skipped by design (would be judge-noise theater). + +--- + +## EVAL-029 — 4-agent plan challenge: 6 BLOCKERs, and the fix round produced 3 of them +- **Date**: 2026-09-15 +- **Method**: 3 blind lenses (correctness / robustness / simplicity) on plan rev 1, then 1 confirmation lens on rev 2. Subject = the gstack Playwright lib plan ([[BDR-088]]). +- **Result**: rev 1 → 3 BLOCKER + 12 MAJOR. Rev 2, written specifically to close them → 3 NEW BLOCKERs, and 2 of the 3 were INTRODUCED BY the fixes: the new "every public function returns 0" rule contradicted the new "return rc", and the printer-name clause came verbatim from my own contract criterion 9. Rev 3 dropped the recovery branch entirely at the human gate — 6 findings closed by deletion instead of code. +- **Anomaly**: my first user-facing answer asserted ~654 MB of orphan Playwright revisions. FALSE — `.links` showed every dir referenced, 0 reclaimable. Caught only while designing the guard, not while asserting the number. Worse, the guard I proposed would itself have deleted gsd-pi's rev 1243. +- **Action**: (1) never state a disk-reclaimable figure before reading the registry that owns it ([[LRN-151]]). (2) A fix round deserves the same challenge as the original plan — 3/3 confirmation BLOCKERs came from fixes, not from the original. (3) The confirmation pass earned its cost: without it the printer override would have shipped and silently disconnected doctor's counters ([[LRN-150]]). +- **Status**: keep. +- **Reference**: `.claude/tasks/plans/2026-09-13-gstack-playwright-lib-2220.md` (rev 3). Links [[BDR-088]], [[LRN-150]]. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index e7b64d3..bdff5fa 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -460,3 +460,5 @@ rules: - Post-merge regression: toast dead again after re-attach from a RESTORED terminal, bell fine. Root cause [[LRN-147]]: ext hooks only terminals born after its activation; `enablePersistentSessions` restores terminals before it. Fix = disable persistent sessions, or fresh terminal + `dtach -a`. Verified: 3/3 toasts on fresh pty. - Same-day counter-example broke that cause: second session's terminal deaf though created LATER, same window, ext global, shells identical. Trigger unknown; [[LRN-148]] adds the 5s pre-flight test + demotes LRN-147's mechanism claim. - Attention signal refined: per-event labels (BDR-087 follow-on), silence on non-attention events, and no turn-end signal while `background_tasks` non-empty ([[LRN-149]]). Payload dump beat the docs: `background_tasks` undocumented for Stop but present on the wire. Branch bugfix/notify-subagent-spawn. +- gstack Playwright: bump extracted to `lib/gstack-playwright.sh`, now re-applied after a successful submodule update ([[BDR-088]]); read-only browsers report in doctor, no pruner — `.links` proved 0 bytes reclaimable and the guard I first proposed would have deleted gsd-pi's rev 1243 ([[BDR-089]], [[LRN-151]]). 4 challengers → 6 BLOCKER, recovery branch withdrawn at the gate ([[EVAL-029]]). 2cebecb on feature/gstack-playwright-lib. +- Node checked against Playwright: already v24 (1.61 needs >=18, 1.63 needs >=20), not the macOS constraint. macOS audit deferred to its own cycle — found statically: `sed -i` with no suffix x3 in install-plugins.sh (BSD sed eats the next arg), `${x,,}` in url-guard.sh (bash 4+, macOS ships 3.2), `readlink -f` in doctor.sh (absent pre-Monterey 12.3). diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index dcf98b2..a98a94f 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -139,6 +139,9 @@ rules: | LRN-134 | 2026-07-17 | resolve-then-pin in stdlib http.client beats monkeypatching getaddrinfo — dual-stack, thread-safe, no requests; classify the OS-resolved IP not the URL text | closing SSRF/DNS-rebinding on any Python HTTP egress | | LRN-135 | 2026-07-17 | a prefix-only scan for a dangerous construct is bypassable by padding — scan the WHOLE document | refusing any hostile construct (DTD/directive/marker) before parse | | LRN-143 | 2026-08-26 | `cmd \| head \|\| fallback` — pipeline rc is head's (0), fallback dead; bounded output → drop head, else pipefail | any probe/fallback bash in skills before trusting `\|\|` | +| LRN-150 | 2026-09-15 | Sourced lib shares caller shell: bare `ok/warn/info` override its printers, and its `set -e` applies inside | any new lib/*.sh | +| LRN-151 | 2026-09-15 | Playwright cache truth = union over `.links`, dir name maps `_`→`-`, revisionOverrides exist | shared versioned binary caches | +| LRN-152 | 2026-09-15 | git `protocol.file=user` blocks submodule fixtures; `-c` misses the code under test, `GIT_CONFIG_*` env does not | tests building git fixtures | --- @@ -1421,3 +1424,29 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s - **Fail-open**: field absent (older client) → still signal. Missed notification worse than extra one. - **Cross-session gotcha**: hook is user-scope, so EVERY session runs it. A single-file dump (`> file`) gets overwritten by another project's session — append JSONL and filter on `.cwd`. That accident proved `permission_prompt` fires with `message="Claude needs your permission"` (unexercisable in this session under `defaultMode: auto`). - **Future**: any hook needing turn-completion semantics must check background_tasks; "turn ended" ≠ "work done". Verified live: Stop with 0 tasks signals, Stop with 1 running subagent silent. + +--- + +## LRN-150 — Sourced shell lib is not a subprocess: prefix printers, honor inherited errexit +- **Date**: 2026-09-15 +- **Pattern**: `source lib.sh` shares the caller's shell. Two bites. (a) bare `ok()`/`warn()`/`info()` in the lib OVERRIDE the caller's same-named funcs. `doctor.sh` counts ERRORS/WARNS inside its own `warn()` → a lib `warn` disconnects the counter and doctor prints "No errors" while warnings scroll. Prefix every lib printer (`_gspw_ok`, `_gspw_warn`, `_gspw_info`). (b) caller's `set -euo pipefail` applies INSIDE the lib's functions: a failing command-substitution assignment (`x="$(. /etc/os-release; [ "$ID" = ubuntu ] && printf ...)"`) aborts the CALLER when the func is called as a bare statement. Reproduced — exit 1 on every non-Ubuntu host, latent in `install-plugins.sh` since [[BDR-029]]. +- **Rule**: public func called bare → `return 0` on every path + `|| true` on every capture. Func allowed to return non-zero → call it ONLY as an `if` condition. +- **Future application**: any new `lib/*.sh` sourced by a script that owns printers or sets `-e`. Check BOTH facets before wiring; the printer one is silent (no error, just a lying summary). +- **Reference**: `lib/gstack-playwright.sh`, `doctor.sh:12-15`. Links [[BDR-088]]. + +--- + +## LRN-151 — Playwright cache truth lives in `.links`, never in one install's view +- **Date**: 2026-09-15 +- **Pattern**: `~/.cache/ms-playwright/.links/` = one file per registered `playwright-core`, content = its path. Required set = UNION of `browsers.json` revisions across ALL of them. Dir name on disk = `${name//-/_}-${revision}`: `chromium-headless-shell` → `chromium_headless_shell-1228`. Miss that mapping and 2 live dirs read as orphan forever. `revisionOverrides` exists (webkit, ffmpeg on mac / debian11 / ubuntu20.04) so the base revision alone under-matches. Playwright prunes this set itself on every `install` (`_deleteStaleBrowsers`, coreBundle.js). +- **Future application**: never call a browser dir orphan from one project's playwright view — read `.links` first. Generalizes to any tool with a shared versioned binary cache plus a registry of consumers: the consumer registry is the source of truth, not the consumer you happen to be standing in. +- **Reference**: `lib/gstack-playwright.sh` `_gspw_browser_referenced`. Links [[BDR-089]], [[EVAL-029]]. + +--- + +## LRN-152 — git `protocol.file=user` kills submodule fixtures; `-c` misses the code under test +- **Date**: 2026-09-15 +- **Pattern**: since the CVE-2022-39253 hardening git refuses submodule clone/fetch over a local path by default (git 2.53 → `protocol.file` = `user`). `-c protocol.file.allow=always` fixes the FIXTURE's own git calls but NOT the `git` the code under test spawns — fresh process, inherits nothing from `-c`. Export for the whole test process instead: `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=protocol.file.allow GIT_CONFIG_VALUE_0=always`. Env propagates, `-c` does not. +- **Also**: fixture repos need LOCAL `user.email`/`user.name` (no global identity here) and `git init -b main` + explicit `submodule..branch`, else `--remote` resolves a different branch than production does. +- **Future application**: any test building a git submodule fixture. Symptom is a hard "transport 'file' not allowed" before the first assertion, which reads like a broken test rather than a policy. +- **Reference**: `lib/tests/gstack-playwright.test.sh`. diff --git a/.claude/tasks/contracts/2026-09-13-gstack-playwright-lib-2220.md b/.claude/tasks/contracts/2026-09-13-gstack-playwright-lib-2220.md new file mode 100644 index 0000000..985a8bb --- /dev/null +++ b/.claude/tasks/contracts/2026-09-13-gstack-playwright-lib-2220.md @@ -0,0 +1,115 @@ +# CONTRACT — gstack-playwright-lib +- date: 2026-09-13 | flow: feat | branch: feature/gstack-playwright-lib +- status: active + +## REQUEST (verbatim — IMMUTABLE) + +Message 1: +> l'installation de chromium, c'est une version fix ou en latest ? Il faudrait mettre en lateste, et d'ailleurs son update est pris en compt dans l'update ? quelq version a besoin gstack ? Ca serait pas plus simple d'installer perplexity a la place ? + +Message 2 (after the assistant proposed fix A + fix B): +> les deux + +Message 3 (answer to the scope question on fix B, after the "654 Mo orphelins" +premise was proven wrong): +> Check read-only dans doctor.sh + +## CLARIFICATIONS + +Q: "mettre en latest" — pin Chromium to latest? +A: Not actionable as asked. Playwright downloads the browser revision its own + version pins (1.61.1 → chromium 1228); the CDP client is coupled to that + build. "Latest" = track the latest Playwright, which is what BDR-029's bump + already does. No change to the pinning mechanism is in scope. + +Q: Volet B — purge the orphan Playwright revisions? +A: Superseded by evidence. `~/.cache/ms-playwright/.links/` registers THREE + playwright installs (gstack 1.61.1 → rev 1228; gsd-pi nvm 1.61.0 → 1228; + gsd-pi ~/.local 1.63.0 → 1243). Every directory on disk is referenced; + zero bytes reclaimable. Playwright already GCs correctly on every + `install` (`_deleteStaleBrowsers`, unions across all registered installs). + User chose: read-only report in doctor.sh, NO deletion anywhere. + +## ACCEPTANCE CRITERIA + +1. `lib/gstack-playwright.sh` exists, is source-safe (sourcing prints nothing + and runs no side effect), and its verb dispatcher works when executed. + CHECK: out=$( . lib/gstack-playwright.sh; echo READY ); [ "$out" = READY ] && bash lib/gstack-playwright.sh 2>&1 | grep -q 'usage:' && echo LIB_OK + EXPECT: LIB_OK + EVIDENCE: MET exit=0 marker-found :: LIB_OK + +2. The bump logic lives ONLY in the lib: `install-plugins.sh` no longer + defines `gstack_bump_playwright_if_unsupported`, sources the lib instead, + and still calls it BEFORE gstack `./setup` (BDR-029 behavior unchanged: + OS-gated, idempotent, non-fatal). + CHECK: grep -q '^gstack_bump_playwright_if_unsupported() {' install-plugins.sh && exit 1; grep -q 'lib/gstack-playwright.sh' install-plugins.sh || exit 1; c=$(grep -n 'gstack_bump_playwright_if_unsupported' install-plugins.sh | grep -v ':[[:space:]]*#' | tail -1 | cut -d: -f1); s=$(grep -n '&& \./setup)' install-plugins.sh | head -1 | cut -d: -f1); [ -n "$c" ] && [ -n "$s" ] && [ "$c" -lt "$s" ] && echo EXTRACT_OK + EXPECT: EXTRACT_OK + EVIDENCE: MET exit=0 marker-found :: EXTRACT_OK + +3. `update-all.sh` delegates the gstack submodule update to the lib + (`gstack_submodule_update_with_bump`) instead of calling + `git submodule update --remote` bare, so the bump is re-applied after every + successful update. + CHECK: grep -q 'gstack_submodule_update_with_bump' update-all.sh && grep -q 'lib/gstack-playwright.sh' update-all.sh && ! grep -qE '^[[:space:]]*if git submodule update --remote skills-external/gstack' update-all.sh && echo WIRED_OK + EXPECT: WIRED_OK + EVIDENCE: MET exit=0 marker-found :: WIRED_OK + +4. [gated 2026-09-15] `gstack_submodule_update_with_bump` NEVER modifies the + submodule working tree. On a successful `git submodule update --remote` it + re-applies the bump; on failure it returns non-zero, touches nothing, and + emits git's own message plus a hint naming the local Playwright bump when + `package.json`/`bun.lock` are the dirty files. The conflict-RECOVERY branch + of the earlier revision (discard, retry, backup, restore) is withdrawn: it + could leave the bump discarded and un-reapplied, regressing a working + browser into BLK-008, which the pre-existing behavior never did. + CHECK: sed 's/#.*//' lib/gstack-playwright.sh | grep -qE 'git [^|;]*(checkout|reset|clean|stash)' && exit 1; bash lib/tests/gstack-playwright.test.sh 2>&1 | grep -q 'update-conflict' && bash lib/tests/gstack-playwright.test.sh 2>&1 | grep -qE '^PASS=[0-9]+ FAIL=0$' && echo NONDESTRUCTIVE_OK + EXPECT: NONDESTRUCTIVE_OK + EVIDENCE: MET exit=0 marker-found :: NONDESTRUCTIVE_OK + +5. `doctor.sh` prints a Playwright-browsers section: total cache size, one line + per browser directory naming the registered playwright install(s) that + reference it, plus counts of unreferenced directories and broken links. + CHECK: bash doctor.sh 2>/dev/null | grep -qi 'playwright browsers' && bash lib/gstack-playwright.sh browsers-report | grep -qE 'chromium-[0-9]+' && bash lib/gstack-playwright.sh browsers-report | grep -qi 'unreferenced' && echo REPORT_OK + EXPECT: REPORT_OK + EVIDENCE: MET exit=0 marker-found :: REPORT_OK + +6. The report is provably read-only: no destructive verb anywhere in the lib, + and the cache directory listing is identical before and after a report run. + CHECK: sed 's/#.*//' lib/gstack-playwright.sh | grep -qwE '(rm|rmdir|unlink|truncate|mv)' && exit 1; b=$(ls -la ~/.cache/ms-playwright ~/.cache/ms-playwright/.links 2>/dev/null | cksum); bash lib/gstack-playwright.sh browsers-report >/dev/null 2>&1; a=$(ls -la ~/.cache/ms-playwright ~/.cache/ms-playwright/.links 2>/dev/null | cksum); [ "$b" = "$a" ] && echo READONLY_OK + EXPECT: READONLY_OK + EVIDENCE: MET exit=0 marker-found :: READONLY_OK + +7. `lib/tests/gstack-playwright.test.sh` exists, passes, and covers at least: + bump skipped when the OS tag is already supported; bump fired when it is + not; submodule-update conflict recovery; browsers-report on a fixture cache + holding a referenced revision, an unreferenced one and a broken link. + CHECK: bash lib/tests/gstack-playwright.test.sh | tail -1 | grep -qE '^PASS=[0-9]+ FAIL=0$' && echo TESTS_OK + EXPECT: TESTS_OK + EVIDENCE: MET exit=0 marker-found :: TESTS_OK + +8. shellcheck clean on every touched shell file. + CHECK: shellcheck lib/gstack-playwright.sh lib/tests/gstack-playwright.test.sh install-plugins.sh update-all.sh doctor.sh >/dev/null 2>&1 && echo SHELLCHECK_OK + EXPECT: SHELLCHECK_OK + EVIDENCE: MET exit=0 marker-found :: SHELLCHECK_OK + +9. [gated 2026-09-15] (judgement) No new dependency; the report degrades + silently when `~/.cache/ms-playwright` is absent, when its `.links` + directory is absent, when `PLAYWRIGHT_BROWSERS_PATH` is `0` or not a + directory, or when no playwright install is registered — doctor must stay + green on a machine that never installed a browser. The lib's printers are + named `_gspw_ok`/`_gspw_warn`/`_gspw_info` and it defines NO bare + `ok`/`warn`/`info`/`pass`/`fail`: doctor.sh sources the lib before every + check, so bare names would override its own printers and silently + disconnect its `ERRORS`/`WARNS` counters. + +## FILE SCOPE + +- lib/gstack-playwright.sh (new) +- lib/tests/gstack-playwright.test.sh (new) +- install-plugins.sh (remove inline fn, source + call lib) +- update-all.sh (call bump + conflict recovery) +- doctor.sh (new read-only report section) + +Out of scope: the gstack submodule itself, the pinning mechanism, any +deletion of cached browsers, the `GSTACK_CHROMIUM_NO_SANDBOX` layer +(LRN-040 layer 2, unchanged). diff --git a/.claude/tasks/plans/2026-09-13-gstack-playwright-lib-2220.md b/.claude/tasks/plans/2026-09-13-gstack-playwright-lib-2220.md new file mode 100644 index 0000000..5f4e8d5 --- /dev/null +++ b/.claude/tasks/plans/2026-09-13-gstack-playwright-lib-2220.md @@ -0,0 +1,201 @@ +# 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index edb9a9b..48837b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- **`make doctor` reports the Playwright browser cache** — a read-only + `Playwright browsers` section listing cache size, which registered + Playwright install requires each cached browser revision, and counts of + unreferenced directories and broken links. Report only: nothing is + pruned, since Playwright's own `install` already unions the required set + across every registered install. +- `lib/gstack-playwright.sh` — the gstack Playwright helpers as a shared + lib (OS-support bump, submodule-update wrapper, cache report), sourced by + `install-plugins.sh`, `update-all.sh` and `doctor.sh`, covered by + `lib/tests/gstack-playwright.test.sh`. + +### Fixed +- **`make update` no longer drops the Playwright OS-support bump** — a + gstack submodule update used to leave the bump unapplied until the next + `make plugin`, the open caveat of BDR-029. `update-all.sh` now goes + through `gstack_submodule_update_with_bump`, which re-applies it after a + successful update and returns non-zero on failure so the existing warn + arm still fires. Two latent bugs travelled with the extracted code: the + ostag capture exited 1 on every non-Ubuntu host and aborted its caller + under inherited `errexit`, and the `bun` calls had no timeout. + ## [1.5.0] — 2026-09-13 ### Added diff --git a/README.md b/README.md index e0d1b1a..5bb6375 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ make profile-reset # re-enable all gstack skills make new-skill name=myskill # scaffold agent + skill files ``` -`doctor.sh` checks: symlinks, GStack submodule, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency. +`doctor.sh` checks: symlinks, GStack submodule, Playwright browser cache, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency. --- diff --git a/doctor.sh b/doctor.sh index 74da787..e9156e6 100644 --- a/doctor.sh +++ b/doctor.sh @@ -18,8 +18,10 @@ REPO="$(cd "$(dirname "$0")" && pwd)" VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown") # Load shared detection library -# shellcheck source=lib/detect-plugins.sh +# shellcheck source=lib/detect-plugins.sh disable=SC1091 source "$REPO/lib/detect-plugins.sh" +# shellcheck source=lib/gstack-playwright.sh disable=SC1091 +source "$REPO/lib/gstack-playwright.sh" echo "" echo "═══ claude-config doctor (v${VERSION}) ═══" @@ -115,6 +117,13 @@ fi echo "" +# ── Playwright browsers (read-only report; NOT nested under gstack — 2 of +# the 3 registered installs are gsd-pi, not gstack) ── +echo "── Playwright browsers ──" +gstack_browsers_report || true + +echo "" + # ──────────────────────────────────────────────────────────── # 3. Prerequisites # ──────────────────────────────────────────────────────────── diff --git a/install-plugins.sh b/install-plugins.sh index 4610ef4..e599fef 100644 --- a/install-plugins.sh +++ b/install-plugins.sh @@ -26,8 +26,10 @@ else fi # Load shared detection library -# shellcheck source=lib/detect-plugins.sh +# shellcheck source=lib/detect-plugins.sh disable=SC1091 source "$REPO/lib/detect-plugins.sh" +# shellcheck source=lib/gstack-playwright.sh disable=SC1091 +source "$REPO/lib/gstack-playwright.sh" # ── Guard hand-curated config against installer drift ──────── # graphify's installer (Step 7) rewrites CLAUDE.md + .claude/settings.json @@ -291,35 +293,6 @@ fi echo "" -# gstack pins Playwright (1.58.x) which only ships browser builds for -# ubuntu<=24.04. On a newer distro the browser install fails ("does not -# support chromium on ubuntuXX.04"). Bump gstack's Playwright to a version -# that supports this OS so ./setup builds the browse binary against it and -# installs a native browser. Fires only when the pinned version genuinely -# lacks support — idempotent across runs. Edits the submodule locally (goes -# dirty); a `git submodule update` resets it and the next install re-applies. -# See BLK-008 / LRN-040. -gstack_bump_playwright_if_unsupported() { - [ -d "$GSTACK_DIR" ] && [ -r /etc/os-release ] || return 0 - local ostag pwlib - # shellcheck disable=SC1091 - ostag="$(. /etc/os-release 2>/dev/null; [ "${ID:-}" = ubuntu ] && printf 'ubuntu%s' "${VERSION_ID:-}")" - [ -n "$ostag" ] || return 0 # only the known Ubuntu case - pwlib="$GSTACK_DIR/node_modules/playwright-core/lib" - # populate node_modules at the pinned version so we can read its support list - ( cd "$GSTACK_DIR" && { bun install --frozen-lockfile >/dev/null 2>&1 || bun install >/dev/null 2>&1; } ) || return 0 - if grep -rqs "$ostag" "$pwlib" 2>/dev/null; then - return 0 # pinned Playwright already supports this OS - fi - info "gstack's Playwright lacks $ostag support — bumping to latest (local submodule edit)..." - ( cd "$GSTACK_DIR" && bun add playwright@latest >/dev/null 2>&1 ) - if grep -rqs "$ostag" "$pwlib" 2>/dev/null; then - ok "gstack Playwright bumped — now supports $ostag (browse binary rebuilt by ./setup)" - else - warn "Playwright bump didn't add $ostag support — gstack browser may stay unavailable" - fi -} - # ============================================================ # STEP 2 — GSTACK SUBMODULE # ============================================================ @@ -367,7 +340,8 @@ if [ -d "$GSTACK_DIR" ]; then # BEFORE ./setup so its frozen-lockfile install picks up the new version and # the browse binary is rebuilt against it (avoids the "does not support # chromium" fail). Non-fatal if it can't — gstack is OFF by default. - gstack_bump_playwright_if_unsupported + # See BLK-008 / LRN-040 / BDR-029; logic lives in lib/gstack-playwright.sh. + gstack_bump_playwright_if_unsupported "$GSTACK_DIR" info "Running GStack setup..." _gstack_setup_ok=0 diff --git a/lib/gstack-playwright.sh b/lib/gstack-playwright.sh new file mode 100644 index 0000000..b6ee221 --- /dev/null +++ b/lib/gstack-playwright.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# ============================================================ +# lib/gstack-playwright.sh — gstack's Playwright: OS-support bump + +# read-only browser-cache report. +# +# Sourced by: install-plugins.sh, update-all.sh, doctor.sh — all three run +# `set -euo pipefail`. gstack_bump_playwright_if_unsupported and +# gstack_browsers_report are called as BARE STATEMENTS under that inherited +# errexit, so they `return 0` on every path and every capture that could +# fail is guarded (`|| true` or an `if`), never a bare `&&`/`||`-less +# statement. gstack_submodule_update_with_bump is the ONE function allowed +# to return non-zero — callers use it ONLY as an `if` condition. +# +# No `set -euo pipefail` here (mirrors lib/detect-plugins.sh): a sourced +# lib must not change the caller's shell options. +# +# See BDR-029 (bump origin), BLK-008 (Chromium-unsupported-OS saga), +# LRN-040 (two-layer fix — this file is layer 1 only). +# ============================================================ + +_GSPW_GREEN='\033[0;32m'; _GSPW_YELLOW='\033[1;33m'; _GSPW_BLUE='\033[0;34m' +_GSPW_NC='\033[0m' + +_gspw_ok() { echo -e " ${_GSPW_GREEN}✓${_GSPW_NC} $1"; } +_gspw_warn() { echo -e " ${_GSPW_YELLOW}⚠${_GSPW_NC} $1"; } +_gspw_info() { echo -e " ${_GSPW_BLUE}→${_GSPW_NC} $1"; } + +# ── OS support ─────────────────────────────────────────────────────────── + +# gstack_pw_ostag [os_release_path] — "ubuntu" on Ubuntu, empty +# otherwise. `|| true` on the capture: the reproduced bug had this exact +# line abort every non-Ubuntu host under inherited errexit. +gstack_pw_ostag() { + local path="${1:-/etc/os-release}" tag + [ -r "$path" ] || return 0 + # shellcheck disable=SC1090 + tag="$(. "$path" 2>/dev/null + [ "${ID:-}" = ubuntu ] && printf 'ubuntu%s' "${VERSION_ID:-}")" || true + if [ -n "$tag" ]; then + printf '%s' "$tag" + fi + return 0 +} + +# gstack_pw_supports — 0 supported, 1 not. +# Always called from an `if`/`&&` context, never as a bare statement. +gstack_pw_supports() { + local pwlib="$1" ostag="$2" + [ -n "$ostag" ] && [ -d "$pwlib" ] || return 1 + grep -rqs "$ostag" "$pwlib" 2>/dev/null +} + +# _gspw_run_timeout — runs in , under `timeout 300` +# when available (absent on stock macOS). Exit 124 = the wrapped command was +# killed by the timeout. Callers MUST invoke this via `cmd || rc=$?` (never +# bare) so a non-zero exit never trips the caller's inherited errexit. +_gspw_run_timeout() { + local dir="$1"; shift + if command -v timeout >/dev/null 2>&1; then + ( cd "$dir" && timeout 300 "$@" ) >/dev/null 2>&1 + else + ( cd "$dir" && "$@" ) >/dev/null 2>&1 + fi +} + +# _gspw_bump_install — populate node_modules at the pinned +# version so its support list can be read. 0 proceed, 1 give up silently +# (both installs failed, matches the pre-existing silent behavior), 2 give +# up loud (a timeout truncated node_modules — the support grep would then +# read a half-written tree). +_gspw_bump_install() { + local dir="$1" rc=0 + _gspw_run_timeout "$dir" bun install --frozen-lockfile || rc=$? + if [ "$rc" -eq 0 ]; then + return 0 + elif [ "$rc" -eq 124 ]; then + _gspw_warn "bun install timed out — skipping Playwright bump" + return 2 + fi + rc=0 + _gspw_run_timeout "$dir" bun install || rc=$? + if [ "$rc" -eq 0 ]; then + return 0 + elif [ "$rc" -eq 124 ]; then + _gspw_warn "bun install timed out — skipping Playwright bump" + return 2 + fi + return 1 +} + +# _gspw_bump_add_latest — 0 ran (support re-checked by caller +# regardless of bun's own exit code, exactly as the pre-existing code did), +# 2 timed out (node_modules left half-written — caller must NOT re-check). +_gspw_bump_add_latest() { + local dir="$1" rc=0 + _gspw_run_timeout "$dir" bun add playwright@latest || rc=$? + if [ "$rc" -eq 124 ]; then + _gspw_warn "bun add playwright@latest timed out — skipping Playwright bump" + return 2 + fi + return 0 +} + +# gstack_bump_playwright_if_unsupported — BDR-029: bump +# gstack's pinned Playwright when it lacks a build for this OS, so +# `./setup` rebuilds the browse binary against a version that has one. +# OS-gated, idempotent, non-fatal — `return 0` on every path. +gstack_bump_playwright_if_unsupported() { + local gstack_dir="$1" ostag pwlib rc=0 + [ -d "$gstack_dir" ] && [ -r /etc/os-release ] || return 0 + ostag="$(gstack_pw_ostag)" + [ -n "$ostag" ] || return 0 + if ! command -v bun >/dev/null 2>&1; then + export PATH="$HOME/.bun/bin:$PATH" + fi + pwlib="$gstack_dir/node_modules/playwright-core/lib" + _gspw_info "checking gstack's Playwright OS support ($ostag)..." + _gspw_bump_install "$gstack_dir" || rc=$? + [ "$rc" -eq 0 ] || return 0 + if gstack_pw_supports "$pwlib" "$ostag"; then + return 0 + fi + _gspw_info "gstack's Playwright lacks $ostag support — bumping to \ +latest (local submodule edit)..." + rc=0 + _gspw_bump_add_latest "$gstack_dir" || rc=$? + [ "$rc" -eq 0 ] || return 0 + if gstack_pw_supports "$pwlib" "$ostag"; then + _gspw_ok "gstack Playwright bumped — now supports $ostag (browse \ +binary rebuilt by ./setup)" + else + _gspw_warn "Playwright bump didn't add $ostag support — gstack \ +browser may stay unavailable" + fi + return 0 +} + +# ── Submodule update ────────────────────────────────────────────────────── + +# gstack_submodule_update_with_bump [sub_path] — the ONE function +# allowed to return non-zero; callers use it ONLY as an `if` condition. +# Never touches the submodule working tree: on failure it prints git's own +# stderr verbatim (never parsed) and returns 1. On success it re-applies +# the bump (closes BDR-029's caveat: the bump used to survive only until +# the next `git submodule update`). +gstack_submodule_update_with_bump() { + local repo="$1" sub="${2:-skills-external/gstack}" err rc=0 + err="$(git -C "$repo" submodule update --remote "$sub" 2>&1 >/dev/null)" \ + || rc=$? + if [ "$rc" -eq 0 ]; then + gstack_bump_playwright_if_unsupported "$repo/$sub" + return 0 + fi + _gspw_warn "$err" + if [ -n "$(git -C "$repo/$sub" status --porcelain \ + -- package.json bun.lock 2>/dev/null)" ]; then + _gspw_info "local Playwright bump (package.json/bun.lock) was not \ +re-applied — re-run: make plugin" + fi + return 1 +} + +# ── Browsers report (read-only) ─────────────────────────────────────────── + +# _gspw_dir_name_parts — prints "normalized_name revision" +# split on the LAST '-', mapping '_' -> '-' on the name (Playwright writes +# chromium_headless_shell-1228 on disk; browsers.json names it +# chromium-headless-shell). +_gspw_dir_name_parts() { + local rev="${1##*-}" name="${1%-*}" + printf '%s %s' "${name//_/-}" "$rev" +} + +# _gspw_browser_referenced — does that +# install require this cache directory (base revision or any +# revisionOverrides value)? +_gspw_browser_referenced() { + local json="$1/browsers.json" name rev + [ -r "$json" ] || return 1 + read -r name rev <<< "$(_gspw_dir_name_parts "$2")" + awk -F'"' -v want_name="$name" -v want_rev="$rev" ' + $2 == "name" { cur = $4; in_ov = 0 } + $2 == "revision" && !in_ov && cur == want_name && $4 == want_rev { + found = 1 + } + $2 == "revisionOverrides" { in_ov = 1 } + in_ov && $2 != "revisionOverrides" && cur == want_name \ + && $4 == want_rev { found = 1 } + /^[[:space:]]*}/ { in_ov = 0 } + END { exit !found } + ' "$json" +} + +# _gspw_browser_name_known — is the NAME +# listed at all, regardless of revision? (distinguishes "unknown revision" +# from "unreferenced" in the report.) +_gspw_browser_name_known() { + local json="$1/browsers.json" name rev + [ -r "$json" ] || return 1 + read -r name rev <<< "$(_gspw_dir_name_parts "$2")" + awk -F'"' -v want="$name" '$2 == "name" && $4 == want { found = 1 } + END { exit !found }' "$json" +} + +# _gspw_install_label — " +# ", e.g. "gstack 1.61.1". +_gspw_install_label() { + local pw_path="$1" parent version + parent=$(basename "$(dirname "$(dirname "$pw_path")")") + version=$(awk -F'"' '$2 == "version" { print $4; exit }' \ + "$pw_path/package.json" 2>/dev/null) || true + printf '%s %s' "$parent" "${version:-?}" +} + +# _gspw_registered_installs — valid playwright-core paths (dir +# exists, browsers.json readable), one per line. A `.links` entry whose +# target is gone or unreadable is silently excluded here (it is counted as +# a broken link by the caller instead). +_gspw_registered_installs() { + local links_dir="$1/.links" f target + [ -d "$links_dir" ] || return 0 + for f in "$links_dir"/*; do + [ -f "$f" ] || continue + target=$(cat "$f" 2>/dev/null) || true + [ -n "$target" ] || continue + if [ -d "$target" ] && [ -r "$target/browsers.json" ]; then + printf '%s\n' "$target" + fi + done + return 0 +} + +# _gspw_report_dir_line — prints the +# report line for one cache directory. Returns 1 only when truly +# unreferenced (caller tallies that); "unknown revision" does not count. +_gspw_report_dir_line() { + local dir_name="$1" installs="$2" p labels="" known=0 + while IFS= read -r p; do + [ -n "$p" ] || continue + if _gspw_browser_referenced "$p" "$dir_name"; then + labels="${labels:+$labels, }$(_gspw_install_label "$p")" + elif _gspw_browser_name_known "$p" "$dir_name"; then + known=1 + fi + done <<< "$installs" + if [ -n "$labels" ]; then + _gspw_info "$dir_name: $labels" + return 0 + elif [ "$known" -eq 1 ]; then + _gspw_info "$dir_name: unknown revision" + return 0 + fi + _gspw_info "$dir_name: unreferenced" + return 1 +} + +# gstack_browsers_report [cache_dir] — read-only. `$1` (or +# PLAYWRIGHT_BROWSERS_PATH, or ~/.cache/ms-playwright) is resolved once; +# "0" (documented as "bundle into node_modules") and any non-directory +# degrade to a silent no-cache path. `return 0` on every path. +gstack_browsers_report() { + local cache installs total links_total valid_count broken=0 unref=0 d name + cache="${1:-${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}}" + [ "$cache" = "0" ] && return 0 + [ -d "$cache" ] || return 0 + installs="$(_gspw_registered_installs "$cache")" + links_total=$(find "$cache/.links" -maxdepth 1 -type f 2>/dev/null \ + | wc -l | tr -d ' ') || true + valid_count=$(printf '%s\n' "$installs" | grep -c . || true) + broken=$((links_total - valid_count)) + total=$(du -sh "$cache" 2>/dev/null | awk '{print $1}') || true + _gspw_info "Playwright browsers: $cache (${total:-0})" + for d in "$cache"/*-[0-9]*; do + [ -d "$d" ] || continue + name=$(basename "$d") + _gspw_report_dir_line "$name" "$installs" || unref=$((unref + 1)) + done + _gspw_info "${unref} unreferenced, ${broken} broken link(s)" + if [ "$unref" -gt 0 ] || [ "$broken" -gt 0 ]; then + _gspw_warn "unreferenced/broken Playwright browser dirs — re-run \ +\`playwright install\`, which prunes stale revisions" + fi + return 0 +} + +# ── CLI dispatch (only when executed, not sourced) — browsers-report ONLY. +# The write functions (the bump, the submodule update) stay sourced-only: a +# CLI verb would expose `bun add playwright@latest` as a command-line entry +# point. ──────────────────────────────────────────────────────────────── +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + case "${1:-}" in + browsers-report) shift; gstack_browsers_report "$@" ;; + *) echo "usage: gstack-playwright.sh browsers-report [cache_dir]" >&2 + exit 2 ;; + esac +fi diff --git a/lib/tests/gstack-playwright.test.sh b/lib/tests/gstack-playwright.test.sh new file mode 100644 index 0000000..1baa684 --- /dev/null +++ b/lib/tests/gstack-playwright.test.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# lib/tests/gstack-playwright.test.sh — lib/gstack-playwright.sh (T1..T17) +# +# git 2.53 defaults protocol.file to "user", which blocks submodule clone +# and fetch. The fixture git calls alone are not enough: the +# `git submodule update --remote` under test runs INSIDE the lib, in a +# fresh git subprocess spawned from THIS process — so the override is +# exported for the WHOLE test process, not passed per-command. +set -u +export GIT_CONFIG_COUNT=1 +export GIT_CONFIG_KEY_0=protocol.file.allow +export GIT_CONFIG_VALUE_0=always + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +L="$ROOT/lib/gstack-playwright.sh" +pass=0; fail=0 +check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); + printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; } + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +git_id() { git -C "$1" config user.email t@example.com + git -C "$1" config user.name Test; } + +# shellcheck source=lib/gstack-playwright.sh +source "$L" + +# ── T1/T2 — ostag detection ────────────────────────────────────────────── +printf 'ID=ubuntu\nVERSION_ID="24.04"\n' > "$tmp/os-ubuntu" +printf 'ID=debian\nVERSION_ID="12"\n' > "$tmp/os-debian" +check T1-ostag-ubuntu "$(gstack_pw_ostag "$tmp/os-ubuntu")" "ubuntu24.04" +check T2-ostag-other "$(gstack_pw_ostag "$tmp/os-debian")" "" + +# ── T3 — errexit safety of the ostag capture (regression: the reproduced +# bug aborted the whole caller on every non-Ubuntu host) ── +cat > "$tmp/t3.sh" <&1 | tail -1)" "REACHED" + +# ── T4/T5 — pw_supports, no bun involved ───────────────────────────────── +mkdir -p "$tmp/pwlib-hit" "$tmp/pwlib-miss" +echo "supports ubuntu24.04 and others" > "$tmp/pwlib-hit/index.js" +echo "supports nothing relevant" > "$tmp/pwlib-miss/index.js" +t4_rc=0; gstack_pw_supports "$tmp/pwlib-hit" ubuntu24.04 >/dev/null 2>&1 \ + || t4_rc=$? +check T4-supports-hit "$t4_rc" 0 +t5_rc=0; gstack_pw_supports "$tmp/pwlib-miss" ubuntu24.04 >/dev/null 2>&1 \ + || t5_rc=$? +check T5-supports-miss "$t5_rc" 1 + +# ── T6/T7 — submodule update, real git fixtures ────────────────────────── +mkdir -p "$tmp/upstream6" +git -C "$tmp/upstream6" init -q -b main; git_id "$tmp/upstream6" +printf '{"a":1}\n' > "$tmp/upstream6/package.json" +git -C "$tmp/upstream6" add package.json +git -C "$tmp/upstream6" commit -q -m init + +mkdir -p "$tmp/repo6" +git -C "$tmp/repo6" init -q -b main; git_id "$tmp/repo6" +printf 'x\n' > "$tmp/repo6/README.md" +git -C "$tmp/repo6" add README.md +git -C "$tmp/repo6" commit -q -m init +git -C "$tmp/repo6" -c protocol.file.allow=always \ + submodule add -q -b main "$tmp/upstream6" gstack-sub +git -C "$tmp/repo6" config submodule.gstack-sub.branch main +git -C "$tmp/repo6" commit -q -m "add submodule" + +printf 'extra\n' > "$tmp/upstream6/extra.txt" +git -C "$tmp/upstream6" add extra.txt +git -C "$tmp/upstream6" commit -q -m "upstream update" + +t6_out=$( + gstack_bump_playwright_if_unsupported() { echo BUMP_CALLED; } + gstack_submodule_update_with_bump "$tmp/repo6" "gstack-sub" + echo "rc=$?" +) +t6_calls=$(printf '%s\n' "$t6_out" | grep -c BUMP_CALLED) +t6_rc=$(printf '%s\n' "$t6_out" | grep -o 'rc=[0-9]*') +check T6-update-success-bumps "$t6_calls:$t6_rc" "1:rc=0" + +mkdir -p "$tmp/upstream7" +git -C "$tmp/upstream7" init -q -b main; git_id "$tmp/upstream7" +printf '{"a":1}\n' > "$tmp/upstream7/package.json" +printf 'lockA\n' > "$tmp/upstream7/bun.lock" +git -C "$tmp/upstream7" add package.json bun.lock +git -C "$tmp/upstream7" commit -q -m init + +mkdir -p "$tmp/repo7" +git -C "$tmp/repo7" init -q -b main; git_id "$tmp/repo7" +printf 'x\n' > "$tmp/repo7/README.md" +git -C "$tmp/repo7" add README.md +git -C "$tmp/repo7" commit -q -m init +git -C "$tmp/repo7" -c protocol.file.allow=always \ + submodule add -q -b main "$tmp/upstream7" gstack-sub +git -C "$tmp/repo7" config submodule.gstack-sub.branch main +git -C "$tmp/repo7" commit -q -m "add submodule" + +# upstream changes package.json content (would overwrite the local edit) +printf '{"a":2}\n' > "$tmp/upstream7/package.json" +git -C "$tmp/upstream7" add package.json +git -C "$tmp/upstream7" commit -q -m "upstream bumps package.json" +# local Playwright-bump-style dirty edit, never committed +printf '{"a":99}\n' > "$tmp/repo7/gstack-sub/package.json" + +echo "T7: update-conflict" +before_pkg=$(cat "$tmp/repo7/gstack-sub/package.json") +before_lock=$(cat "$tmp/repo7/gstack-sub/bun.lock") +t7_out=$(gstack_submodule_update_with_bump "$tmp/repo7" "gstack-sub" 2>&1) +t7_rc=$? +after_pkg=$(cat "$tmp/repo7/gstack-sub/package.json") +after_lock=$(cat "$tmp/repo7/gstack-sub/bun.lock") +t7_files_ok=N +[ "$before_pkg" = "$after_pkg" ] && [ "$before_lock" = "$after_lock" ] \ + && t7_files_ok=Y +t7_hint_ok=N +printf '%s\n' "$t7_out" | grep -q 'make plugin' && t7_hint_ok=Y +t7_state="$t7_rc:$t7_files_ok:$t7_hint_ok" +check T7-update-conflict-nondestructive "$t7_state" "1:Y:Y" + +# ── T8 — no destructive command anywhere in the lib source ─────────────── +d8=OK +sed 's/#.*//' "$L" | grep -qE 'git [^|;]*(checkout|reset|clean|stash)' && d8=BAD +sed 's/#.*//' "$L" | grep -qwE '(rm|rmdir|unlink|truncate|mv)' && d8=BAD +check T8-no-destructive-command "$d8" OK + +# ── T9-T14 — browsers-report, fixture cache + playwright-core installs ─── +mkdir -p "$tmp/installs/fixA/node_modules/playwright-core" +cat > "$tmp/installs/fixA/node_modules/playwright-core/browsers.json" <<'EOF' +{ + "comment": "Do not edit this file, use utils/roll_browser.js", + "browsers": [ + { + "name": "chromium", + "revision": "1228", + "installByDefault": true + }, + { + "name": "chromium-headless-shell", + "revision": "1228", + "installByDefault": true + }, + { + "name": "webkit", + "revision": "2311", + "installByDefault": true, + "revisionOverrides": { + "mac14": "2251", + "debian11-x64": "2105" + } + }, + { + "name": "ffmpeg", + "revision": "1011", + "installByDefault": true + } + ] +} +EOF +cat > "$tmp/installs/fixA/node_modules/playwright-core/package.json" <<'EOF' +{ + "name": "playwright-core", + "version": "1.61.1" +} +EOF + +mkdir -p "$tmp/cache1/.links" \ + "$tmp/cache1/chromium-1228" \ + "$tmp/cache1/chromium_headless_shell-1228" \ + "$tmp/cache1/webkit-2105" \ + "$tmp/cache1/firefox-9999" \ + "$tmp/cache1/chromium-9999" +printf '%s' "$tmp/installs/fixA/node_modules/playwright-core" \ + > "$tmp/cache1/.links/link-valid" +printf '%s' "$tmp/no-such-install/node_modules/playwright-core" \ + > "$tmp/cache1/.links/link-broken" + +out1="$(gstack_browsers_report "$tmp/cache1" 2>&1)" +has1() { printf '%s\n' "$out1" | grep -q "$1" && echo Y; } +check T9-report-referenced "$(has1 'chromium-1228: fixA 1.61.1')" Y +check T10-report-underscore-dir \ + "$(has1 'chromium_headless_shell-1228: fixA 1.61.1')" Y +t11_unref=$(has1 'firefox-9999: unreferenced') +t11_unknown=$(has1 'chromium-9999: unknown revision') +check T11-report-unreferenced "$t11_unref$t11_unknown" YY +check T12-report-broken-link "$(has1 '1 broken link')" Y +check T13-report-revision-override "$(has1 'webkit-2105: fixA 1.61.1')" Y + +mkdir -p "$tmp/cache2/.links" "$tmp/cache2/chromium-1228" +printf '%s' "$tmp/installs/fixA/node_modules/playwright-core" \ + > "$tmp/cache2/.links/link-valid" +out2="$(gstack_browsers_report "$tmp/cache2" 2>&1)"; rc2=$? +zero2=$(printf '%s\n' "$out2" | grep -q '0 unreferenced, 0 broken link(s)' \ + && echo Y) +check T14-report-zero-counts-exit-0 "$rc2:$zero2" "0:Y" + +# ── T15/T16 — degrade silently, nothing on stderr ──────────────────────── +err15="$(gstack_browsers_report "$tmp/does-not-exist-cache" 2>&1 1>/dev/null)" +rc15=$? +check T15-report-no-cache "$rc15:[$err15]" "0:[]" + +err16="$(gstack_browsers_report "0" 2>&1 1>/dev/null)" +rc16=$? +check T16-report-browsers-path-zero "$rc16:[$err16]" "0:[]" + +# ── T17 — sourcing emits nothing ────────────────────────────────────────── +out17="$(bash -c "source '$L'; :" 2>&1)" +check T17-source-safe "[$out17]" "[]" + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/update-all.sh b/update-all.sh index 318bde5..2c4119d 100644 --- a/update-all.sh +++ b/update-all.sh @@ -15,8 +15,10 @@ REPO="$(cd "$(dirname "$0")" && pwd)" VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown") # Load shared detection library -# shellcheck source=lib/detect-plugins.sh +# shellcheck source=lib/detect-plugins.sh disable=SC1091 source "$REPO/lib/detect-plugins.sh" +# shellcheck source=lib/gstack-playwright.sh disable=SC1091 +source "$REPO/lib/gstack-playwright.sh" echo "" echo "═══ claude-config update (v${VERSION}) ═══" @@ -84,7 +86,7 @@ if [[ "$_gstack_confirm" =~ ^[Yy]$ ]]; then _gstack_state=$(bash "$REPO/lib/toggle-external.sh" status gstack 2>/dev/null || echo "unknown") fi - if git submodule update --remote skills-external/gstack 2>/dev/null; then + if gstack_submodule_update_with_bump "$REPO"; then if [ -d "skills-external/gstack" ]; then if [ -x "skills-external/gstack/setup" ]; then if (cd skills-external/gstack && ./setup) 2>/dev/null; then