From 3b7928ed3294a28d64331bb3891a1b7b3b315e6a Mon Sep 17 00:00:00 2001 From: Xavier Costa Date: Tue, 1 Sep 2026 15:03:56 +0100 Subject: [PATCH] Initial commit --- .claude-plugin/marketplace.json | 35 + .claude-plugin/plugin.json | 19 + .github/workflows/ci.yml | 87 ++ .github/workflows/pages.yml | 133 +++ .gitignore | 11 + .mcp.json | 8 + AGENTS.md | 72 ++ CLAUDE.md | 140 +++ CODE_OF_CONDUCT.md | 57 ++ CONTRIBUTING.md | 76 ++ LICENSE | 21 + README.md | 240 ++++- commands/browsergnome.md | 31 + commands/what-if.md | 28 + docs/banner-dark.png | Bin 0 -> 60259 bytes docs/banner-dark.svg | 38 + docs/banner-light.png | Bin 0 -> 63776 bytes docs/banner-light.svg | 38 + docs/diagrams/gate.svg | 62 ++ docs/diagrams/loop.svg | 136 +++ docs/diagrams/warmup.svg | 47 + docs/lcp-map.png | Bin 0 -> 78105 bytes docs/logo-1024.png | Bin 0 -> 22300 bytes docs/logo-badge-1024.png | Bin 0 -> 23049 bytes docs/logo-badge.svg | 8 + docs/logo.svg | 8 + docs/pages/index.html | 164 ++++ docs/perf-map.png | Bin 0 -> 2993510 bytes .../2026-08-09-browsergnome-brand-design.md | 134 +++ hooks/hooks.json | 24 + package-lock.json | 219 +++++ package.json | 36 + skills/browsergnome/SKILL.md | 473 ++++++++++ .../browsergnome/assets/3d-force-graph.min.js | 5 + .../bundle-stats.esbuild-sample.json.gz | Bin 0 -> 248 bytes ...le-stats.webpack-multichunk-sample.json.gz | Bin 0 -> 415 bytes .../bundle-stats.webpack-sample.json.gz | Bin 0 -> 211 bytes .../browsergnome/assets/lcp-map.template.html | 322 +++++++ skills/browsergnome/assets/ledger.template.md | 38 + .../assets/perf-map.template.html | 497 ++++++++++ skills/browsergnome/assets/playbook.seed.json | 137 +++ .../browsergnome/assets/report.template.html | 347 +++++++ .../browsergnome/assets/run-state.sample.json | 68 ++ .../assets/trace.cls-sample.json.gz | Bin 0 -> 1880 bytes .../assets/trace.eventtiming-sample.json.gz | Bin 0 -> 2452 bytes .../assets/trace.interaction-sample.json.gz | Bin 0 -> 26711 bytes .../assets/trace.multi-lcp-sample.json.gz | Bin 0 -> 1984 bytes .../trace.render-blocking-sample.json.gz | Bin 0 -> 24061 bytes .../browsergnome/assets/trace.sample.json.gz | Bin 0 -> 35604 bytes .../references/architectural-perf-catalog.md | 260 +++++ .../references/bundlers/esbuild.md | 27 + .../references/bundlers/rspack.md | 23 + .../references/bundlers/turbopack.md | 32 + .../browsergnome/references/bundlers/vite.md | 27 + .../references/bundlers/webpack.md | 30 + skills/browsergnome/references/dep-pulse.md | 339 +++++++ .../references/frameworks/next-app-router.md | 57 ++ .../references/frameworks/next-pages.md | 35 + .../references/frameworks/remix.md | 42 + .../references/frameworks/tanstack-start.md | 43 + .../references/frameworks/vite-spa.md | 41 + .../references/hosts/cloudflare.md | 24 + skills/browsergnome/references/hosts/node.md | 26 + .../browsergnome/references/hosts/vercel.md | 28 + .../references/knowledge/INDEX.md | 70 ++ .../references/knowledge/bundle.md | 25 + .../references/knowledge/caching.md | 25 + .../browsergnome/references/knowledge/cls.md | 16 + .../browsergnome/references/knowledge/css.md | 17 + .../references/knowledge/fonts.md | 11 + .../references/knowledge/hydration-rsc.md | 20 + .../references/knowledge/images-media.md | 60 ++ .../browsergnome/references/knowledge/inp.md | 40 + .../browsergnome/references/knowledge/lcp.md | 33 + .../references/knowledge/third-party.md | 27 + skills/browsergnome/references/measurement.md | 441 +++++++++ skills/browsergnome/references/perf-map.md | 168 ++++ skills/browsergnome/references/presets.md | 211 +++++ .../browsergnome/references/senior-audit.md | 204 ++++ skills/browsergnome/references/tools.md | 51 + skills/browsergnome/references/what-if.md | 95 ++ skills/browsergnome/scripts/build_lcp_map.mjs | 200 ++++ .../browsergnome/scripts/build_perf_map.mjs | 83 ++ .../browsergnome/scripts/build_playbook.mjs | 466 +++++++++ .../browsergnome/scripts/build_run_report.mjs | 77 ++ skills/browsergnome/scripts/bundle_stats.mjs | 259 +++++ .../browsergnome/scripts/check_invariants.mjs | 163 ++++ skills/browsergnome/scripts/crux.mjs | 221 +++++ skills/browsergnome/scripts/doctor.mjs | 475 ++++++++++ .../browsergnome/scripts/lcp_attribution.mjs | 573 +++++++++++ skills/browsergnome/scripts/perf_scan.mjs | 893 ++++++++++++++++++ skills/browsergnome/scripts/stats.mjs | 260 +++++ skills/browsergnome/scripts/trace_metrics.mjs | 515 ++++++++++ templates/ci/README.md | 226 +++++ .../ci/browsergnome-autopilot-browser.yml | 241 +++++ templates/ci/browsergnome-autopilot-build.yml | 207 ++++ 96 files changed, 11164 insertions(+), 2 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 commands/browsergnome.md create mode 100644 commands/what-if.md create mode 100644 docs/banner-dark.png create mode 100644 docs/banner-dark.svg create mode 100644 docs/banner-light.png create mode 100644 docs/banner-light.svg create mode 100644 docs/diagrams/gate.svg create mode 100644 docs/diagrams/loop.svg create mode 100644 docs/diagrams/warmup.svg create mode 100644 docs/lcp-map.png create mode 100644 docs/logo-1024.png create mode 100644 docs/logo-badge-1024.png create mode 100644 docs/logo-badge.svg create mode 100644 docs/logo.svg create mode 100644 docs/pages/index.html create mode 100644 docs/perf-map.png create mode 100644 docs/superpowers/specs/2026-08-09-browsergnome-brand-design.md create mode 100644 hooks/hooks.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 skills/browsergnome/SKILL.md create mode 100644 skills/browsergnome/assets/3d-force-graph.min.js create mode 100644 skills/browsergnome/assets/bundle-stats.esbuild-sample.json.gz create mode 100644 skills/browsergnome/assets/bundle-stats.webpack-multichunk-sample.json.gz create mode 100644 skills/browsergnome/assets/bundle-stats.webpack-sample.json.gz create mode 100644 skills/browsergnome/assets/lcp-map.template.html create mode 100644 skills/browsergnome/assets/ledger.template.md create mode 100644 skills/browsergnome/assets/perf-map.template.html create mode 100644 skills/browsergnome/assets/playbook.seed.json create mode 100644 skills/browsergnome/assets/report.template.html create mode 100644 skills/browsergnome/assets/run-state.sample.json create mode 100644 skills/browsergnome/assets/trace.cls-sample.json.gz create mode 100644 skills/browsergnome/assets/trace.eventtiming-sample.json.gz create mode 100644 skills/browsergnome/assets/trace.interaction-sample.json.gz create mode 100644 skills/browsergnome/assets/trace.multi-lcp-sample.json.gz create mode 100644 skills/browsergnome/assets/trace.render-blocking-sample.json.gz create mode 100644 skills/browsergnome/assets/trace.sample.json.gz create mode 100644 skills/browsergnome/references/architectural-perf-catalog.md create mode 100644 skills/browsergnome/references/bundlers/esbuild.md create mode 100644 skills/browsergnome/references/bundlers/rspack.md create mode 100644 skills/browsergnome/references/bundlers/turbopack.md create mode 100644 skills/browsergnome/references/bundlers/vite.md create mode 100644 skills/browsergnome/references/bundlers/webpack.md create mode 100644 skills/browsergnome/references/dep-pulse.md create mode 100644 skills/browsergnome/references/frameworks/next-app-router.md create mode 100644 skills/browsergnome/references/frameworks/next-pages.md create mode 100644 skills/browsergnome/references/frameworks/remix.md create mode 100644 skills/browsergnome/references/frameworks/tanstack-start.md create mode 100644 skills/browsergnome/references/frameworks/vite-spa.md create mode 100644 skills/browsergnome/references/hosts/cloudflare.md create mode 100644 skills/browsergnome/references/hosts/node.md create mode 100644 skills/browsergnome/references/hosts/vercel.md create mode 100644 skills/browsergnome/references/knowledge/INDEX.md create mode 100644 skills/browsergnome/references/knowledge/bundle.md create mode 100644 skills/browsergnome/references/knowledge/caching.md create mode 100644 skills/browsergnome/references/knowledge/cls.md create mode 100644 skills/browsergnome/references/knowledge/css.md create mode 100644 skills/browsergnome/references/knowledge/fonts.md create mode 100644 skills/browsergnome/references/knowledge/hydration-rsc.md create mode 100644 skills/browsergnome/references/knowledge/images-media.md create mode 100644 skills/browsergnome/references/knowledge/inp.md create mode 100644 skills/browsergnome/references/knowledge/lcp.md create mode 100644 skills/browsergnome/references/knowledge/third-party.md create mode 100644 skills/browsergnome/references/measurement.md create mode 100644 skills/browsergnome/references/perf-map.md create mode 100644 skills/browsergnome/references/presets.md create mode 100644 skills/browsergnome/references/senior-audit.md create mode 100644 skills/browsergnome/references/tools.md create mode 100644 skills/browsergnome/references/what-if.md create mode 100644 skills/browsergnome/scripts/build_lcp_map.mjs create mode 100644 skills/browsergnome/scripts/build_perf_map.mjs create mode 100644 skills/browsergnome/scripts/build_playbook.mjs create mode 100644 skills/browsergnome/scripts/build_run_report.mjs create mode 100644 skills/browsergnome/scripts/bundle_stats.mjs create mode 100644 skills/browsergnome/scripts/check_invariants.mjs create mode 100644 skills/browsergnome/scripts/crux.mjs create mode 100644 skills/browsergnome/scripts/doctor.mjs create mode 100644 skills/browsergnome/scripts/lcp_attribution.mjs create mode 100644 skills/browsergnome/scripts/perf_scan.mjs create mode 100644 skills/browsergnome/scripts/stats.mjs create mode 100644 skills/browsergnome/scripts/trace_metrics.mjs create mode 100644 templates/ci/README.md create mode 100644 templates/ci/browsergnome-autopilot-browser.yml create mode 100644 templates/ci/browsergnome-autopilot-build.yml diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..3f69fe0 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,35 @@ +{ + "name": "browsergnome", + "description": "The autonomous JS web performance engineer, as a self-installable Claude Code plugin marketplace.", + "owner": { + "name": "Xavier", + "email": "xavi-999@users.noreply.github.com" + }, + "plugins": [ + { + "name": "browsergnome", + "source": "./", + "version": "0.2.1", + "description": "The autonomous JS web performance engineer — one propose -> measure -> keep/revert loop over chrome-devtools-mcp, with an LCP Attribution Map and a per-repo performance memory.", + "author": { + "name": "Xavier", + "email": "xavi-999@users.noreply.github.com" + }, + "license": "MIT", + "keywords": [ + "web", + "performance", + "chrome-devtools", + "lcp", + "claude-code", + "plugin" + ], + "category": "performance", + "tags": [ + "web", + "performance", + "mcp" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..ff560b8 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "browsergnome", + "version": "0.2.1", + "description": "The autonomous JS web performance engineer. Orchestrates chrome-devtools-mcp into one propose → measure → keep/revert loop — with an LCP Attribution Map, a live HTML run report, configurable commit mode, and per-repo performance memory.", + "author": { + "name": "Xavier", + "email": "xavi-999@users.noreply.github.com" + }, + "license": "MIT", + "keywords": [ + "claude-code", + "chrome-devtools", + "web-vitals", + "lcp", + "performance", + "plugin" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc48564 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +# CI covers only the offline chain: +# - all self-tests (stats / playbook / doctor / trace / bundle / lcp-attribution) +# - offline smoke chain: lcp_attribution → build_lcp_map, build_run_report from the sample fixture +# - JSON validation of all plugin manifests +# - official plugin manifest validation (claude plugin validate --strict) +# +# The live path (chrome-devtools-mcp / real browser / MCP runtime) cannot be CI-tested +# because it requires a running Chrome instance and an active Claude Code session. + +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm ci + + - name: All self-tests + run: npm test + + - name: Offline smoke chain — lcp_attribution + run: node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json + + - name: Offline smoke chain — build_lcp_map + run: node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out /tmp/lcp-map.html + + - name: Offline smoke chain — build_run_report + run: node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out /tmp/report.html + + # Assert each output file exists and is non-empty. + - name: Assert smoke-chain outputs are non-empty + run: | + test -s /tmp/attribution.json || (echo "attribution.json is missing or empty" && exit 1) + test -s /tmp/lcp-map.html || (echo "lcp-map.html is missing or empty" && exit 1) + test -s /tmp/report.html || (echo "report.html is missing or empty" && exit 1) + + - name: Validate JSON files + run: | + node -e " + const fs = require('fs'); + const files = [ + '.claude-plugin/plugin.json', + '.claude-plugin/marketplace.json', + '.mcp.json', + 'package.json', + 'skills/browsergnome/assets/run-state.sample.json', + ]; + let ok = true; + for (const f of files) { + try { + JSON.parse(fs.readFileSync(f, 'utf8')); + console.log(' ok ' + f); + } catch (e) { + console.error('FAIL ' + f + ': ' + e.message); + ok = false; + } + } + if (!ok) process.exit(1); + " + + # Static manifest validation via the official Claude Code CLI. + # No auth needed — `plugin validate` is offline. --strict treats + # warnings (e.g. a missing marketplace description) as errors. + - name: Official plugin validation (strict) + run: | + npm install -g @anthropic-ai/claude-code@2.1.226 + claude plugin validate . --strict diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..195c4c9 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,133 @@ +# GitHub Pages live demo. +# +# Builds the public demo at https://xavi-999.github.io/browsergnome/ : +# - perf-map.html — a REAL static scan of excalidraw at a pinned SHA (the same commit +# recorded in references/perf-map.md's calibration note — 527 modules, 16 hotspots). +# - lcp-map.html — a REAL LCP attribution from the shipped trace fixture +# (skills/browsergnome/assets/trace.render-blocking-sample.json.gz, a real nextjs.org capture). +# - report.html — the live run report rendered from the bundled sample run-state +# (skills/browsergnome/assets/run-state.sample.json). +# - index.html — landing page (docs/pages/index.html) with real substituted stats. +# +# No generated HTML is committed to master — everything is built here and deployed as a +# Pages artifact. +# +# Runs on every push to master, plus manual dispatch. On a free-tier private repo the deploy +# step will fail (GitHub Pages requires a public repo, or a paid plan) — that's expected until +# the repo goes public and Pages is enabled once in Settings > Pages. No other change needed +# here after that; this workflow is otherwise ready to go. + +name: Pages + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +env: + # excalidraw main, resolved 2026-08-08 — the calibration commit in references/perf-map.md. + # Bump deliberately; re-verify hotspot count against the calibration note if you do. + EXCALIDRAW_SHA: 4872083c044491b6d5c96ae134a75464f96d6831 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Fetch excalidraw at pinned SHA + run: | + git init excalidraw + git -C excalidraw remote add origin https://github.com/excalidraw/excalidraw + git -C excalidraw fetch --depth 1 origin "$EXCALIDRAW_SHA" + git -C excalidraw checkout FETCH_HEAD + + # Alias resolution is make-or-break for fan-in/centrality (13 @excalidraw/* aliases). + # If the scan prints "aliases (none found)" the graph is meaningless — fail loudly. + - name: Scan excalidraw (perf_scan) + run: | + set -o pipefail + node skills/browsergnome/scripts/perf_scan.mjs excalidraw --out /tmp/graph.json | tee /tmp/scan.log + if grep -qE 'aliases +\(none found\)' /tmp/scan.log; then + echo "::error::perf_scan resolved no import aliases on excalidraw — graph would be meaninglessly sparse" + exit 1 + fi + + - name: Build perf map + run: | + mkdir -p _site + node skills/browsergnome/scripts/build_perf_map.mjs /tmp/graph.json --out _site/perf-map.html + + - name: Build LCP attribution map (shipped fixture) + run: | + node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json + node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out _site/lcp-map.html + + - name: Build run report (sample run-state) + run: node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out _site/report.html + + - name: Assemble landing page + run: | + cp docs/pages/index.html _site/ + cp docs/logo-badge.svg _site/ + # Pull the hero numbers from the real scan and the real trace — the page never + # claims a figure the build didn't measure. + node -e " + const g = JSON.parse(require('fs').readFileSync('/tmp/graph.json', 'utf8')); + const attr = JSON.parse(require('fs').readFileSync('/tmp/attribution.json', 'utf8')); + const fmt = (n) => n.toLocaleString('en-US'); + const out = { + MODULES: fmt(g.nodes.length), + HOTSPOTS: fmt(g.nodes.filter((n) => n.isHotspot).length), + FINDINGS: fmt(g.nodes.reduce((s, n) => s + (n.findings?.length || 0), 0)), + LCP_MS: attr.lcpMs != null ? Math.round(attr.lcpMs) : '?', + }; + require('fs').writeFileSync('/tmp/stats.env', Object.entries(out).map(([k, v]) => k + '=' + v).join('\n')); + " + . /tmp/stats.env + sed -i \ + -e "s/__EXCALIDRAW_SHA__/${EXCALIDRAW_SHA:0:8}/g" \ + -e "s/__BUILD_DATE__/$(date -u +%Y-%m-%d)/g" \ + -e "s/__STAT_MODULES__/${MODULES}/g" \ + -e "s/__STAT_HOTSPOTS__/${HOTSPOTS}/g" \ + -e "s/__STAT_FINDINGS__/${FINDINGS}/g" \ + -e "s/__LCP_MS__/${LCP_MS}/g" \ + _site/index.html + # nothing un-templated left behind + if grep -q '__' _site/index.html; then + echo "::error::unsubstituted placeholder left in index.html" && exit 1 + fi + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d1972c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.DS_Store +*.log + +# Runtime artifacts produced against a target web app (these live in the +# target repo's own .bgn/, never in the plugin). Ignored here so a test run +# inside this repo doesn't pollute it. +.bgn/ +graph.json +perf-map.html +lcp-map.html diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5f9b864 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["chrome-devtools-mcp@1.6.0"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5f8e4d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# AGENTS.md + +Vendor-neutral instructions for coding agents working in this repo. Claude Code should read +`CLAUDE.md` instead — it has the same facts plus Claude-specific wiring (skills, hooks, MCP setup). + +## What this repo is + +browsergnome is a **Claude Code plugin**, not an application. Most of its behavior is Markdown read at +runtime by an LLM agent; the `.mjs` files are the deterministic, offline half. + +- `skills/browsergnome/SKILL.md` — the orchestrator spec: menu, propose→measure→keep/revert loop, gate + math, presets, `.bgn/config.json` defaults, reference index. +- `skills/browsergnome/references/` — read-on-demand context: `tools.md`, `presets.md`, + `measurement.md`, `perf-map.md`, `senior-audit.md`, `what-if.md`, `knowledge/`, and three stack-axis + catalogs (`frameworks/`, `bundlers/`, `hosts/`). +- `skills/browsergnome/scripts/*.mjs` — pure, offline, self-testing tools. No framework, no runtime + browser dependency, no shared util module. Each script is standalone except one ESM import edge + (`lcp_attribution.mjs` imports parser functions from `trace_metrics.mjs`/`bundle_stats.mjs`, guarded + by a `pathToFileURL(process.argv[1])` check so importing doesn't also fire the CLI). +- `commands/`, `hooks/hooks.json`, `.mcp.json`, `.claude-plugin/` — Claude Code plugin wiring; not + relevant to a non-Claude agent working on the `.mjs` scripts or docs. +- `templates/ci/` — GitHub Actions templates for running Autoresearch unattended in a *target* repo. + +**Two-repo model.** At runtime the working directory is the *target* web repo being optimized, not +this one. Scripts resolve against `$CLAUDE_PLUGIN_ROOT`, and `.bgn/` (config, ledger, perf-memory, +playbook) is written into the **target** repo, never this one. + +## Commands + +No build step, no linter, no test framework beyond the scripts' own self-tests. + +```bash +npm install # @babel/parser + @babel/traverse, needed only by perf_scan.mjs +npm test # all six self-tests, chained +node skills/browsergnome/scripts/stats.mjs --self-test # a single self-test, directly +``` + +CI (`.github/workflows/ci.yml`) runs `npm test` plus an offline smoke chain (`lcp_attribution.mjs` → +`build_lcp_map.mjs`, `build_run_report.mjs`) and `claude plugin validate . --strict`. + +## Cross-file invariants + +These have no single source of truth; an edit to one side silently breaks the other. + +- **chrome-devtools-mcp pin** appears in `.mcp.json`, `references/tools.md`, and `doctor.mjs`'s + self-test fixtures. Bump all three together; never use `@latest`. +- **Stack-catalog filenames** under `references/frameworks|bundlers|hosts/` must match + `doctor.mjs`'s detector return strings verbatim. +- **All product-facing docs** (`README.md`, `docs/pages/`, `SKILL.md`, `references/`) read as one + confident, unhedged voice — no validation-status narration, no dev-diary voice, no local-machine + specifics. Facts stay accurate; nothing editorializes about whether something was confirmed to work. +- **Perf Map calibration** — `.github/workflows/pages.yml`'s `EXCALIDRAW_SHA` is pinned to the commit + `references/perf-map.md`'s calibration note (527 modules / 16 hotspots) was measured against. + +## Project rules + +- **Don't claim something works that doesn't.** `/bisect` is not built — say so plainly, don't guess + its behavior. State what's built and what isn't as plain fact, same unhedged voice as the docs. +- **Never tune `perf_scan.mjs`'s `CONFIG` against a fixture** — it's circular by construction. Use a + real cloned OSS app. +- **Never use `lighthouse_audit` for the gate** — it excludes performance. Route measurement through + `performance_start_trace`. +- **Never `git add -A`** in the propose→measure→gate loop; stage only the touched paths. +- **Knowledge-base status tiers** (`proven` / `documented` / `ungated hypothesis` / `dead end`) in + `references/knowledge/` require a real measured before/after to upgrade. A citation is not a + measurement. +- Node ≥ 18, ESM throughout. The only runtime deps are `@babel/parser` + `@babel/traverse`. + +## Full detail + +`CLAUDE.md` in this same directory carries everything above plus the script pipeline diagram and +Claude-Code-specific wiring (skills, hooks, MCP). Read it for anything this file doesn't cover. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca03e09 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,140 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repo is + +browsergnome is a **Claude Code plugin**, not an application. Most of its behavior is Markdown that +Claude reads at runtime; the `.mjs` files are the deterministic, offline half. + +- `skills/browsergnome/SKILL.md` — the orchestrator. Menu, propose→measure→keep/revert loop, gate + math, presets, `.bgn/config.json` defaults, reference index. **Read it before changing any runtime + behavior**; it is the spec, this file is not a summary of it. +- `skills/browsergnome/references/` — read-on-demand context (~30 files: `tools.md`, `presets.md`, + `measurement.md`, `perf-map.md`, `senior-audit.md`, `what-if.md`, `knowledge/`, and three + stack-axis catalogs `frameworks/` `bundlers/` `hosts/`). +- `skills/browsergnome/scripts/*.mjs` — pure, offline, self-testing tools. No framework, no runtime + browser dependency, no shared util module — each script is standalone except one ESM import edge + (`lcp_attribution.mjs` imports parser functions from `trace_metrics.mjs`/`bundle_stats.mjs`, guarded + by a `pathToFileURL(process.argv[1])` check so importing doesn't also fire the CLI). +- `commands/` (`/browsergnome`, `/what-if`), `hooks/hooks.json`, `.mcp.json`, + `.claude-plugin/{plugin,marketplace}.json` — plugin wiring. +- `templates/ci/` — two GitHub Actions templates (build-only, browser) for running Autoresearch + unattended in a *target* repo, with their own adoption README. + +**Two-repo model.** At runtime the cwd is the *target* web repo being optimized, not this one. +Scripts resolve against `$CLAUDE_PLUGIN_ROOT`, and `.bgn/` (config, ledger, perf-memory, playbook) is +written into the **target** repo. `.gitignore` here ignores `.bgn/`, `graph.json`, `perf-map.html`, +`lcp-map.html` so a dev run inside this repo doesn't pollute it. + +## Commands + +No build step, no linter, no test framework. + +```bash +npm install # @babel/parser + @babel/traverse, needed only by perf_scan.mjs +npm test # the six self-tests, chained +npm run stats:test # a single self-test (also: playbook:test doctor:test trace:test + # bundle:test lcp-attribution:test) +node skills/browsergnome/scripts/stats.mjs --self-test # same thing, directly +``` + +Each testable script carries its own `selfTest()` with a local pass/fail-tally helper (`check()` in +most; `build_playbook.mjs` names it `ok()`) behind `--self-test`, `process.exit(fail ? 1 : 0)`, no +shared assert module. `trace_metrics.mjs`/`bundle_stats.mjs`/`lcp_attribution.mjs` assert against real +gzipped captures in `assets/`; `stats.mjs`/`doctor.mjs`/`build_playbook.mjs` test pure functions with +inline fixtures. Adding a test means extending the relevant `selfTest()`, not adding a file. Keep +`package.json`'s `test` script in sync when a new `*:test` is added. + +CI (`.github/workflows/ci.yml`, Node 18 + 20) runs `npm test` plus two things `npm test` does not: + +```bash +# offline smoke chain +node skills/browsergnome/scripts/lcp_attribution.mjs skills/browsergnome/assets/trace.render-blocking-sample.json.gz > /tmp/attribution.json +node skills/browsergnome/scripts/build_lcp_map.mjs /tmp/attribution.json --out /tmp/lcp-map.html +node skills/browsergnome/scripts/build_run_report.mjs skills/browsergnome/assets/run-state.sample.json --out /tmp/report.html + +claude plugin validate . --strict # manifest validation; offline, no auth +``` + +## Script pipeline + +``` +target repo ──perf_scan.mjs──> graph.json ──build_perf_map.mjs──> perf-map.html + +trace.json.gz ──┬─ (imported) ─┐ + │ ├──lcp_attribution.mjs──(stdout)──> attribution.json ──build_lcp_map.mjs──> lcp-map.html +bundle stats ───┴─ (imported) ─┘ + +trace.json.gz ──trace_metrics.mjs──(stdout)──> {lcp,fcp,ttfb,cls,inp,tbt,...} (agent feeds N values into stats.mjs by hand) +bundler stats ──bundle_stats.mjs───(stdout)──> {bundler,totalBytes,chunks:[...]} + +run-state.json ──build_run_report.mjs──> report.html +.bgn/ledger/*.md + assets/playbook.seed.json ──build_playbook.mjs──> .bgn/playbook.{md,json} +stats.mjs gate arbiter (argv numbers in, JSON out): keep ⇔ improvement > max(minEffect, k·pooledStdDev) +doctor.mjs preflight: MCP pin check, stack detection, git state, `--init` bootstraps .bgn/ +``` + +**Not every arrow above is a file handoff** — `stats.mjs` only takes comma-separated numbers on argv, +so `trace_metrics.mjs → stats.mjs` and `KEEP/REVERT → .bgn/ledger/*.md` are agent-mediated: no script +writes a ledger entry or `run-state.json`, the agent does, from `assets/ledger.template.md` / +`assets/run-state.sample.json`'s shape (`SKILL.md` steps 2 and 9). + +Two conventions hold across the real file/stdout handoffs: + +- **CLI guard.** `trace_metrics.mjs` and `bundle_stats.mjs` are imported by `lcp_attribution.mjs`, + so their CLI is gated on `import.meta.url === pathToFileURL(process.argv[1]).href`. Preserve it on + any script that becomes importable. `perf_scan.mjs` has zero exports — CLI-only by design. +- **Marker injection.** `build_perf_map` / `build_lcp_map` / `build_run_report` produce standalone + HTML by `split().join()` on comment markers (`/*__GRAPH_DATA__*/`, `/*__LCP_DATA__*/`, + `/*__RUN_DATA__*/`, `/*__FORCE_GRAPH_LIB__*/`) in `assets/*.template.html`, deliberately not regex + (the 1.3 MB vendored `assets/3d-force-graph.min.js` could match one). Renaming a marker without + updating its builder fails loudly — keep it that way. Only `build_run_report.mjs` escapes + `` in injected data; the other two inject raw JSON/text into a ` + + + + + diff --git a/skills/browsergnome/assets/ledger.template.md b/skills/browsergnome/assets/ledger.template.md new file mode 100644 index 0000000..a6ae11c --- /dev/null +++ b/skills/browsergnome/assets/ledger.template.md @@ -0,0 +1,38 @@ +# Experiment Ledger — {{preset}} on {{target}} + +- **Run:** {{timestamp}} +- **Repo / commit baseline:** {{repo}} @ {{baseline_sha}} +- **Metric:** {{metric}} ({{direction}}-is-better, unit {{unit}}) +- **Protocol:** N={{n}} runs, 1 warm-up discarded, identical workload ({{workload}}) +- **Tools:** drive={{drive_tool}}, measure={{measure_tool}} + +## Baseline +- runs: {{baseline_runs}} +- mean ± stddev: {{baseline_mean}} ± {{baseline_std}} + +## Hypotheses + +### H1 — {{hypothesis}} +- **Guide:** {{guide}} +- **Guidance:** {{guidance}} +- **Change (atomic):** {{change_description}} ({{files_touched}}) +- **Measurement mode:** {{mode}} (interleaved ABABAB, or sequential AAAA→BBBB if the diff needed a + rebuild — see references/measurement.md's rebuild-fallback list; `n/a (deterministic build)` for + `bundle-size`, where there's no run-to-run variance for interleaving/sequencing to matter) +- **Candidate runs:** {{candidate_runs}} +- **mean ± stddev:** {{candidate_mean}} ± {{candidate_std}} +- **Gate:** improvement {{improvement}} vs noise band {{noise_band}} (max of min_effect {{min_effect}}, k·pooled_std {{k_pooled}}) + — or, for `layout-shift`'s race-driven shape (`gateOccurrence()` instead of `gate()`): + `occurrence rate improvement {{improvement}} (baseline {{baseline_rate}} vs candidate {{candidate_rate}}, Fisher exact p={{p_value}})` +- **Decision:** {{KEEP|REVERT}} +- **Commit / revert:** {{commit_sha_or_reverted}} +- **Memory line distilled:** `{{memory_line}}` + + + +## Result +- **Kept:** {{kept_summary}} +- **Net delta vs baseline:** {{net_delta}} +- **Commits:** {{commit_list}} +- **Reverted dead-ends (don't retry):** {{reverted_list}} +- **Dep Pulse:** {{dep_pulse}} (`dispatched` | `deferred` | `cached` | `off` — see references/dep-pulse.md) diff --git a/skills/browsergnome/assets/perf-map.template.html b/skills/browsergnome/assets/perf-map.template.html new file mode 100644 index 0000000..6a62372 --- /dev/null +++ b/skills/browsergnome/assets/perf-map.template.html @@ -0,0 +1,497 @@ + + + + + +browsergnome · perf map + + + +
+ + + +
+ + +
+ + + +
+
+ +
+
top 3 · paste into /browsergnome
+
+ +
+ +
+ Critical + High + Medium + Below gate + size = perf debt +
+ + + +
drag to rotate · scroll to zoom · click a node
+ + + + + + + + diff --git a/skills/browsergnome/assets/playbook.seed.json b/skills/browsergnome/assets/playbook.seed.json new file mode 100644 index 0000000..664afdd --- /dev/null +++ b/skills/browsergnome/assets/playbook.seed.json @@ -0,0 +1,137 @@ +[ + { + "preset": "first-load", + "guide": "lcp", + "hypothesis": "Post-build critical CSS (critters) for App Router", + "direction": "lower", + "unit": "ms", + "kept": 2, + "tried": 2, + "effects": [874, 306], + "source": "seeded" + }, + { + "preset": "interaction", + "guide": "inp", + "hypothesis": "Deferred content-visibility release via requestIdleCallback", + "direction": "higher", + "unit": "fps", + "kept": 1, + "tried": 1, + "effects": [27], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "images-media", + "hypothesis": "Lossless WebP for PNG client logos", + "direction": "lower", + "unit": "kB", + "kept": 1, + "tried": 1, + "effects": [133], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "fonts", + "hypothesis": "next/font preload:false on below-fold fonts", + "direction": "lower", + "unit": "requests", + "kept": 1, + "tried": 1, + "effects": [3], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "caching", + "hypothesis": "No dynamic APIs in the root layout", + "direction": "higher", + "unit": "prerendered pages", + "kept": 1, + "tried": 1, + "effects": [22], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "images-media", + "hypothesis": "`fill` images need `sizes`", + "direction": "lower", + "unit": "kB", + "kept": 0, + "tried": 1, + "effects": [], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "lcp", + "guidance": "optimize-preload-priority", + "hypothesis": " for the LCP image", + "direction": "lower", + "unit": "ms", + "kept": 0, + "tried": 1, + "effects": [], + "source": "seeded" + }, + { + "preset": "bundle-size", + "guide": "bundle", + "hypothesis": "experimental.optimizeCss (App Router)", + "direction": "lower", + "unit": "ms", + "kept": 0, + "tried": 1, + "effects": [], + "source": "seeded" + }, + { + "preset": "interaction", + "guide": "inp", + "guidance": "parallax-scroll-effects", + "hypothesis": "Off-screen parallax/transform gating", + "direction": "higher", + "unit": "fps", + "kept": 0, + "tried": 1, + "effects": [], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "third-party", + "guidance": "improve-next-page-load-performance", + "hypothesis": "Speculation Rules API", + "direction": "lower", + "unit": "ms", + "kept": 0, + "tried": 0, + "effects": [], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "caching", + "hypothesis": "bfcache eligibility", + "direction": "lower", + "unit": "ms", + "kept": 0, + "tried": 0, + "effects": [], + "source": "seeded" + }, + { + "preset": "first-load", + "guide": "third-party", + "hypothesis": "dns-prefetch for non-critical third-party origins", + "direction": "lower", + "unit": "ms", + "kept": 0, + "tried": 0, + "effects": [], + "source": "seeded" + } +] diff --git a/skills/browsergnome/assets/report.template.html b/skills/browsergnome/assets/report.template.html new file mode 100644 index 0000000..14c4b65 --- /dev/null +++ b/skills/browsergnome/assets/report.template.html @@ -0,0 +1,347 @@ + + + + + + +browsergnome · run report + + + +
+ +
+ + run report +
+
+ +
+ +
+ + + diff --git a/skills/browsergnome/assets/run-state.sample.json b/skills/browsergnome/assets/run-state.sample.json new file mode 100644 index 0000000..b69adbb --- /dev/null +++ b/skills/browsergnome/assets/run-state.sample.json @@ -0,0 +1,68 @@ +{ + "preset": "first-load", + "target": "https://example.com/", + "status": "done", + "startedAt": "2026-08-05T09:00:00.000Z", + "unit": "ms", + "direction": "lower", + "baseline": { "mean": 1450, "std": 18, "runs": 5 }, + "iterations": [ + { + "n": 1, + "hypothesis": "Preload the LCP hero image", + "guide": "lcp", + "change": "Added for the hero banner image referenced by the LCP element", + "candidate": { "mean": 1290, "std": 16, "runs": 5 }, + "decision": "KEEP", + "delta": "-160ms (-11%)", + "noiseBand": 32, + "commit": "b4c1a02" + }, + { + "n": 2, + "hypothesis": "Defer the third-party analytics tag", + "guide": "third-party", + "change": "Added defer to the analytics so it can't close the script block if it appears in data values + const safeData = rawData.replace(/<\/script>/gi, '<\\/script>'); + const [before, after] = template.split(DATA_MARKER); + const out = before + safeData + after; + + const absOut = path.resolve(outFile); + fs.writeFileSync(absOut, out, 'utf8'); + + const data = JSON.parse(rawData); + const kept = (data.iterations || []).filter(i => i.decision === 'KEEP').length; + const total = (data.iterations || []).length; + console.log(`browsergnome run-report`); + console.log(` wrote ${absOut}`); + console.log(` ${data.preset || '?'} · ${data.target || '?'} · status: ${data.status || '?'}`); + if (total) console.log(` ${kept}/${total} iterations kept · net: ${data.netDelta || '—'}`); + + if (open) { + const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + execFile(opener, [absOut]); + console.log(` opened: ${absOut}`); + } +} + +main(); diff --git a/skills/browsergnome/scripts/bundle_stats.mjs b/skills/browsergnome/scripts/bundle_stats.mjs new file mode 100644 index 0000000..8cefaf8 --- /dev/null +++ b/skills/browsergnome/scripts/bundle_stats.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * bundle_stats.mjs — parse a bundler's build-time stats output into a + * normalized {bundler, totalBytes, chunks:[{chunk, file, bytes, modules:[{module, bytes}]}]} + * shape. Build-time only, no browser — feeds `bundle-size`'s gate directly, + * and is the exact chunk→module→bytes mapping the LCP Attribution Map's + * apportioned module tier needs (see references/perf-map.md). + * + * Two formats are implemented, both verified against real generated builds + * (not recalled from memory — see selfTest()'s fixture checks): + * + * - **webpack** (`webpack --json > stats.json`): `assets[]` gives real + * shipped bytes per output file; `chunks[]` maps a chunk id to its + * names/files; `modules[]` maps modules to chunk ids via a `chunks: []` + * array on each entry. Production mode's ModuleConcatenationPlugin + * merges multiple source modules into one `modules[]` entry with a + * nested `modules` array (`"./src/index.js + 1 modules"`) — a naive + * parser reading only top-level `modules[].size` silently drops every + * concatenated module. There's also an orphaned duplicate entry per + * concatenated module (same module, `chunks: []`) that must be skipped, + * not counted twice. A second real build with an added `import()` (real + * code-splitting) confirmed multi-chunk attribution keys correctly by + * chunk id, and that webpack's own injected runtime modules + * (`webpack/runtime/...`) land in whichever chunk really ships them, + * which is correct — they're real shipped bytes. + * + * - **esbuild** (`--metafile=meta.json`): `outputs[file].bytes` is the real + * shipped size; `outputs[file].inputs[path].bytesInOutput` is each + * source module's contribution. Flat, no concatenation quirk — the + * simpler of the two real shapes checked here. + * + * Vite/Rollup and Turbopack are explicitly NOT implemented: Vite's CLI + * doesn't emit a stats file by default (would need `rollup-plugin- + * visualizer` or similar, an added dependency in the *target* repo, not + * assumable) and Turbopack has no stable public stats format. `detectFormat` + * returns `null` for anything that isn't webpack or esbuild shaped; callers + * must treat that as "no bundler stats available" and degrade — never guess + * a shape for an unsupported format (see `computeINP`'s interactionId:0 bug + * in trace_metrics.mjs for what guessing field shapes costs). + * + * Usage: + * node bundle_stats.mjs + * node bundle_stats.mjs --self-test + */ + +import fs from 'node:fs'; +import zlib from 'node:zlib'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +// ── Decode ────────────────────────────────────────────────────────────────── + +/** Same gzip-by-magic-byte convention as trace_metrics.mjs's decodeTrace. */ +export function decodeStats(buf) { + const isGzip = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b; + const text = (isGzip ? zlib.gunzipSync(buf) : buf).toString('utf8'); + return JSON.parse(text); +} + +/** + * Which of the two implemented formats this JSON is, or null if neither. + * webpack stats are array-shaped (`assets`/`chunks`/`modules` all arrays); + * esbuild metafiles are object-shaped (`inputs`/`outputs` are keyed by path, + * not arrays) — the two formats don't overlap on this check. + */ +export function detectFormat(json) { + if (Array.isArray(json?.assets) && Array.isArray(json?.chunks) && Array.isArray(json?.modules)) { + return 'webpack'; + } + if (json?.inputs && typeof json.inputs === 'object' && json?.outputs && typeof json.outputs === 'object') { + return 'esbuild'; + } + return null; +} + +// ── Normalizers ───────────────────────────────────────────────────────────── + +/** + * Flatten a webpack `modules[]` entry into {module, bytes} leaves, expanding + * ModuleConcatenationPlugin's nested `modules` array when present (the + * concatenated-entry's own `size` is the pre-summed total of its children, + * so recursing into `modules` and NOT also counting the parent's own `size` + * is what avoids double-counting). + */ +function flattenWebpackModule(m) { + if (Array.isArray(m.modules) && m.modules.length > 0) { + return m.modules.flatMap(flattenWebpackModule); + } + return [{ module: m.name, bytes: m.size ?? 0 }]; +} + +function normalizeWebpack(json) { + const chunkById = new Map(json.chunks.map(c => [c.id, c])); + // asset bytes are the real shipped size (post-minify); keyed by the chunk + // id(s) the asset belongs to. Only count JS assets — a chunk can also emit + // a .css or .map sibling asset under the same chunk id. + const assetBytesByChunkId = new Map(); + for (const asset of json.assets) { + if (!asset.name.endsWith('.js')) continue; + for (const chunkId of asset.chunks ?? []) { + assetBytesByChunkId.set(chunkId, (assetBytesByChunkId.get(chunkId) ?? 0) + (asset.size ?? 0)); + } + } + + // top-level modules with a non-empty `chunks` are real entries; entries + // with `chunks: []` are orphaned duplicates of an already-concatenated + // module (confirmed on a real build — see file header) and must be skipped. + const modulesByChunkId = new Map(); + for (const m of json.modules) { + if (!Array.isArray(m.chunks) || m.chunks.length === 0) continue; + const leaves = flattenWebpackModule(m); + for (const chunkId of m.chunks) { + const list = modulesByChunkId.get(chunkId) ?? []; + list.push(...leaves); + modulesByChunkId.set(chunkId, list); + } + } + + const chunks = []; + for (const [chunkId, chunk] of chunkById) { + const bytes = assetBytesByChunkId.get(chunkId) ?? chunk.size ?? 0; + chunks.push({ + chunk: chunk.names?.[0] ?? String(chunkId), + file: chunk.files?.[0] ?? null, + bytes, + modules: modulesByChunkId.get(chunkId) ?? [], + }); + } + return { bundler: 'webpack', totalBytes: chunks.reduce((s, c) => s + c.bytes, 0), chunks }; +} + +function normalizeEsbuild(json) { + const chunks = Object.entries(json.outputs) + .filter(([file]) => file.endsWith('.js')) + .map(([file, out]) => ({ + chunk: file, + file, + bytes: out.bytes ?? 0, + modules: Object.entries(out.inputs ?? {}).map(([module, info]) => ({ + module, + bytes: info.bytesInOutput ?? 0, + })), + })); + return { bundler: 'esbuild', totalBytes: chunks.reduce((s, c) => s + c.bytes, 0), chunks }; +} + +/** + * Parse + normalize in one call. Returns `{error}` (never throws) for an + * unrecognized format — Vite/Turbopack callers must check for `.error` and + * degrade to "no bundler stats available" rather than guessing a shape. + */ +export function parseBundleStats(json) { + const format = detectFormat(json); + if (format === 'webpack') return normalizeWebpack(json); + if (format === 'esbuild') return normalizeEsbuild(json); + return { error: 'unrecognized bundler stats format — only webpack (--json) and esbuild (--metafile) are implemented; Vite/Turbopack are not (see file header)' }; +} + +// ── Self-test ─────────────────────────────────────────────────────────────── + +function selfTest() { + let pass = 0, fail = 0; + const check = (name, got, want) => { + const eq = JSON.stringify(got) === JSON.stringify(want); + console.log(` ${eq ? 'PASS' : 'FAIL'} ${name} (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); + eq ? pass++ : fail++; + }; + + check('detectFormat: unrecognized shape returns null', detectFormat({ foo: 1 }), null); + check('parseBundleStats: unrecognized shape reports error, not a throw', parseBundleStats({ foo: 1 }).error != null, true); + + // shipped fixture 1: a real `webpack --json` production build this + // session, ModuleConcatenationPlugin ON (the default in production mode) — + // exercises the concatenated-module + orphan-duplicate handling for real, + // not via hand-written synthetic data mimicking a guess at the shape. + try { + const path = new URL('../assets/bundle-stats.webpack-sample.json.gz', import.meta.url); + const json = decodeStats(fs.readFileSync(path)); + check('webpack fixture: detected as webpack', detectFormat(json), 'webpack'); + const result = parseBundleStats(json); + check('webpack fixture: one chunk (main)', result.chunks.length, 1); + const main = result.chunks[0]; + check('webpack fixture: chunk bytes = real shipped asset size (372), not the pre-minify module sum (308)', main.bytes, 372); + check('webpack fixture: both concatenated modules recovered, not just the outer entry', main.modules.length, 2); + check('webpack fixture: module bytes sum to the pre-concatenation module total (308)', + main.modules.reduce((s, m) => s + m.bytes, 0), 308); + check('webpack fixture: no double-count from the orphaned duplicate entry', + result.totalBytes, 372); + } catch (err) { + console.log(` FAIL webpack fixture decodes and parses (threw: ${err.message})`); + fail++; + } + + // shipped fixture 1b: same real build, but with a dynamic `import()` added + // (real code-splitting, not simulated) — exercises chunk-id keying across + // more than one chunk, including webpack's own injected runtime modules + // (`webpack/runtime/...`) landing in the main chunk's module list, which + // is correct: they really do ship real bytes in that chunk. + try { + const path = new URL('../assets/bundle-stats.webpack-multichunk-sample.json.gz', import.meta.url); + const json = decodeStats(fs.readFileSync(path)); + const result = parseBundleStats(json); + check('multi-chunk fixture: two chunks (main + lazy)', result.chunks.length, 2); + const lazy = result.chunks.find(c => c.file === '899.wp-out.js'); + check('multi-chunk fixture: lazy chunk isolated to its own bytes, not merged into main', lazy?.bytes, 435); + check('multi-chunk fixture: lazy chunk has only its own module, not main\'s', lazy?.modules, [{ module: './src/lazy.js', bytes: 74 }]); + const main = result.chunks.find(c => c.file === 'wp-out.js'); + check('multi-chunk fixture: main chunk does not pick up the lazy module', main?.modules.some(m => m.module === './src/lazy.js'), false); + } catch (err) { + console.log(` FAIL multi-chunk fixture decodes and parses (threw: ${err.message})`); + fail++; + } + + // shipped fixture 2: a real `esbuild --metafile` build. + try { + const path = new URL('../assets/bundle-stats.esbuild-sample.json.gz', import.meta.url); + const json = decodeStats(fs.readFileSync(path)); + check('esbuild fixture: detected as esbuild', detectFormat(json), 'esbuild'); + const result = parseBundleStats(json); + check('esbuild fixture: one chunk (single entry, no code-splitting)', result.chunks.length, 1); + const main = result.chunks[0]; + check('esbuild fixture: chunk bytes = real output size', main.bytes, 945); + check('esbuild fixture: two modules recovered', main.modules.length, 2); + check('esbuild fixture: per-module bytesInOutput, not raw input bytes', + main.modules.find(m => m.module === 'src/util.js')?.bytes, 253); + } catch (err) { + console.log(` FAIL esbuild fixture decodes and parses (threw: ${err.message})`); + fail++; + } + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + const filePath = process.argv.slice(2).find(a => !a.startsWith('--')); + if (!filePath) { + console.error('usage: node bundle_stats.mjs '); + console.error(' node bundle_stats.mjs --self-test'); + process.exit(1); + } + if (!fs.existsSync(filePath)) { + console.error(`file not found: ${filePath}`); + process.exit(1); + } + const json = decodeStats(fs.readFileSync(filePath)); + console.log(JSON.stringify(parseBundleStats(json), null, 2)); +} + +// See trace_metrics.mjs's identical guard (and its comment on why +// pathToFileURL, not a hand-built `file://` string) for why this matters — +// lcp_attribution.mjs imports parseBundleStats from this file directly. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/skills/browsergnome/scripts/check_invariants.mjs b/skills/browsergnome/scripts/check_invariants.mjs new file mode 100644 index 0000000..9483837 --- /dev/null +++ b/skills/browsergnome/scripts/check_invariants.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * check_invariants.mjs — gates CLAUDE.md's "Cross-file invariants" section. + * + * Those invariants have no single source of truth by design (a version pin + * duplicated into human-readable docs, a config key mirrored into a schema + * table) — an edit to one side silently breaks the other. This turns the + * four *mechanically* checkable ones into a CI failure instead of tribal + * knowledge. Run from this repo's root only (unlike doctor.mjs, which runs + * from a target repo). + * + * Deliberately NOT covered here — see CONTRIBUTING.md: + * - "one confident, unhedged voice" across product docs — editorial, not + * mechanical. + * - `Guidance:` ids resolve against the external modern-web-guidance + * catalog, which isn't installed in CI — a check here would be theatre. + * + * Usage: + * node check_invariants.mjs # run the real checks against this repo, exit 0/1 + * node check_invariants.mjs --self-test + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { checkVersionPin } from './doctor.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const read = (p) => fs.readFileSync(p, 'utf8'); + +// ── Pure checks (inputs are strings/arrays — no I/O) ──────────────────────── + +/** + * Stack-catalog filenames (references/frameworks|bundlers|hosts/*.md) must + * match doctor.mjs's detectFramework/detectBundler/detectHost return strings + * verbatim, in both directions. + * @param {string[]} catalogNames - basenames without extension + * @param {string[]} detectorValues - non-"unknown" return values from the source + * @returns {{ok:boolean, missingCatalog:string[], orphanCatalog:string[]}} + */ +export function checkCatalogNames(catalogNames, detectorValues) { + const catalogSet = new Set(catalogNames); + const detectorSet = new Set(detectorValues); + const missingCatalog = detectorValues.filter((v) => !catalogSet.has(v)); + const orphanCatalog = catalogNames.filter((n) => !detectorSet.has(n)); + return { ok: missingCatalog.length === 0 && orphanCatalog.length === 0, missingCatalog, orphanCatalog }; +} + +/** + * depPulse / depPulseAutoApply must exist in both SKILL.md's config schema + * table and doctor.mjs's DEFAULT_CONFIG. + * @param {string} skillMdText + * @param {string} doctorMjsText + * @returns {{ok:boolean, missingFromSkillMd:string[], missingFromDoctor:string[]}} + */ +export function checkDepPulseKeys(skillMdText, doctorMjsText) { + const keys = ['depPulse', 'depPulseAutoApply']; + const missingFromSkillMd = keys.filter((k) => !new RegExp('`' + k + '`').test(skillMdText)); + const missingFromDoctor = keys.filter((k) => !new RegExp('\\b' + k + '\\s*:').test(doctorMjsText)); + return { ok: missingFromSkillMd.length === 0 && missingFromDoctor.length === 0, missingFromSkillMd, missingFromDoctor }; +} + +/** + * pages.yml's EXCALIDRAW_SHA must match the short SHA in perf-map.md's + * calibration note — bumping one without re-verifying the hotspot count + * against the other is exactly the drift this guards. + * @param {string} pagesYmlText + * @param {string} perfMapMdText + * @returns {{ok:boolean, workflowSha:string|null, docsSha:string|null}} + */ +export function checkPerfMapCalibration(pagesYmlText, perfMapMdText) { + const workflowMatch = pagesYmlText.match(/EXCALIDRAW_SHA:\s*([0-9a-f]+)/); + const docsMatch = perfMapMdText.match(/`([0-9a-f]{8,})`,\s*527 modules/); + const workflowSha = workflowMatch ? workflowMatch[1] : null; + const docsSha = docsMatch ? docsMatch[1] : null; + return { ok: workflowSha != null && docsSha != null && workflowSha.startsWith(docsSha), workflowSha, docsSha }; +} + +// ── Self-test ──────────────────────────────────────────────────────────── + +function selfTest() { + let pass = 0, fail = 0; + const check = (name, got, want) => { + const ok = JSON.stringify(got) === JSON.stringify(want); + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name} (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); + ok ? pass++ : fail++; + }; + + check('checkCatalogNames: agreement', checkCatalogNames(['vite', 'webpack'], ['vite', 'webpack']).ok, true); + check('checkCatalogNames: catches an orphan catalog file', checkCatalogNames(['vite', 'rollup'], ['vite']).orphanCatalog, ['rollup']); + check('checkCatalogNames: catches a missing catalog file', checkCatalogNames(['vite'], ['vite', 'rspack']).missingCatalog, ['rspack']); + + check('checkDepPulseKeys: both present', checkDepPulseKeys('| `depPulse` |\n| `depPulseAutoApply` |', 'depPulse: true,\ndepPulseAutoApply: true,').ok, true); + check('checkDepPulseKeys: catches SKILL.md drift', checkDepPulseKeys('| `depPulse` |', 'depPulse: true,\ndepPulseAutoApply: true,').missingFromSkillMd, ['depPulseAutoApply']); + check('checkDepPulseKeys: catches doctor.mjs drift', checkDepPulseKeys('| `depPulse` |\n| `depPulseAutoApply` |', 'depPulse: true,').missingFromDoctor, ['depPulseAutoApply']); + + check('checkPerfMapCalibration: agreement (short SHA is a prefix)', checkPerfMapCalibration('EXCALIDRAW_SHA: 4872083c044491b6d5c96ae134a75464f96d6831', '`4872083c`, 527 modules').ok, true); + check('checkPerfMapCalibration: catches drift', checkPerfMapCalibration('EXCALIDRAW_SHA: deadbeef00000000000000000000000000000000', '`4872083c`, 527 modules').ok, false); + + check('checkVersionPin (re-exported from doctor.mjs): still agrees', checkVersionPin('{"mcpServers":{"chrome-devtools":{"args":["chrome-devtools-mcp@1.6.0"]}}}', '**Pinned: `chrome-devtools-mcp@1.6.0`.**').agree, true); + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +} + +// ── Real run ───────────────────────────────────────────────────────────── + +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + let pass = 0, fail = 0; + const report = (name, ok, detail) => { + console.log(` ${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + ok ? pass++ : fail++; + }; + + const doctorMjsText = read(path.join(repoRoot, 'skills/browsergnome/scripts/doctor.mjs')); + + // 1. chrome-devtools-mcp pin + const mcpJsonText = read(path.join(repoRoot, '.mcp.json')); + const toolsMdText = read(path.join(repoRoot, 'skills/browsergnome/references/tools.md')); + const pin = checkVersionPin(mcpJsonText, toolsMdText); + report('chrome-devtools-mcp pin (.mcp.json <-> references/tools.md)', pin.agree, + pin.agree ? `@${pin.mcpPin}` : `mcp=@${pin.mcpPin ?? '?'} docs=@${pin.docsPin ?? '?'}`); + + // 2. Stack-catalog filenames vs detector return strings + const axisField = { frameworks: 'framework', bundlers: 'bundler', hosts: 'host' }; + const detectorNames = { frameworks: 'detectFramework', bundlers: 'detectBundler', hosts: 'detectHost' }; + for (const [axis, field] of Object.entries(axisField)) { + const catalogDir = path.join(repoRoot, 'skills/browsergnome/references', axis); + const catalogNames = fs.readdirSync(catalogDir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')); + const fnMatch = doctorMjsText.match(new RegExp(`function ${detectorNames[axis]}\\([^)]*\\) \\{([\\s\\S]*?)\\n\\}`)); + const fnBody = fnMatch ? fnMatch[1] : ''; + // `field: 'value'` (direct) and `field: cond ? 'a' : 'b'` / `field = cond ? 'a' : 'b'` + // (ternary) both appear across the three detectors — capture the RHS expression up to the + // statement end, then pull every quoted literal out of it, so a new ternary branch is picked + // up without this script's regex needing to change. + const rhsExprs = [...fnBody.matchAll(new RegExp(`\\b${field}\\s*[:=]\\s*([^,;\\n]+)`, 'g'))].map((m) => m[1]); + const detectorValues = rhsExprs.flatMap((expr) => [...expr.matchAll(/'([\w-]+)'/g)].map((m) => m[1])).filter((v) => v !== 'unknown'); + const result = checkCatalogNames(catalogNames, [...new Set(detectorValues)]); + report(`${axis} catalog <-> ${detectorNames[axis]}()`, result.ok, + result.ok ? undefined : `missing catalog file for [${result.missingCatalog}], orphan catalog file(s) [${result.orphanCatalog}]`); + } + + // 3. depPulse config keys + const skillMdText = read(path.join(repoRoot, 'skills/browsergnome/SKILL.md')); + const depPulse = checkDepPulseKeys(skillMdText, doctorMjsText); + report('depPulse/depPulseAutoApply (SKILL.md <-> doctor.mjs DEFAULT_CONFIG)', depPulse.ok, + depPulse.ok ? undefined : `missing from SKILL.md: [${depPulse.missingFromSkillMd}], missing from doctor.mjs: [${depPulse.missingFromDoctor}]`); + + // 4. Perf Map calibration SHA + const pagesYmlText = read(path.join(repoRoot, '.github/workflows/pages.yml')); + const perfMapMdText = read(path.join(repoRoot, 'skills/browsergnome/references/perf-map.md')); + const calibration = checkPerfMapCalibration(pagesYmlText, perfMapMdText); + report('Perf Map calibration SHA (pages.yml <-> perf-map.md)', calibration.ok, + calibration.ok ? calibration.workflowSha.slice(0, 8) : `workflow=${calibration.workflowSha ?? '?'} docs=${calibration.docsSha ?? '?'}`); + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +} + +main(); diff --git a/skills/browsergnome/scripts/crux.mjs b/skills/browsergnome/scripts/crux.mjs new file mode 100644 index 0000000..88873ed --- /dev/null +++ b/skills/browsergnome/scripts/crux.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * crux.mjs — real-user field data (CrUX) for the LCP Attribution Map's third + * tier, alongside the lab (unthrottled) and throttled-reference readouts. + * Build-time only: `build_lcp_map.mjs` fetches this once when the map is + * built, so the shipped HTML stays a standalone offline file. + * + * Two paths, in preference order: + * - the canonical CrUX API (`records:queryRecord`) — needs a free Google + * Cloud API key, returns origin-level p75 + histograms directly. + * - keyless PageSpeed Insights (`runPagespeed`) — same underlying field + * data via `originLoadingExperience`/`loadingExperience`, no key needed, + * used whenever no `cruxApiKey` is configured (the common case). + * + * Both APIs report field data for an ORIGIN (all pages aggregated) or a + * single URL; this always queries by origin, since "production at max" for + * a whole app is the origin-level number, not one route's. + * + * `fetch` is injectable so selfTest() runs fully offline. + * + * Usage: + * import { getFieldData } from './crux.mjs' + * node crux.mjs --self-test + */ + +import process from 'node:process'; + +const CRUX_ENDPOINT = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord'; +const PSI_ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'; + +// Standard Core Web Vitals good/needs-improvement/poor boundaries — [good-max, poor-min]. +// ms for everything except cls (unitless). +const THRESHOLDS = { + lcp: [2500, 4000], + fcp: [1800, 3000], + cls: [0.1, 0.25], + inp: [200, 500], + ttfb: [800, 1800], +}; + +export function rateMetric(key, value) { + if (value == null) return null; + const t = THRESHOLDS[key]; + if (!t) return null; + if (value <= t[0]) return 'good'; + if (value > t[1]) return 'poor'; + return 'ni'; +} + +// CrUX API: metrics keyed by snake_case; every percentile is a raw ms +// integer except cumulative_layout_shift, whose p75 ships as a string +// decimal (already unscaled — see Chrome's own API docs). +const CRUX_METRIC_KEYS = { + lcp: 'largest_contentful_paint', + fcp: 'first_contentful_paint', + cls: 'cumulative_layout_shift', + inp: 'interaction_to_next_paint', + ttfb: 'experimental_time_to_first_byte', +}; + +export function normalizeCruxResponse(json) { + const metrics = json?.record?.metrics || {}; + const out = {}; + for (const [key, cruxKey] of Object.entries(CRUX_METRIC_KEYS)) { + const p75 = metrics[cruxKey]?.percentiles?.p75; + const value = p75 == null ? null : key === 'cls' ? parseFloat(p75) : Number(p75); + out[key] = { p75: value, rating: rateMetric(key, value) }; + } + return out; +} + +// PSI carries the same field data under SCREAMING_SNAKE keys. Its own +// FAST/AVERAGE/SLOW/NONE category is used as-is when present (it's Google's +// judgement of the same number) rather than re-deriving from THRESHOLDS. +// CLS's percentile is scaled x100 (a value of 15 means CLS 0.15) — every +// other metric is a raw ms integer. +const PSI_METRIC_KEYS = { + lcp: 'LARGEST_CONTENTFUL_PAINT_MS', + fcp: 'FIRST_CONTENTFUL_PAINT_MS', + cls: 'CUMULATIVE_LAYOUT_SHIFT_SCORE', + inp: 'INTERACTION_TO_NEXT_PAINT', + ttfb: 'EXPERIMENTAL_TIME_TO_FIRST_BYTE', +}; +const PSI_CATEGORY = { FAST: 'good', AVERAGE: 'ni', SLOW: 'poor', NONE: null }; + +export function normalizePsiResponse(json) { + const metrics = (json?.originLoadingExperience || json?.loadingExperience)?.metrics || {}; + const out = {}; + for (const [key, psiKey] of Object.entries(PSI_METRIC_KEYS)) { + const m = metrics[psiKey]; + if (!m) { out[key] = { p75: null, rating: null }; continue; } + const value = key === 'cls' ? m.percentile / 100 : m.percentile; + out[key] = { p75: value, rating: PSI_CATEGORY[m.category] ?? rateMetric(key, value) }; + } + return out; +} + +/** + * Fetch real-user field data for `prodUrl`'s origin. Never throws — any + * failure (invalid URL, offline, no `fetch` global, non-2xx, no field data + * for this origin — CrUX/PSI both return this for low-traffic origins) + * returns null so the map still builds without this tier. + */ +export async function getFieldData(prodUrl, { apiKey, fetchImpl, timeoutMs = 8000 } = {}) { + const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null); + if (!doFetch) return null; + let origin; + try { origin = new URL(prodUrl).origin; } catch { return null; } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + if (apiKey) { + const res = await doFetch(`${CRUX_ENDPOINT}?key=${encodeURIComponent(apiKey)}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ origin, formFactor: 'DESKTOP' }), + signal: controller.signal, + }); + if (!res.ok) return null; + return { source: 'crux', url: origin, metrics: normalizeCruxResponse(await res.json()) }; + } + const res = await doFetch(`${PSI_ENDPOINT}?url=${encodeURIComponent(origin)}&strategy=DESKTOP`, { signal: controller.signal }); + if (!res.ok) return null; + const json = await res.json(); + if (!json?.originLoadingExperience && !json?.loadingExperience) return null; + return { source: 'psi', url: origin, metrics: normalizePsiResponse(json) }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +// ── Self-test ─────────────────────────────────────────────────────────────── + +function selfTest() { + let pass = 0, fail = 0; + const check = (name, got, want) => { + const eq = JSON.stringify(got) === JSON.stringify(want); + console.log(` ${eq ? 'PASS' : 'FAIL'} ${name} (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); + eq ? pass++ : fail++; + }; + + // rateMetric — boundary values + check('rateMetric: exactly at good boundary -> good', rateMetric('lcp', 2500), 'good'); + check('rateMetric: just past good boundary -> ni', rateMetric('lcp', 2501), 'ni'); + check('rateMetric: exactly at poor boundary -> ni (poor is strictly greater)', rateMetric('lcp', 4000), 'ni'); + check('rateMetric: just past poor boundary -> poor', rateMetric('lcp', 4001), 'poor'); + check('rateMetric: null value -> null', rateMetric('lcp', null), null); + check('rateMetric: unknown key -> null', rateMetric('bogus', 100), null); + + // normalizeCruxResponse — real documented shape, cls as a string decimal + const cruxFixture = { + record: { + key: { origin: 'https://example.com' }, + metrics: { + largest_contentful_paint: { percentiles: { p75: 2400 } }, + first_contentful_paint: { percentiles: { p75: 1500 } }, + cumulative_layout_shift: { percentiles: { p75: '0.05' } }, + interaction_to_next_paint: { percentiles: { p75: 150 } }, + experimental_time_to_first_byte: { percentiles: { p75: 600 } }, + }, + }, + }; + const cruxNorm = normalizeCruxResponse(cruxFixture); + check('normalizeCruxResponse: lcp p75 + good rating', cruxNorm.lcp, { p75: 2400, rating: 'good' }); + check('normalizeCruxResponse: cls string decimal parsed to a number, not left as a string', cruxNorm.cls.p75, 0.05); + check('normalizeCruxResponse: cls rating from the parsed number', cruxNorm.cls.rating, 'good'); + check('normalizeCruxResponse: missing metrics object -> every field null, no throw', normalizeCruxResponse({}).lcp, { p75: null, rating: null }); + + // normalizePsiResponse — category used as-is; cls percentile /100 + const psiFixture = { + originLoadingExperience: { + metrics: { + LARGEST_CONTENTFUL_PAINT_MS: { percentile: 4200, category: 'SLOW' }, + FIRST_CONTENTFUL_PAINT_MS: { percentile: 1700, category: 'FAST' }, + CUMULATIVE_LAYOUT_SHIFT_SCORE: { percentile: 5, category: 'FAST' }, + INTERACTION_TO_NEXT_PAINT: { percentile: 600, category: 'SLOW' }, + EXPERIMENTAL_TIME_TO_FIRST_BYTE: { percentile: 900, category: 'AVERAGE' }, + }, + }, + }; + const psiNorm = normalizePsiResponse(psiFixture); + check('normalizePsiResponse: lcp uses PSI\'s own SLOW category, not a re-derived rating', psiNorm.lcp, { p75: 4200, rating: 'poor' }); + check('normalizePsiResponse: cls percentile scaled /100 (5 -> 0.05)', psiNorm.cls.p75, 0.05); + check('normalizePsiResponse: falls back to loadingExperience when originLoadingExperience is absent', + normalizePsiResponse({ loadingExperience: psiFixture.originLoadingExperience }).lcp.p75, 4200); + check('normalizePsiResponse: metric absent from response (e.g. pre-INP PSI payload) -> null, not a throw', + normalizePsiResponse({ originLoadingExperience: { metrics: {} } }).inp, { p75: null, rating: null }); + + // getFieldData — offline, fully injected fetch + (async () => { + let calls = 0; + const okFetch = (body) => async () => { calls++; return { ok: true, json: async () => body }; }; + + const invalidUrlCalls = calls; + const r1 = await getFieldData('not a url', { fetchImpl: async () => { calls++; return { ok: true, json: async () => ({}) }; } }); + check('getFieldData: invalid prodUrl -> null, without ever calling fetch', [r1, calls - invalidUrlCalls], [null, 0]); + + const r2 = await getFieldData('https://example.com/some/page', { apiKey: 'k', fetchImpl: okFetch(cruxFixture) }); + check('getFieldData: with an apiKey, uses the CrUX API and normalizes it', [r2.source, r2.url, r2.metrics.lcp.p75], ['crux', 'https://example.com', 2400]); + + const r3 = await getFieldData('https://example.com', { fetchImpl: okFetch(psiFixture) }); + check('getFieldData: no apiKey, falls back to keyless PSI', [r3.source, r3.metrics.cls.p75], ['psi', 0.05]); + + const r4 = await getFieldData('https://example.com', { fetchImpl: async () => ({ ok: false }) }); + check('getFieldData: non-2xx response -> null', r4, null); + + const r5 = await getFieldData('https://example.com', { fetchImpl: async () => { throw new Error('offline'); } }); + check('getFieldData: fetch throws (offline/aborted) -> null, not an unhandled rejection', r5, null); + + const r6 = await getFieldData('https://example.com', { fetchImpl: okFetch({}) }); + check('getFieldData: 2xx but no field data for this origin -> null, not an empty metrics object', r6, null); + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); + })(); +} + +if (process.argv.includes('--self-test')) selfTest(); diff --git a/skills/browsergnome/scripts/doctor.mjs b/skills/browsergnome/scripts/doctor.mjs new file mode 100644 index 0000000..60ff066 --- /dev/null +++ b/skills/browsergnome/scripts/doctor.mjs @@ -0,0 +1,475 @@ +#!/usr/bin/env node +/** + * doctor.mjs — browsergnome preflight + first-run setup. + * + * Run from the TARGET web repo. Auto-bootstraps the .bgn/ workspace, detects + * git state, and detects framework/bundler/host on three independent axes + * (unknown on any axis is fine — the loop still runs on the generic catalog). + * + * The web needs one MCP (chrome-devtools-mcp, which manages its own browser + * process) and a URL — no simulator, no device, no CLI daemon to probe. + * What this script CANNOT do is speak the MCP protocol to verify the live + * tool surface — that would mean bundling an MCP client into a preflight + * script, an extra dependency this project avoids. Instead it cross-checks + * the two static sources + * that should agree (`.mcp.json`'s pin vs `references/tools.md`'s + * documented pin) and leaves live-drift detection to the agent, at the + * point it actually calls a tool (see SKILL.md's Scope & delegation note). + * + * Usage: + * node doctor.mjs # report checklist + setup actions + * node doctor.mjs --init # also create .bgn/ if missing + * node doctor.mjs --url # verify the target URL responds + * node doctor.mjs --self-test # run parser unit assertions (CI contract), exit 0/1 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { execSync, execFileSync } from 'node:child_process'; +import os from 'node:os'; +import { pathToFileURL } from 'node:url'; + +// ── Pure detection functions (exported; no I/O — all inputs are strings/booleans) ─ + +/** + * Classify git repository state from raw command output — four states: + * usable / no-repo / no-commits / detached. + * @param {{insideWorkTree:boolean|null, headSha:string|null, symbolicRef:string|null, porcelain?:string}} opts + * @returns {{ state:'usable'|'no-repo'|'no-commits'|'detached', branch:string|null, dirty:string[] }} + */ +export function parseGitState({ insideWorkTree, headSha, symbolicRef, porcelain = '' }) { + if (!insideWorkTree) return { state: 'no-repo', branch: null, dirty: [] }; + if (!headSha) return { state: 'no-commits', branch: null, dirty: [] }; + if (!symbolicRef) return { state: 'detached', branch: null, dirty: [] }; + const branch = symbolicRef.replace('refs/heads/', ''); + const dirty = porcelain ? porcelain.split('\n').map(l => l.trim()).filter(Boolean) : []; + return { state: 'usable', branch, dirty }; +} + +/** + * Framework axis. Unknown is a legitimate result, not a failure — the loop + * falls back to the generic knowledge base. + * @param {{hasNextConfig:boolean, hasAppDir:boolean, hasPagesDir:boolean, hasRemixConfig:boolean, hasTanstackStartDep:boolean, hasViteConfig:boolean}} opts + */ +export function detectFramework({ hasNextConfig, hasAppDir, hasPagesDir, hasRemixConfig, hasTanstackStartDep, hasViteConfig }) { + if (hasNextConfig) { + const framework = hasAppDir ? 'next-app-router' : hasPagesDir ? 'next-pages' : 'next-app-router'; + return { framework, source: 'next.config' }; + } + if (hasRemixConfig) return { framework: 'remix', source: 'remix.config' }; + if (hasTanstackStartDep) return { framework: 'tanstack-start', source: 'package.json dependency' }; + if (hasViteConfig) return { framework: 'vite-spa', source: 'vite.config' }; + return { framework: 'unknown', source: 'none' }; +} + +/** + * Bundler axis. Infers from framework when no bundler config file exists + * (e.g. Next.js ships webpack/turbopack without a standalone config). + * @param {{framework:string, nextConfigText:string, hasWebpackConfig:boolean, hasRspackConfig:boolean, hasViteConfig:boolean, hasEsbuildMetafile:boolean}} opts + */ +export function detectBundler({ framework, nextConfigText = '', hasWebpackConfig, hasRspackConfig, hasViteConfig, hasEsbuildMetafile }) { + if (hasRspackConfig) return { bundler: 'rspack', source: 'rspack.config' }; + if (hasWebpackConfig) return { bundler: 'webpack', source: 'webpack.config' }; + if (hasEsbuildMetafile) return { bundler: 'esbuild', source: 'metafile' }; + if (framework === 'next-app-router' || framework === 'next-pages') { + // Two config shapes exist across Next.js versions: `experimental.turbo` (13-15.x) and a + // top-level `turbopack` key (16+, after the `next-experimental-turbo-to-turbopack` codemod). + // `\bturbo\s*:` alone matches the old key but not the new one — "turbopack:" has no word + // boundary between "turbo" and "pack", so it silently fell through to the webpack default on + // a Next 16 config. `turbo(pack)?\s*:` catches both without executing the config file. + const turbo = /\bturbo(pack)?\s*:/.test(nextConfigText); + return { bundler: turbo ? 'turbopack' : 'webpack', source: turbo ? 'next.config turbo key' : 'next.js default' }; + } + if (hasViteConfig || framework === 'vite-spa' || framework === 'tanstack-start') { + return { bundler: 'vite', source: hasViteConfig ? 'vite.config' : 'framework default' }; + } + return { bundler: 'unknown', source: 'none' }; +} + +/** + * Host axis. + * @param {{hasVercelJson:boolean, hasVercelDir:boolean, hasWranglerToml:boolean, hasHeadersFile:boolean, hasDockerfile:boolean, hasServerJs:boolean}} opts + */ +export function detectHost({ hasVercelJson, hasVercelDir, hasWranglerToml, hasHeadersFile, hasDockerfile, hasServerJs }) { + if (hasVercelJson || hasVercelDir) return { host: 'vercel', source: 'vercel.json/.vercel' }; + if (hasWranglerToml || hasHeadersFile) return { host: 'cloudflare', source: 'wrangler.toml/_headers' }; + if (hasDockerfile || hasServerJs) return { host: 'node', source: 'Dockerfile/server.js' }; + return { host: 'unknown', source: 'none' }; +} + +/** + * Parse the pinned version out of `.mcp.json`'s chrome-devtools args + * (`["chrome-devtools-mcp@1.6.0"]` → `"1.6.0"`), and out of + * `references/tools.md`'s `**Pinned: \`chrome-devtools-mcp@1.6.0\`.**` line. + * The two must agree — this is a config-consistency check, not a live + * protocol check (see file header). + * @param {string|null} mcpJsonText + * @param {string|null} toolsMdText + * @returns {{ mcpPin:string|null, docsPin:string|null, agree:boolean }} + */ +export function checkVersionPin(mcpJsonText, toolsMdText) { + const mcpMatch = mcpJsonText?.match(/chrome-devtools-mcp@([\d.]+)/); + const docsMatch = toolsMdText?.match(/Pinned:\s*`chrome-devtools-mcp@([\d.]+)`/); + const mcpPin = mcpMatch ? mcpMatch[1] : null; + const docsPin = docsMatch ? docsMatch[1] : null; + return { mcpPin, docsPin, agree: mcpPin != null && mcpPin === docsPin }; +} + +/** + * Parse a simple `curl -sI -o /dev/null -w '%{http_code}'`-style status + * code string. + * @param {string|null} statusCodeOut + * @returns {{ reachable:boolean, status:number|null }} + */ +export function parseUrlProbe(statusCodeOut) { + const status = statusCodeOut ? parseInt(statusCodeOut, 10) : NaN; + if (isNaN(status) || status === 0) return { reachable: false, status: null }; + return { reachable: status < 500, status }; +} + +// ── Module-level helpers ────────────────────────────────────────────────────── + +const ok = (b) => (b ? '\x1b[32mOK \x1b[0m' : '\x1b[31mXX \x1b[0m'); +const warn = '\x1b[33m?? \x1b[0m'; + +function sh(cmd) { + try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } +} +function read(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } } +function exists(p) { return fs.existsSync(p); } + +const repo = process.cwd(); +const doInit = process.argv.includes('--init'); +const urlIdx = process.argv.indexOf('--url'); +const targetUrl = urlIdx !== -1 ? process.argv[urlIdx + 1] : null; + +// ── Bootstrap .bgn/ workspace ────────────────────────────────────────────────── + +const PERF_MEMORY_HEADER = (name) => `# Performance Memory — ${name} + +> browsergnome's per-repo brain. One line per perf gap: +> \`area/file · symptom · suspected cause · preset · status(open|fixed|reverted) · ref\` +> Read at the start of any perf work. Commit this file with the app. + + +`; + +const DEFAULT_CONFIG = (axes) => ({ + commitMode: 'per-iteration', // "per-iteration" | "one-commit" | "no-commit" + liveReport: false, // write/refresh .bgn/report.html during the run + openReport: true, // auto-open report.html when liveReport is on + runs: 5, // N measurement runs per iteration + warmupDiscard: 1, // warm-up runs to discard + k: 2, // gate noise multiplier + budget: 6, // max iterations per run (0 = run until no fix clears gate) + interleaved: true, // ABABAB measurement — see references/measurement.md + framework: axes.framework, + bundler: axes.bundler, + host: axes.host, + emulate: { + cpuThrottlingRate: 1, // 1-20; 1 = no slowdown, matches DevTools/Lighthouse-adjacent numbers + networkConditions: null, // null | 'Offline' | 'Slow 3G' | 'Fast 3G' | 'Slow 4G' | 'Fast 4G' + viewport: '1280x720x1', + }, + depPulse: true, // dispatch the ambient Dep Pulse subagent (Autoresearch Preflight / Senior Engineer Audit) — see references/dep-pulse.md. A direct dependency ask runs Dep Pulse standalone regardless of this flag. + depPulseAutoApply: true, // benign patch bumps (conditions 1-3) skip the pre-install confirm + prodUrl: null, // production origin for the LCP Attribution Map's CrUX field-data tier — auto-detected/prompted at build time if unset + cruxApiKey: null, // optional Google Cloud API key for the canonical CrUX API; omit to use keyless PageSpeed Insights (same data, no key) +}); + +// Fills only top-level keys missing from an existing config.json — never overwrites a user's value, +// never descends into nested objects (e.g. emulate). Mirrors the .gitignore backfill below: an old +// .bgn/ predates newer DEFAULT_CONFIG keys and would otherwise read them as undefined forever. +function backfillConfig(existing, defaults) { + if (!existing || typeof existing !== 'object') existing = {}; + const added = Object.keys(defaults).filter(k => !(k in existing)); + const config = added.length ? { ...defaults, ...existing } : existing; + return { config, added }; +} + +const GITIGNORE_CONTENT = `# browsergnome — generated run artifacts (not committed with the app) +report.html +run-state.json +dep-pulse.json +`; + +function bootstrap(axes) { + const dir = path.join(repo, '.bgn'); + fs.mkdirSync(path.join(dir, 'ledger'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'archive'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'audit'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'what-if'), { recursive: true }); + + const mem = path.join(dir, 'perf-memory.md'); + if (!fs.existsSync(mem)) fs.writeFileSync(mem, PERF_MEMORY_HEADER(path.basename(repo))); + + // .bgn/ doesn't exist yet at this point (bootstrap only runs when !bgnExists in main()), + // so config.json can't already exist either — no backfill branch needed here. + const cfg = path.join(dir, 'config.json'); + fs.writeFileSync(cfg, JSON.stringify(DEFAULT_CONFIG(axes), null, 2) + '\n'); + + const gi = path.join(dir, '.gitignore'); + if (!fs.existsSync(gi)) { + fs.writeFileSync(gi, GITIGNORE_CONTENT); + } else { + // repo bootstrapped before a line was added to GITIGNORE_CONTENT — backfill, don't just skip + const existing = fs.readFileSync(gi, 'utf8'); + const missing = GITIGNORE_CONTENT.split('\n').filter(l => l && !l.startsWith('#') && !existing.includes(l)); + if (missing.length) fs.appendFileSync(gi, (existing.endsWith('\n') ? '' : '\n') + missing.join('\n') + '\n'); + } + + console.log(` bootstrapped ${path.relative(repo, dir) || '.bgn'}/ (perf-memory.md, config.json, ledger/, archive/, audit/, what-if/)`); + console.log(` .bgn/.gitignore created — report.html, run-state.json, and dep-pulse.json are gitignored`); +} + +// ── Self-test (CI contract — short-circuits before main() I/O) ──────────────── + +function selfTest() { + let pass = 0, fail = 0; + const check = (name, got, want) => { + const eq = JSON.stringify(got) === JSON.stringify(want); + console.log(` ${eq ? 'PASS' : 'FAIL'} ${name} (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); + eq ? pass++ : fail++; + }; + + // parseGitState — four states + check('no-repo → state', parseGitState({ insideWorkTree: false }).state, 'no-repo'); + check('no-commits → state', parseGitState({ insideWorkTree: true, headSha: null }).state, 'no-commits'); + check('detached → state', parseGitState({ insideWorkTree: true, headSha: 'a', symbolicRef: null }).state, 'detached'); + { + const r = parseGitState({ insideWorkTree: true, headSha: 'a', symbolicRef: 'refs/heads/main', porcelain: ' M a.js\n?? b.js' }); + check('usable → branch', r.branch, 'main'); + check('usable → dirty length', r.dirty.length, 2); + } + + // detectFramework + { + check('next.config + app/ → next-app-router', detectFramework({ hasNextConfig: true, hasAppDir: true }).framework, 'next-app-router'); + check('next.config + pages/ → next-pages', detectFramework({ hasNextConfig: true, hasAppDir: false, hasPagesDir: true }).framework, 'next-pages'); + check('remix.config → remix', detectFramework({ hasRemixConfig: true }).framework, 'remix'); + check('@tanstack/start dep → tanstack-start', detectFramework({ hasTanstackStartDep: true }).framework, 'tanstack-start'); + check('@tanstack/react-start (the actively-maintained package name) also detected', + detectFramework({ hasTanstackStartDep: /@tanstack\/(react-|solid-)?start/.test('@tanstack/react-start') }).framework, 'tanstack-start'); + check('vite.config → vite-spa', detectFramework({ hasViteConfig: true }).framework, 'vite-spa'); + check('nothing → unknown', detectFramework({}).framework, 'unknown'); + } + + // detectBundler + { + check('rspack.config wins over next → rspack', detectBundler({ framework: 'next-app-router', hasRspackConfig: true }).bundler, 'rspack'); + check('next.config turbo key (13-15.x shape) → turbopack', detectBundler({ framework: 'next-app-router', nextConfigText: 'module.exports = { experimental: { turbo: {} } }' }).bundler, 'turbopack'); + check('next.config turbopack key (16+ shape) → turbopack', detectBundler({ framework: 'next-app-router', nextConfigText: 'module.exports = { turbopack: {} }' }).bundler, 'turbopack'); + check('next default (no turbo/turbopack key) → webpack', detectBundler({ framework: 'next-app-router', nextConfigText: 'module.exports = {}' }).bundler, 'webpack'); + check('vite-spa framework → vite', detectBundler({ framework: 'vite-spa' }).bundler, 'vite'); + check('esbuild metafile → esbuild', detectBundler({ framework: 'unknown', hasEsbuildMetafile: true }).bundler, 'esbuild'); + check('nothing → unknown', detectBundler({ framework: 'unknown' }).bundler, 'unknown'); + } + + // detectHost + { + check('vercel.json → vercel', detectHost({ hasVercelJson: true }).host, 'vercel'); + check('.vercel/ dir → vercel', detectHost({ hasVercelDir: true }).host, 'vercel'); + check('wrangler.toml → cloudflare', detectHost({ hasWranglerToml: true }).host, 'cloudflare'); + check('Dockerfile → node', detectHost({ hasDockerfile: true }).host, 'node'); + check('nothing → unknown', detectHost({}).host, 'unknown'); + } + + // checkVersionPin + { + const r1 = checkVersionPin('{"mcpServers":{"chrome-devtools":{"args":["chrome-devtools-mcp@1.6.0"]}}}', '**Pinned: `chrome-devtools-mcp@1.6.0`.**'); + check('matching pins agree', r1.agree, true); + const r2 = checkVersionPin('{"mcpServers":{"chrome-devtools":{"args":["chrome-devtools-mcp@1.7.0"]}}}', '**Pinned: `chrome-devtools-mcp@1.6.0`.**'); + check('drifted pins disagree', r2.agree, false); + check('drifted pins → both values reported', [r2.mcpPin, r2.docsPin], ['1.7.0', '1.6.0']); + check('missing file → no agreement, no crash', checkVersionPin(null, null).agree, false); + } + + // DEFAULT_CONFIG — depPulse keys + { + const cfg = DEFAULT_CONFIG({ framework: 'unknown', bundler: 'unknown', host: 'unknown' }); + check('DEFAULT_CONFIG.depPulse defaults true', cfg.depPulse, true); + check('DEFAULT_CONFIG.depPulseAutoApply defaults true', cfg.depPulseAutoApply, true); + check('GITIGNORE_CONTENT covers dep-pulse.json (bootstrap() backfills this on old repos)', + GITIGNORE_CONTENT.includes('dep-pulse.json'), true); + } + + // backfillConfig — fills missing top-level keys, never overwrites present ones + { + const defaults = { a: 1, b: 2, c: 3 }; + const r1 = backfillConfig({ a: 1, b: 99 }, defaults); + check('missing key added', r1.config.c, 3); + check('present key not overwritten', r1.config.b, 99); + check('added lists only the missing key', r1.added, ['c']); + const r2 = backfillConfig({ a: 1, b: 2, c: 3 }, defaults); + check('nothing missing → added is empty', r2.added, []); + } + + // parseUrlProbe + { + check('200 → reachable', parseUrlProbe('200').reachable, true); + check('404 → reachable (server responded)', parseUrlProbe('404').reachable, true); + check('500 → not reachable', parseUrlProbe('500').reachable, false); + check('empty → not reachable', parseUrlProbe('').reachable, false); + check('null → not reachable', parseUrlProbe(null).reachable, false); + } + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +function gatherAxes() { + const nextConfigPath = ['next.config.js', 'next.config.mjs', 'next.config.ts'].map(f => path.join(repo, f)).find(exists); + const nextConfigText = nextConfigPath ? read(nextConfigPath) : ''; + const framework = detectFramework({ + hasNextConfig: !!nextConfigPath, + hasAppDir: exists(path.join(repo, 'app')) || exists(path.join(repo, 'src', 'app')), + hasPagesDir: exists(path.join(repo, 'pages')) || exists(path.join(repo, 'src', 'pages')), + hasRemixConfig: exists(path.join(repo, 'remix.config.js')), + // `@tanstack/start` (the original unscoped name) is now stale — the project split into + // `@tanstack/react-start` / `@tanstack/solid-start`, neither of which the bare + // `@tanstack/start` substring match would catch. See `frameworks/tanstack-start.md`. + hasTanstackStartDep: /@tanstack\/(react-|solid-)?start/.test(read(path.join(repo, 'package.json')) || ''), + hasViteConfig: ['vite.config.js', 'vite.config.ts'].some(f => exists(path.join(repo, f))), + }); + const bundler = detectBundler({ + framework: framework.framework, + nextConfigText, + hasWebpackConfig: ['webpack.config.js', 'webpack.config.ts'].some(f => exists(path.join(repo, f))), + hasRspackConfig: exists(path.join(repo, 'rspack.config.js')), + hasViteConfig: ['vite.config.js', 'vite.config.ts'].some(f => exists(path.join(repo, f))), + hasEsbuildMetafile: exists(path.join(repo, 'meta.json')), + }); + const host = detectHost({ + hasVercelJson: exists(path.join(repo, 'vercel.json')), + hasVercelDir: exists(path.join(repo, '.vercel')), + hasWranglerToml: exists(path.join(repo, 'wrangler.toml')), + hasHeadersFile: exists(path.join(repo, '_headers')), + hasDockerfile: exists(path.join(repo, 'Dockerfile')), + hasServerJs: exists(path.join(repo, 'server.js')), + }); + return { framework: framework.framework, bundler: bundler.bundler, host: host.host, sources: { framework: framework.source, bundler: bundler.source, host: host.source } }; +} + +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + console.log(`\nbrowsergnome doctor — ${repo}\n`); + + const node = process.versions.node; + console.log(` ${ok(parseInt(node, 10) >= 18)} node ${node} (>=18 required)`); + + // ── chrome-devtools-mcp pin consistency ───────────────────────────────── + const mcpJsonPath = process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, '.mcp.json') + : path.join(path.dirname(new URL(import.meta.url).pathname), '../../../.mcp.json'); + const toolsMdPath = process.env.CLAUDE_PLUGIN_ROOT + ? path.join(process.env.CLAUDE_PLUGIN_ROOT, 'skills/browsergnome/references/tools.md') + : path.join(path.dirname(new URL(import.meta.url).pathname), '../references/tools.md'); + const pin = checkVersionPin(read(mcpJsonPath), read(toolsMdPath)); + if (pin.mcpPin == null) { + console.log(` ${warn} could not find a pinned chrome-devtools-mcp version in .mcp.json`); + } else if (pin.docsPin == null) { + console.log(` ${warn} references/tools.md has no parseable "Pinned: \`chrome-devtools-mcp@X.Y.Z\`" line —`); + console.log(` can't confirm it agrees with .mcp.json's @${pin.mcpPin}. Fix the line's format, not the version.`); + } else if (pin.agree) { + console.log(` ${ok(true)} chrome-devtools-mcp@${pin.mcpPin} — .mcp.json and references/tools.md agree`); + } else { + console.log(` ${ok(false)} chrome-devtools-mcp pin drift: .mcp.json has @${pin.mcpPin}, references/tools.md documents @${pin.docsPin}`); + console.log(` Re-verify the live tool surface against the new version and update tools.md (see its header for how).`); + } + console.log(` ${warn} live tool-surface drift (added/removed/renamed tools) can't be checked from this script —`); + console.log(` it needs a live MCP call. If a tool call returns an unexpected shape mid-run, that's the signal;`); + console.log(` re-verify against references/tools.md rather than assuming the pin still holds.`); + + const mwgInstalled = sh(`find "${os.homedir()}/.claude/plugins" -path "*/modern-web-guidance*" -name "*.md" -maxdepth 6 2>/dev/null | head -1`); + console.log(` ${mwgInstalled ? ok(true) : warn} modern-web-guidance${mwgInstalled ? '' : ' — install: /plugin marketplace add GoogleChrome/modern-web-guidance && /plugin install modern-web-guidance@googlechrome'}`); + + // ── Git state ───────────────────────────────────────────────────────────── + const gitInsideWorkTree = sh('git rev-parse --is-inside-work-tree') === 'true'; + const gitHeadSha = gitInsideWorkTree ? sh('git rev-parse HEAD') : null; + const gitSymbolicRef = (gitInsideWorkTree && gitHeadSha) ? sh('git symbolic-ref HEAD') : null; + const gitPorcelain = (gitInsideWorkTree && gitHeadSha) ? (sh('git status --porcelain') || '') : ''; + const gitState = parseGitState({ insideWorkTree: gitInsideWorkTree, headSha: gitHeadSha, symbolicRef: gitSymbolicRef, porcelain: gitPorcelain }); + + console.log(''); + if (gitState.state === 'no-repo') { + console.log(` ${ok(false)} git: not inside a git repo`); + console.log(` browsergnome uses git as memory. Run: git init && git add -A && git commit -m 'init'`); + } else if (gitState.state === 'no-commits') { + console.log(` ${ok(false)} git: no commits yet — run: git commit --allow-empty -m init`); + } else if (gitState.state === 'detached') { + console.log(` ${ok(false)} git: detached HEAD — checkout a branch first: git switch -c perf/browsergnome`); + } else { + const branchStr = gitState.branch ? ` on ${gitState.branch}` : ''; + if (gitState.dirty.length === 0) { + console.log(` ${ok(true)} git tree clean${branchStr}`); + } else { + console.log(` ${warn} ${gitState.dirty.length} pre-existing change(s)${branchStr} — browsergnome will leave these untouched:`); + for (const f of gitState.dirty) console.log(` ${f}`); + } + } + + // ── Stack detection (three independent axes) ───────────────────────────── + const axes = gatherAxes(); + console.log(`\n Stack:`); + console.log(` ${ok(axes.framework !== 'unknown')} framework: ${axes.framework} (${axes.sources.framework})`); + console.log(` ${ok(axes.bundler !== 'unknown')} bundler: ${axes.bundler} (${axes.sources.bundler})`); + console.log(` ${ok(axes.host !== 'unknown')} host: ${axes.host} (${axes.sources.host})`); + if (axes.framework === 'unknown' || axes.bundler === 'unknown' || axes.host === 'unknown') { + console.log(` ${warn} unknown axes fall back to the generic knowledge base — still fully functional.`); + } + + // ── Target URL reachability ─────────────────────────────────────────────── + if (targetUrl) { + // execFileSync, not sh()'s shell string — targetUrl is user/config-supplied and must not be + // shell-interpolated (a URL containing shell metacharacters would otherwise break out of the quotes). + let status = null; + try { + status = execFileSync('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '5', targetUrl], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); + } catch {} + const probe = parseUrlProbe(status); + console.log(`\n ${ok(probe.reachable)} ${targetUrl} — ${probe.status ? `HTTP ${probe.status}` : 'not reachable'}`); + } else { + console.log(`\n ${warn} no --url given — pass the target repo's dev/preview URL to verify reachability`); + } + + // ── .bgn/ workspace ──────────────────────────────────────────────────────── + const bgnExists = fs.existsSync(path.join(repo, '.bgn')); + console.log(''); + if (bgnExists) { + console.log(` ${ok(true)} .bgn/ workspace present`); + const cfgPath = path.join(repo, '.bgn', 'config.json'); + if (!fs.existsSync(cfgPath)) { + fs.writeFileSync(cfgPath, JSON.stringify(DEFAULT_CONFIG(axes), null, 2) + '\n'); + console.log(` ${ok(true)} .bgn/config.json created with defaults`); + } else { + let parsed = null; + try { parsed = JSON.parse(read(cfgPath)); } catch {} + if (parsed === null) { + console.log(` ${ok(false)} .bgn/config.json is not valid JSON — fix or delete it, then re-run --init`); + } else { + const { config, added } = backfillConfig(parsed, DEFAULT_CONFIG(axes)); + if (added.length) { + fs.writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n'); + console.log(` ${ok(true)} .bgn/config.json backfilled with new defaults: ${added.join(', ')}`); + } else { + console.log(` ${ok(true)} .bgn/config.json present`); + } + } + } + } else if (doInit) { + bootstrap(axes); + } else { + console.log(` ${warn} .bgn/ not found — run: node doctor.mjs --init (creates perf-memory.md, config.json, ledger/, archive/, audit/, what-if/)`); + } +} + +// See trace_metrics.mjs's identical guard (and its comment on why +// pathToFileURL, not a hand-built `file://` string) for why this matters — +// check_invariants.mjs imports checkVersionPin from this file directly. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/skills/browsergnome/scripts/lcp_attribution.mjs b/skills/browsergnome/scripts/lcp_attribution.mjs new file mode 100644 index 0000000..1d5a7fc --- /dev/null +++ b/skills/browsergnome/scripts/lcp_attribution.mjs @@ -0,0 +1,573 @@ +#!/usr/bin/env node +/** + * lcp_attribution.mjs — DATA LAYER ONLY for the LCP Attribution Map. + * Combines a captured trace (trace_metrics.mjs's decoded events) with + * bundle_stats.mjs's chunk→module bytes mapping into a three-tier node + * list: network resources (measured), chunks (measured), source modules + * (apportioned). No visualization here — see the file-level note at the + * bottom for what's deliberately not built and why. + * + * **Network timing comes from raw trace events, not `performance_ + * analyze_insight`.** That MCP tool's RenderBlocking/LCPBreakdown/ + * DocumentLatency insights return semi-structured text (a report with an + * embedded pseudo-CSV table), not JSON — fine for an agent reading it + * live, wrong for a pure offline-testable script. `ResourceSendRequest` + * carries `url`, `renderBlocking` ('blocking' | 'potentially_blocking' | + * 'non_blocking' | absent), `resourceType`, and `frame` directly; + * `ResourceReceiveResponse`/`ResourceFinish` carry response/finish timing, + * correlated by `requestId`. Computing each render-blocking CSS request's + * duration from these raw events (send.ts to finish.ts) reproduced + * `performance_analyze_insight`'s own reported duration for the same + * requests to within ~1ms on a live capture — the small gap is expected, + * since the insight's window is queuedTime→processingCompleteTime, + * slightly wider than send→finish. + * `assets/trace.render-blocking-sample.json.gz` ships that capture, + * headers stripped, as `selfTest()`'s fixture. + * + * **The module-apportionment ratio divides by the chunk's own module-byte + * sum, not `bundle_stats.mjs`'s `chunk.bytes`.** Those are two different + * numbers for the same chunk: `chunk.bytes` is the real shipped ASSET size + * (post-minify); the sum of `chunk.modules[].bytes` is the pre-minify + * MODULE size (webpack's own `chunk.size` stat). On the shipped multi-chunk + * fixture: asset bytes 11886, module-byte sum 7546 — apportioning against + * the asset-size denominator would under-attribute every module by the + * same ~36% ratio, and the ms wouldn't sum back to `chunk_ms`. + * `apportionModuleMs` computes the denominator fresh from `chunk.modules` + * instead. + * + * **Network tier = resources with `renderBlocking === 'blocking'` that + * started before the navigation's LCP timestamp** — the same set + * `performance_analyze_insight`'s RenderBlocking insight reports (matched + * 6/6 on the real capture). This is deliberately NOT "the exact resource + * that IS the LCP element" — the one real `largestContentfulPaint:: + * Candidate` event captured this way (a text node, not an image) carries + * no `url` field at all, only `nodeId`/`type`/`size`, so there's no + * reliable node↔network-request link to build on. "The LCP-critical-path + * network resources" is the honest claim here, not "the LCP resource itself." + * + * **Contribution ms is each resource's own load duration, clipped to the + * pre-LCP window — not a wall-clock-additive model.** Render-blocking + * requests load in parallel (the real fixture's 6 CSS requests all start + * within ~1ms of each other), so summing every node's `ms` will generally + * exceed the actual LCP time. That's a known, disclosed property of + * per-resource duration, not a bug — the map's job is showing *which* + * resources were on the critical path and *how long each individually + * took*, not decomposing wall-clock time into non-overlapping slices + * (that would need a browser main-thread contention model this trace + * doesn't provide). Say so in the UI when this ships — see the bottom note. + * + * Usage: + * node lcp_attribution.mjs [bundle-stats.json[.gz]] + * node lcp_attribution.mjs --self-test + */ + +import fs from 'node:fs'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { decodeTrace, pickNavigation, computeLCP, computeFCP, computeTTFB, computeLongTasksAndTBT, computeScriptTimings } from './trace_metrics.mjs'; +import { decodeStats, parseBundleStats } from './bundle_stats.mjs'; + +// ── Network tier (measured) ──────────────────────────────────────────────── + +/** + * Correlate ResourceSendRequest/ResourceReceiveResponse/ResourceFinish by + * `requestId`, scoped to the navigation's frame, into per-request records. + * A request missing its Finish event (still in flight when the trace + * stopped) is dropped — no finish time means no measured duration. + * + * `ResourceFinish` carries no `frame` field at all (confirmed on a real + * capture — only `decodedBodyLength`/`didFail`/`encodedDataLength`/ + * `finishTime`/`requestId`), so frame scoping happens once, on the `sends` + * map, and `finishes` is looked up by requestId without re-checking frame. + * This only stays correct because requestId doesn't collide across frames: + * most requests use `.` with the counter unique per renderer + * process (confirmed on 92 of 93 requests in the real fixture); the + * navigation request itself uses a GUID instead. Neither form can produce + * the same value across two different requests, so a finish can't + * cross-attach to the wrong frame's send. A synthetic two-frame test below + * with distinct requestIds locks in that behavior; it doesn't (and can't, + * given the ID scheme) test an actual collision, because nothing here + * produces one. + * + * `didFail` requests ARE included, deliberately: a render-blocking request + * that failed (404, aborted, CSP-blocked) still occupied the render-blocking + * slot for however long it took to fail, which is real cost the map should + * show, not silently drop. Each resource carries `failed` so a future UI + * can render it distinctly (a failed blocking request is a different kind + * of fix — "why is this failing" — than a slow-but-successful one). + */ +export function computeNetworkResources(events, nav) { + const sends = new Map(); + for (const e of events) { + if (e.name === 'ResourceSendRequest' && e.args?.data?.frame === nav.frame) { + sends.set(e.args.data.requestId, e); + } + } + const finishes = new Map(); + for (const e of events) { + if (e.name === 'ResourceFinish' && sends.has(e.args?.data?.requestId)) { + finishes.set(e.args.data.requestId, e); + } + } + + const resources = []; + for (const [requestId, sendEvent] of sends) { + const finishEvent = finishes.get(requestId); + if (!finishEvent) continue; + const d = sendEvent.args.data; + resources.push({ + url: d.url, + resourceType: d.resourceType, + renderBlocking: d.renderBlocking === 'blocking', + startMs: (sendEvent.ts - nav.navStartUs) / 1000, + finishMs: (finishEvent.ts - nav.navStartUs) / 1000, + bytes: finishEvent.args.data.decodedBodyLength ?? 0, + failed: finishEvent.args.data.didFail ?? false, + }); + } + return resources; +} + +/** + * Network tier: render-blocking resources that started before LCP, each + * node's `ms` clipped to [start, min(finish, lcpMs)] — see file header for + * why this isn't wall-clock-additive. Returns [] if `lcpMs` is null (no LCP + * in this trace) since "before LCP" is undefined without an LCP timestamp. + */ +export function attributeNetworkTier(resources, lcpMs) { + if (lcpMs == null) return []; + return resources + .filter(r => r.renderBlocking && r.startMs < lcpMs) + .map(r => ({ + class: 'network', + label: r.url, + resourceType: r.resourceType, + bytes: r.bytes, + ms: Math.max(0, Math.min(r.finishMs, lcpMs) - Math.max(r.startMs, 0)), + confidence: 'measured', + failed: r.failed ?? false, // see computeNetworkResources's doc comment — included deliberately + })); +} + +// ── Chunk tier (measured) ─────────────────────────────────────────────────── + +/** + * Chunk tier: reuses trace_metrics.mjs's computeScriptTimings verbatim + * (already verified against real traces — see that file's header) — + * {url, compileMs, evaluateMs} per script URL, ms = compile+evaluate. + */ +export function attributeChunkTier(events, nav) { + return computeScriptTimings(events, nav).map(s => ({ + class: 'chunk', + label: s.url, + ms: s.compileMs + s.evaluateMs, + compileMs: s.compileMs, + evaluateMs: s.evaluateMs, + confidence: 'measured', + })); +} + +// ── Module tier (apportioned) ─────────────────────────────────────────────── + +/** + * For one bundle_stats.mjs chunk record, split its measured `chunkMs` + * (from the chunk tier above, matched by filename — see `matchChunkToUrl`) + * across its modules by BYTE SHARE OF THAT CHUNK'S OWN MODULES, not by + * share of the chunk's shipped asset bytes (see file header for why those + * are different numbers and dividing by the wrong one under-attributes + * every module). Modules with 0 total chunk bytes (an empty modules[] — + * e.g. an unsupported-format bundler stats file) get no module tier for + * that chunk; the caller should already have degraded before this point + * (see `attributeModuleTier`). + */ +export function apportionModuleMs(chunk, chunkMs) { + const totalModuleBytes = chunk.modules.reduce((s, m) => s + m.bytes, 0); + if (totalModuleBytes === 0) return []; + return chunk.modules.map(m => ({ + class: 'module', + label: m.module, + bytes: m.bytes, + ms: chunkMs * (m.bytes / totalModuleBytes), + confidence: 'apportioned', + })); +} + +/** + * Glue chunk-tier nodes (URLs, from the trace) to bundle_stats chunks + * (filenames, from the build) by suffix match — a trace script URL is + * always `//`, and bundle_stats' `chunk.file` is just + * ``, so `url.endsWith('/' + chunk.file)` is the correct match, not + * equality. + */ +function matchChunkToUrl(chunkFile, scriptUrl) { + return typeof scriptUrl === 'string' && scriptUrl.endsWith('/' + chunkFile); +} + +/** + * Module tier for every bundle_stats chunk that has a matching chunk-tier + * (measured) entry. Chunks with no bundle stats match, or bundle stats that + * failed to parse (`bundleStats.error` set — unsupported bundler), produce + * no module tier — degrade to chunk-only, per the plan's mandatory + * degradation rule (see file header). Callers must not fabricate one. + */ +export function attributeModuleTier(chunkTierNodes, bundleStats) { + if (!bundleStats || bundleStats.error || !Array.isArray(bundleStats.chunks)) return []; + const modules = []; + for (const chunk of bundleStats.chunks) { + const matched = chunkTierNodes.find(n => matchChunkToUrl(chunk.file, n.label)); + if (!matched) continue; + modules.push(...apportionModuleMs(chunk, matched.ms)); + } + return modules; +} + +// ── Transport profile ──────────────────────────────────────────────────── + +function safeOrigin(url) { + try { return new URL(url).origin; } catch { return null; } +} + +/** + * First-party request count by HTTP protocol, scoped to the navigation's + * frame. First-party only (same origin as the navigation URL) — third-party + * CDN/widget/analytics requests are usually already on h2/h3 and would mask + * a first-party origin still on HTTP/1.1. + * + * HTTP/1.1 caps the browser at ~6 connections per origin; a first-party + * origin with many requests on HTTP/1.1 serializes into a request waterfall + * a production CDN (h2/h3, multiplexed) wouldn't have. `legacyHttp` flags + * that case so a measurement against it can be labeled non-representative + * instead of reported at face value. + */ +export function computeTransportProfile(events, nav) { + const origin = safeOrigin(nav.url); + const sends = new Map(); + for (const e of events) { + if (e.name === 'ResourceSendRequest' && e.args?.data?.frame === nav.frame) { + sends.set(e.args.data.requestId, e.args.data.url); + } + } + const byProtocol = {}; + let firstPartyTotal = 0, http1Count = 0; + for (const e of events) { + if (e.name !== 'ResourceReceiveResponse') continue; + const requestId = e.args?.data?.requestId; + const url = sends.get(requestId); + if (!url || (origin && safeOrigin(url) !== origin)) continue; + firstPartyTotal++; + const protocol = e.args.data.protocol || 'unknown'; + byProtocol[protocol] = (byProtocol[protocol] || 0) + 1; + if (protocol === 'http/1.1') http1Count++; + } + const http1Share = firstPartyTotal ? http1Count / firstPartyTotal : 0; + return { + firstPartyTotal, + byProtocol, + http1Count, + http1Share, + legacyHttp: firstPartyTotal > 0 && http1Share >= 0.5, + }; +} + +// ── Phase breakdown ────────────────────────────────────────────────────── + +/** + * TTFB / load-to-FCP / FCP-to-LCP split of the full lcpMs window. Unlike + * the node tiers above (per-resource, can overlap, can undershoot lcpMs — + * see file header), this always sums to exactly lcpMs by construction: each + * mark is clamped into [previous mark, lcpMs] before subtracting, so a + * missing or out-of-order FCP/TTFB can't make a phase negative or make the + * three parts not add up. It exists because the node tiers only cover + * network-loading time — a load that's fast but whose LCP fires long after + * FCP (main-thread work, client-side data fetching, hydration) would + * otherwise show a handful of small nodes next to a much larger lcpMs with + * nothing accounting for the difference. + */ +export function computeLcpPhases(ttfbMs, fcpMs, lcpMs) { + if (lcpMs == null) return null; + const ttfb = Math.max(0, Math.min(ttfbMs ?? 0, lcpMs)); + const fcp = Math.max(ttfb, Math.min(fcpMs ?? ttfb, lcpMs)); + return { ttfbMs: ttfb, loadToFcpMs: fcp - ttfb, fcpToLcpMs: lcpMs - fcp }; +} + +// ── Throttled reference (optional, secondary capture) ─────────────────── + +/** + * Summary metrics (LCP/FCP/TBT) from a second, separately-captured trace — + * a throttled reference run alongside the main (default: unthrottled) + * capture, for a Lighthouse-comparable secondary readout. `label` describes + * whatever `emulate` conditions that second trace was actually captured + * under; this function has no way to know that from the trace itself, so it + * takes the caller's word for it rather than guessing. + */ +export function computeThrottledReference(events, label) { + const nav = pickNavigation(events); + if (!nav) return null; + const lcpMs = computeLCP(events, nav); + const { tbt } = computeLongTasksAndTBT(events, nav); + return { label: label || 'throttled', lcpMs, fcpMs: computeFCP(events, nav), tbtMs: tbt }; +} + +// ── Combine ────────────────────────────────────────────────────────────── + +/** + * Build the full three-tier node list for one trace (+ optional bundle + * stats). `bundleStats` is optional — omit it (or pass a `{error}` result + * from `parseBundleStats`) to get the mandatory degraded chunk-only map. + * `throttledEvents`/`throttledLabel` are optional — a second trace's events + * to summarize as a secondary reference reading (see `computeThrottledReference`). + */ +export function buildAttributionData(events, bundleStats, throttledEvents, throttledLabel) { + const nav = pickNavigation(events); + if (!nav) return { error: 'no navigation found in trace' }; + const lcpMs = computeLCP(events, nav); + const network = attributeNetworkTier(computeNetworkResources(events, nav), lcpMs); + const chunks = attributeChunkTier(events, nav); + const degraded = !bundleStats || bundleStats.error != null; + const modules = degraded ? [] : attributeModuleTier(chunks, bundleStats); + return { + url: nav.url, + lcpMs, + tbtMs: computeLongTasksAndTBT(events, nav).tbt, + phases: computeLcpPhases(computeTTFB(events, nav), computeFCP(events, nav), lcpMs), + transport: computeTransportProfile(events, nav), + throttled: throttledEvents ? computeThrottledReference(throttledEvents, throttledLabel) : null, + degraded, // true → no bundler stats, module tier intentionally empty + nodes: [...network, ...chunks, ...modules], + }; +} + +// ── Self-test ─────────────────────────────────────────────────────────────── + +function selfTest() { + let pass = 0, fail = 0; + const check = (name, got, want) => { + const eq = JSON.stringify(got) === JSON.stringify(want); + console.log(` ${eq ? 'PASS' : 'FAIL'} ${name} (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`); + eq ? pass++ : fail++; + }; + + // apportionModuleMs: divides by module-byte-sum, not asset bytes (see file header). + const chunk = { file: 'main.js', bytes: 1000, modules: [{ module: 'a.js', bytes: 30 }, { module: 'b.js', bytes: 70 }] }; + const modMs = apportionModuleMs(chunk, 100); + check('apportionModuleMs: a.js gets 30% of chunk ms (30/100 module-byte share, not 30/1000 asset-byte share)', + modMs.find(m => m.label === 'a.js').ms, 30); + check('apportionModuleMs: b.js gets 70%', modMs.find(m => m.label === 'b.js').ms, 70); + check('apportionModuleMs: module ms sums back to chunk ms exactly (proves the denominator fix)', + modMs.reduce((s, m) => s + m.ms, 0), 100); + check('apportionModuleMs: empty modules[] (unsupported-format degrade case) returns []', + apportionModuleMs({ file: 'x.js', bytes: 100, modules: [] }, 50), []); + + // computeLcpPhases: always sums to exactly lcpMs, even with missing/out-of-order marks + check('computeLcpPhases: null lcpMs -> null', computeLcpPhases(10, 20, null), null); + check('computeLcpPhases: normal case sums to lcpMs', computeLcpPhases(10, 40, 100), + { ttfbMs: 10, loadToFcpMs: 30, fcpToLcpMs: 60 }); + check('computeLcpPhases: missing ttfb/fcp treated as 0, still sums to lcpMs', + computeLcpPhases(null, null, 100), { ttfbMs: 0, loadToFcpMs: 0, fcpToLcpMs: 100 }); + check('computeLcpPhases: fcp before ttfb clamped, no negative phase', + computeLcpPhases(50, 10, 100), { ttfbMs: 50, loadToFcpMs: 0, fcpToLcpMs: 50 }); + check('computeLcpPhases: marks past lcpMs clamped, no negative phase', + computeLcpPhases(200, 300, 100), { ttfbMs: 100, loadToFcpMs: 0, fcpToLcpMs: 0 }); + + // computeTransportProfile: first-party-only protocol profiling + { + const FRAME = 'TFRAME', PID = 111; + const nav = { frame: FRAME, pid: PID, navStartUs: 1_000_000, url: 'https://example.com/' }; + const send = (n, url) => ({ name: 'ResourceSendRequest', ts: 1_000_000 + n, args: { data: { requestId: `r${n}`, frame: FRAME, url } } }); + const recv = (n, protocol) => ({ name: 'ResourceReceiveResponse', ts: 1_000_000 + n + 1, args: { data: { requestId: `r${n}`, protocol } } }); + + const mostlyHttp1 = [ + send(1, 'https://example.com/a.js'), recv(1, 'http/1.1'), + send(2, 'https://example.com/b.js'), recv(2, 'http/1.1'), + send(3, 'https://example.com/c.js'), recv(3, 'h2'), + ]; + const mostlyHttp1Profile = computeTransportProfile(mostlyHttp1, nav); + check('computeTransportProfile: mostly http/1.1 first-party -> legacyHttp true', mostlyHttp1Profile.legacyHttp, true); + check('computeTransportProfile: http1Count counts only http/1.1', mostlyHttp1Profile.http1Count, 2); + check('computeTransportProfile: firstPartyTotal counts all first-party responses', mostlyHttp1Profile.firstPartyTotal, 3); + + const thirdPartyHttp1 = [ + send(1, 'https://example.com/a.js'), recv(1, 'h2'), + send(2, 'https://widget.example/w.js'), recv(2, 'http/1.1'), // third-party, must not count + ]; + check('computeTransportProfile: third-party http/1.1 excluded, first-party is h2 -> legacyHttp false', + computeTransportProfile(thirdPartyHttp1, nav).legacyHttp, false); + + check('computeTransportProfile: no responses -> firstPartyTotal 0, legacyHttp false', + computeTransportProfile([], nav), { firstPartyTotal: 0, byProtocol: {}, http1Count: 0, http1Share: 0, legacyHttp: false }); + } + + // matchChunkToUrl (via attributeModuleTier): suffix match, not equality + const chunkTier = [{ class: 'chunk', label: 'https://example.com/_next/static/main.js', ms: 100, confidence: 'measured' }]; + const bundleStats = { chunks: [{ file: 'main.js', bytes: 1000, modules: [{ module: 'a.js', bytes: 100 }] }] }; + check('attributeModuleTier: matches trace URL to bundle chunk by filename suffix, not exact equality', + attributeModuleTier(chunkTier, bundleStats).length, 1); + check('attributeModuleTier: no bundle stats (undefined) degrades to empty module tier', + attributeModuleTier(chunkTier, undefined), []); + check('attributeModuleTier: bundle stats with .error (unsupported bundler) degrades to empty module tier', + attributeModuleTier(chunkTier, { error: 'unrecognized format' }), []); + + // attributeNetworkTier: clipping + null-LCP handling + const resources = [ + { url: 'a.css', resourceType: 'Stylesheet', renderBlocking: true, startMs: 10, finishMs: 40, bytes: 500 }, + { url: 'b.css', resourceType: 'Stylesheet', renderBlocking: false, startMs: 10, finishMs: 40, bytes: 500 }, // not blocking, excluded + { url: 'c.css', resourceType: 'Stylesheet', renderBlocking: true, startMs: 200, finishMs: 300, bytes: 500 }, // starts after LCP, excluded + { url: 'd.css', resourceType: 'Stylesheet', renderBlocking: true, startMs: 10, finishMs: 150, bytes: 500 }, // finishes after LCP, clipped + ]; + const net = attributeNetworkTier(resources, 100); + check('attributeNetworkTier: excludes non-blocking resources', net.some(n => n.label === 'b.css'), false); + check('attributeNetworkTier: excludes resources starting after LCP', net.some(n => n.label === 'c.css'), false); + check('attributeNetworkTier: clips a resource finishing after LCP to the LCP boundary (150->100, ms=90 not 140)', + net.find(n => n.label === 'd.css')?.ms, 90); + check('attributeNetworkTier: unclipped resource keeps its real duration (a.css, 40-10=30)', + net.find(n => n.label === 'a.css')?.ms, 30); + check('attributeNetworkTier: null LCP (no LCP in trace) returns no network tier, not a guess', + attributeNetworkTier(resources, null), []); + + // computeNetworkResources: frame scoping + didFail — see that function's + // doc comment for why a finish never cross-attaches across frames here. + { + const FRAME_A = 'FRAMEA', FRAME_B = 'FRAMEB', PID = 111; + const navA = { frame: FRAME_A, pid: PID, navStartUs: 1_000_000, url: 'https://a.example/' }; + const crossFrameEvents = [ + { name: 'ResourceSendRequest', ts: 1_010_000, args: { data: { requestId: '111.1', frame: FRAME_A, url: 'a.css', resourceType: 'Stylesheet', renderBlocking: 'blocking' } } }, + { name: 'ResourceSendRequest', ts: 1_020_000, args: { data: { requestId: '111.2', frame: FRAME_B, url: 'b.css', resourceType: 'Stylesheet', renderBlocking: 'blocking' } } }, + { name: 'ResourceFinish', ts: 1_030_000, args: { data: { requestId: '111.1', decodedBodyLength: 100, didFail: false } } }, + { name: 'ResourceFinish', ts: 1_099_000, args: { data: { requestId: '111.2', decodedBodyLength: 200, didFail: false } } }, + ]; + const resourcesA = computeNetworkResources(crossFrameEvents, navA); + check('computeNetworkResources: only frame A\'s request is returned, not frame B\'s (distinct requestIds, no cross-attach)', + resourcesA.map(r => r.url), ['a.css']); + check('computeNetworkResources: frame A\'s request gets frame A\'s own finish time (20ms), not frame B\'s (89ms)', + resourcesA[0].finishMs - resourcesA[0].startMs, 20); + + const failedEvents = [ + { name: 'ResourceSendRequest', ts: 1_010_000, args: { data: { requestId: '111.3', frame: FRAME_A, url: 'broken.js', resourceType: 'Script', renderBlocking: 'blocking' } } }, + { name: 'ResourceFinish', ts: 1_015_000, args: { data: { requestId: '111.3', decodedBodyLength: 0, didFail: true } } }, + ]; + const failedResources = computeNetworkResources(failedEvents, navA); + check('computeNetworkResources: a failed (didFail:true) request is still included, not silently dropped', + failedResources.length, 1); + check('computeNetworkResources: failed request carries failed:true so a UI can render it distinctly', + failedResources[0].failed, true); + check('attributeNetworkTier: failed field passes through to the node', + attributeNetworkTier(failedResources, 100)[0]?.failed, true); + } + + // shipped fixture: a real nextjs.org capture — six real render-blocking + // CSS requests (see file header for the performance_analyze_insight + // spot-check these durations were checked against). + try { + const path = new URL('../assets/trace.render-blocking-sample.json.gz', import.meta.url); + const events = decodeTrace(fs.readFileSync(path)); + const nav = pickNavigation(events); + const resources = computeNetworkResources(events, nav); + const blocking = resources.filter(r => r.renderBlocking); + check('real fixture: 6 real render-blocking CSS requests found (matches performance_analyze_insight\'s own count)', + blocking.length, 6); + const lcpMs = computeLCP(events, nav); + check('real fixture: LCP present and in a sane range (matches the ~922ms chrome-devtools-mcp reported live)', + lcpMs > 800 && lcpMs < 1000, true); + const network = attributeNetworkTier(resources, lcpMs); + check('real fixture: all 6 render-blocking requests attributed (all started well before LCP)', + network.length, 6); + const durations = network.map(n => Math.round(n.ms * 10) / 10).sort((a, b) => a - b); + check('real fixture: durations in the range independently spot-checked against performance_analyze_insight (32-35ms window)', + durations.every(d => d > 25 && d < 40), true); + // confirms the trim kept EvaluateScript/v8.compile — without this check, + // a fixture with only network events would pass every assertion above + // by luck rather than by content. + const chunkTier = attributeChunkTier(events, nav); + check('real fixture: chunk tier is genuinely non-empty (real script timings survived the trim, not just network events)', + chunkTier.length > 0, true); + const phases = computeLcpPhases(computeTTFB(events, nav), computeFCP(events, nav), lcpMs); + check('real fixture: phases sum to exactly lcpMs', + Math.round((phases.ttfbMs + phases.loadToFcpMs + phases.fcpToLcpMs) * 1000) / 1000, + Math.round(lcpMs * 1000) / 1000); + check('real fixture: nextjs.org served over modern HTTP, no false-positive legacyHttp flag', + computeTransportProfile(events, nav).legacyHttp, false); + } catch (err) { + console.log(` FAIL real fixture decodes and parses (threw: ${err.message})`); + fail++; + } + + // buildAttributionData: degradation path (no bundle stats -> chunk-only) + try { + const path = new URL('../assets/trace.render-blocking-sample.json.gz', import.meta.url); + const events = decodeTrace(fs.readFileSync(path)); + const result = buildAttributionData(events, undefined); + check('buildAttributionData: degrades cleanly with no bundle stats (no module-tier nodes)', + result.nodes.some(n => n.class === 'module'), false); + check('buildAttributionData: degraded flag set true so a UI can say so, per the plan\'s mandatory rule', + result.degraded, true); + check('buildAttributionData: network + chunk tiers still present even when degraded', + result.nodes.some(n => n.class === 'network') && result.nodes.some(n => n.class === 'chunk'), true); + check('buildAttributionData: top-level tbtMs is a number (for the main-run stats row)', + typeof result.tbtMs, 'number'); + } catch (err) { + console.log(` FAIL buildAttributionData degradation path (threw: ${err.message})`); + fail++; + } + + // buildAttributionData: optional throttled reference wiring + try { + const path = new URL('../assets/trace.render-blocking-sample.json.gz', import.meta.url); + const events = decodeTrace(fs.readFileSync(path)); + const noThrottled = buildAttributionData(events, undefined); + check('buildAttributionData: throttled is null when no second trace given', noThrottled.throttled, null); + + const withThrottled = buildAttributionData(events, undefined, events, '4x CPU · Slow 4G'); + check('buildAttributionData: throttled reference present when a second trace is given', withThrottled.throttled != null, true); + check('buildAttributionData: throttled reference carries the caller-supplied label', withThrottled.throttled.label, '4x CPU · Slow 4G'); + check('buildAttributionData: throttled.lcpMs matches computeLCP on that trace directly', withThrottled.throttled.lcpMs, noThrottled.lcpMs); + check('buildAttributionData: throttled reference has a numeric tbtMs', typeof withThrottled.throttled.tbtMs, 'number'); + + check('computeThrottledReference: default label when none given', computeThrottledReference(events).label, 'throttled'); + check('computeThrottledReference: no navigation -> null', computeThrottledReference([]), null); + } catch (err) { + console.log(` FAIL buildAttributionData throttled reference wiring (threw: ${err.message})`); + fail++; + } + + console.log(`\n ${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + const args = process.argv.slice(2); + const flagValueIdx = (flag) => { const i = args.indexOf(flag); return i >= 0 ? i + 1 : -1; }; + const throttledIdx = flagValueIdx('--throttled'); + const throttledLabelIdx = flagValueIdx('--throttled-label'); + const skip = new Set([throttledIdx, throttledLabelIdx]); + const positional = args.filter((a, i) => !a.startsWith('--') && !skip.has(i)); + const [traceFile, bundleStatsFile] = positional; + if (!traceFile) { + console.error('usage: node lcp_attribution.mjs [bundle-stats.json[.gz]] [--throttled ] [--throttled-label ]'); + console.error(' node lcp_attribution.mjs --self-test'); + process.exit(1); + } + const events = decodeTrace(fs.readFileSync(traceFile)); + const bundleStats = bundleStatsFile ? parseBundleStats(decodeStats(fs.readFileSync(bundleStatsFile))) : undefined; + const throttledEvents = throttledIdx >= 0 ? decodeTrace(fs.readFileSync(args[throttledIdx])) : undefined; + const throttledLabel = throttledLabelIdx >= 0 ? args[throttledLabelIdx] : undefined; + console.log(JSON.stringify(buildAttributionData(events, bundleStats, throttledEvents, throttledLabel), null, 2)); +} + +// See trace_metrics.mjs's identical guard for why pathToFileURL, not a +// hand-built `file://` string. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} + +/** + * NOT built, deliberately — reviewing attribution math and reviewing a + * rendering are different jobs, and bundling them risks not being able to + * tell which one a bug is in: `build_lcp_map.mjs`'s HTML/3D output (nodes + * laid out by import graph X/Y, elevated by execution-order Z, the red LCP + * plane, click-to-collapse causal chains, node shrink-on-KEEP animation — + * see `references/perf-map.md`). + */ diff --git a/skills/browsergnome/scripts/perf_scan.mjs b/skills/browsergnome/scripts/perf_scan.mjs new file mode 100644 index 0000000..f6801b2 --- /dev/null +++ b/skills/browsergnome/scripts/perf_scan.mjs @@ -0,0 +1,893 @@ +#!/usr/bin/env node +/** + * perf_scan.mjs — static web perf-debt scanner. + * + * Walks a web app repo, parses every JS/TS(X) source with Babel, runs a set + * of anti-pattern detectors, and emits graph.json: a module dependency graph + * where each node carries a perf-debt score and the findings behind it. + * + * The Babel AST walker, alias resolution, import graph, centrality, and the + * four noise-control mechanisms (severity weights, diminishing returns, + * log-scale centrality amplification, the combined hotspot gate) are + * stack-independent — see references/perf-map.md. Only the detector catalog + * below is web-specific. + * + * Design notes: + * - Nodes = source modules (files). Edges = static import relationships. + * - The make-or-break property of this tool is signal-vs-noise. Score, then + * GATE: only nodes whose debt clears HOTSPOT_DEBT are colored/enlarged; + * everything else stays small and grey with findings tucked into the + * tooltip. + * - Every tuning knob lives in CONFIG below. Tune there against a real OSS + * app, never against a seeded fixture (a fixture is circular). + * + * Usage: + * node perf_scan.mjs [--out graph.json] [--quiet] [--no-compiler-detect] + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { createRequire } from 'node:module'; + +// Babel packages are CJS — load via createRequire (same pattern as doctor.mjs) +// so a missing install fails with one actionable line instead of a raw +// ERR_MODULE_NOT_FOUND stack. +const requireLocal = createRequire(import.meta.url); +let parse, traverse; +try { + ({ parse } = requireLocal('@babel/parser')); + const _traverse = requireLocal('@babel/traverse'); + traverse = _traverse.default || _traverse; +} catch { + console.error(`[browsergnome] missing deps (@babel/parser, @babel/traverse) — run: npm install in ${path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// CONFIG — the single place to tune signal-vs-noise. See perf-map.md. +// --------------------------------------------------------------------------- +const CONFIG = { + // Severity -> raw debt weight. MEDIUM/LOW are deliberately small so the + // common-but-harmless detectors can't drown the structural ones. + severityWeight: { CRITICAL: 10, HIGH: 5, MEDIUM: 1.5, LOW: 0.4 }, + + // Per-detector weight override, used instead of the tier default. Only + // barrelImport needs one: its MEDIUM severity label is earned (real + // shipped-byte cost) but 3-4 barrel imports in one file is common import + // hygiene, not a hotspot-worthy defect by itself — at the tier default it + // crossed the gate alone in 7 of 33 hotspots on a real-app calibration run + // (excalidraw). Tuned down until that stopped, while it still counts + // normally when combined with real structural findings. + weightOverride: { barrelImport: 0.6 }, + + // debt = rawSeverity * (1 + centralityK*log2(1+fanIn) + (hasList ? listBonus : 0)) + // Central modules (imported widely) and list screens amplify their debt — a + // flaw in a shared hub matters more than in a leaf. LOGARITHMIC because a + // real repo's hub file can have fan-in in the hundreds, and a linear + // multiplier lets one such file dwarf everything. Calibrated on a + // React Native app's import graph — re-verify on a real web app. + centralityK: 0.25, + listBonus: 0.5, + + // Diminishing returns: the Nth identical finding in one file carries far + // less signal than the first. Past this many of the SAME detector in a + // file, extra instances add only log2 weight. + diminishAfter: 3, + + // Gating combines two rules (see isHotspot below): + // 1. debt >= hotspotDebt — for MEDIUM/LOW findings that only matter once + // they accumulate. + // 2. any HIGH/CRITICAL finding — structurally important even in a leaf + // file, so it's ALWAYS a hotspot regardless of debt. + hotspotDebt: 6, + + // Perf Map renders only nodes with debt >= this (live-adjustable in the HTML). + displayMinDebt: 2, + + // Node sizing for the 3D map. + sizeBaseHot: 6, + sizeK: 1.3, + sizeMax: 60, + sizeCold: 0.8, + + // Files/dirs never scanned. Web swap: drop ios/android/Pods/.expo (no + // native platform trees), keep .next, add the other framework build dirs. + ignoreDirs: new Set([ + 'node_modules', '.git', 'build', 'dist', '.next', '.turbo', '.vercel', + 'out', 'coverage', '__snapshots__', '__mocks__', 'vendor', + ]), + // Test/spec/story files are excluded — they aren't shipped perf surface. + ignoreFileRe: /(\.test\.|\.spec\.|\.stories\.|\.d\.ts$)/, + exts: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'], + + // heavyEntryImport: full-package imports of these at an app-entry file are + // HIGH cost. Scoped packages never satisfy the plain "no slash" full-package + // test, so @mui's heavy barrels are listed explicitly — "@mui/material" + // (bare) is the barrel; "@mui/material/Button" is already tree-shakeable. + heavyLibs: new Set([ + 'moment', 'lodash', 'chart.js', 'three', 'date-fns', 'crypto-js', 'rxjs', + 'core-js', '@mui/material', '@mui/icons-material', + ]), + entryFileRe: /(^|\/)(_app|_document|layout|main|index|App)\.(t|j)sx?$/, + + // clientComponentInServerTree: 'use client' at a module this central turns + // that much of the server-rendered tree into client bundle. Heuristic + // threshold, not derived — tune against a real Next App Router repo. + useClientFanInThreshold: 8, + + // largeStaticImport: JSON/SVG imported directly into shared code. 8KB is a + // guess at "big enough to matter shipped raw into a bundle" — tune on a + // real repo, this isn't derived from anything. + largeStaticImportBytes: 8192, + + // Virtualization libraries that make an unbounded .map() render safe. + virtualizationLibs: new Set([ + '@tanstack/react-virtual', '@tanstack/virtual-core', 'react-window', + 'react-virtualized', + ]), +}; + +// Severity ordering for "max severity" of a node. +const SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 }; +const SEV_COLOR = { + CRITICAL: '#ff3b3b', + HIGH: '#ff9f1c', + MEDIUM: '#ffd23f', + LOW: '#7f8c9b', +}; +const COLD_COLOR = '#586480'; // visible-but-muted grey-blue for below-gate nodes + +// Detector catalog: id -> { preset, guide, severity, title }. +// `guide` points at a references/knowledge/.md leaf — the hypothesis +// space a finding routes into (see references/knowledge/INDEX.md). +// `preset` is one of the four v1 presets: first-load | layout-shift | +// bundle-size | interaction. Re-render/leak/list-stability findings all +// bucket under `interaction` (they degrade INP) — there is no re-renders or +// memory-leaks preset in web v1 (memory-leaks deferred, see references/tools.md). +const DETECTORS = { + nestedComponent: { preset: 'interaction', guide: 'inp', severity: 'HIGH', + title: 'Component defined inside another component' }, + inlinePropLiteral: { preset: 'interaction', guide: 'inp', severity: 'LOW', + title: 'Inline function/object literal as prop' }, + listRowNoMemo: { preset: 'interaction', guide: 'inp', severity: 'MEDIUM', + title: 'List row component not wrapped in React.memo' }, + effectNoCleanup: { preset: 'interaction', guide: 'inp', severity: 'HIGH', + title: 'useEffect subscription/timer/observer with no cleanup' }, + indexAsKey: { preset: 'interaction', guide: 'inp', severity: 'HIGH', + title: 'List uses array index as key' }, + barrelImport: { preset: 'bundle-size', guide: 'bundle', severity: 'MEDIUM', + title: 'Barrel re-export import' }, + heavyEntryImport: { preset: 'first-load', guide: 'bundle', severity: 'HIGH', + title: 'Heavy synchronous import at app entry' }, + imageNoDims: { preset: 'layout-shift', guide: 'images-media', severity: 'HIGH', + title: 'Image without explicit dimensions' }, + unvirtualizedLongList: { preset: 'interaction', guide: 'inp', severity: 'MEDIUM', + title: 'Unbounded list rendered without virtualization' }, + clientComponentInServerTree: { preset: 'first-load', guide: 'hydration-rsc', severity: 'HIGH', + title: "'use client' boundary at a high-fan-in module" }, + syncScriptInHead: { preset: 'first-load', guide: 'third-party', severity: 'HIGH', + title: 'Render-blocking