From 17dabf4b7e0e0d841ef574c65da88e44b637d9bf Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Jul 2026 15:09:40 -0700 Subject: [PATCH 01/29] Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept A ground-up hardening of live mode, driven by a production session in a nested-app monorepo that hit six distinct failure classes. Full design rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now has a mechanical fix and a regression test. Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot (keyed on dev-server configs, not monorepo brand markers), persists a manifest, and every live CLI re-anchors onto it at startup, so a helper run from the wrong directory can no longer fork session state. Context files are discovered upward to the git root. Render truth: variant_mounted / variant_mount_failed events give the journal per-variant mount state; failures reach the agent's poll queue, raise a persistent error card with Retry (no more localStorage wipe), and an attach probe names root/dev-server mismatches explicitly. The browser rehydrates from the server when localStorage is gone. Svelte: the scaffolder now parses with the app's own svelte 5 compiler. Control flow survives (an each collection crosses the contract as one structured prop), keyed each blocks hydrate synthetic keys, and anything a detached preview cannot support falls back to source-preview instead of shipping a wrong scaffold. Preview modules live in per-publish revision directories, defeating stale transform caches. Accept: CSS is reconciled, not appended. Matching selectors are replaced, params bake from params.json kinds, the compiler's unused-selector pass prunes superseded rules (pre-existing dead rules protected), a selector- loss postcondition refuses any write that would drop hand-written rules, and live-complete refuses to finish while live plumbing remains in source. Also: framework registry (live/frameworks/) with a crash-safe injection journal, session-store snapshot caching with read-only reads, protocol enum consolidation, steer Send button, honest DESIGN-panel empty states. Testing: new unit suites (roots, AST scaffolder, accept CSS, accept pipeline, framework conformance); e2e now fails on preview-tree 404s, proves computed-style mount for every variant, drives the Tune panel through baked params, and injects failures (broken mounts, republish, storage loss). New runtime fixtures: monorepo-nested-vite (repo root != app root) and vite8-sveltekit-stateful (each blocks + state). Nightly full-matrix cron. An independent adversarial review pass preceded this commit; its blocker and major findings are fixed and regression-tested. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 16 +- .impeccable/config.json | 1 + CLAUDE.md | 6 + bun.lock | 41 + docs/LIVE-REWRITE-PLAN.md | 165 ++++ package.json | 1 + scripts/test-suites.mjs | 8 +- skill/reference/live.md | 15 +- skill/scripts/live-accept.mjs | 2 + skill/scripts/live-browser.js | 847 ++++++++++++++++-- skill/scripts/live-complete.mjs | 34 +- skill/scripts/live-inject.mjs | 549 +++--------- skill/scripts/live-insert.mjs | 2 + skill/scripts/live-poll.mjs | 2 + skill/scripts/live-resume.mjs | 49 +- skill/scripts/live-server.mjs | 133 ++- skill/scripts/live-status.mjs | 14 +- skill/scripts/live-wrap.mjs | 80 +- skill/scripts/live.mjs | 76 +- skill/scripts/live/accept-css.mjs | 597 ++++++++++++ skill/scripts/live/accept-verify.mjs | 55 ++ skill/scripts/live/event-validation.mjs | 61 +- skill/scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ skill/scripts/live/frameworks/index.mjs | 143 +++ skill/scripts/live/frameworks/journal.mjs | 197 ++++ skill/scripts/live/frameworks/nextjs.mjs | 49 + skill/scripts/live/frameworks/nuxt.mjs | 161 ++++ skill/scripts/live/frameworks/script-src.mjs | 17 + skill/scripts/live/frameworks/static-html.mjs | 26 + skill/scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 +++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + skill/scripts/live/roots.mjs | 329 +++++++ skill/scripts/live/session-store.mjs | 248 ++++- skill/scripts/live/svelte-ast.mjs | 764 ++++++++++++++++ skill/scripts/live/svelte-component.mjs | 486 ++++++++-- skill/scripts/live/tanstack-adapter.mjs | 2 +- skill/scripts/live/vocabulary.mjs | 135 +++ tests/framework-fixtures.test.mjs | 19 +- tests/framework-fixtures/README.md | 88 +- .../monorepo-nested-vite/files/DESIGN.md | 39 + .../monorepo-nested-vite/files/PRODUCT.md | 24 + .../monorepo-nested-vite/files/package.json | 4 + .../files/website/index.html | 11 + .../files/website/package.json | 19 + .../files/website/src/App.jsx | 32 + .../files/website/src/main.jsx | 10 + .../files/website/src/styles.css | 13 + .../files/website/vite.config.js | 10 + .../monorepo-nested-vite/fixture.json | 42 + .../monorepo-nested-vite/gitignore.txt | 4 + .../vite8-react-mapped-list/fixture.json | 1 + .../files/src/routes/+page.svelte | 43 +- .../vite8-sveltekit-stateful/fixture.json | 52 +- .../vite8-sveltekit/fixture.json | 1 + tests/live-accept-css.test.mjs | 199 ++++ tests/live-browser-regression.test.mjs | 268 ++++++ tests/live-e2e.test.mjs | 595 ++++++++++-- tests/live-e2e/agent.mjs | 356 +++++++- tests/live-e2e/session.mjs | 149 ++- tests/live-e2e/ui.mjs | 242 +++++ tests/live-event-validation.test.mjs | 126 ++- tests/live-frameworks.test.mjs | 431 +++++++++ tests/live-inject.test.mjs | 151 +++- tests/live-recovery-commands.test.mjs | 71 +- tests/live-roots.test.mjs | 198 ++++ tests/live-server.test.mjs | 270 +++++- tests/live-session-store.test.mjs | 338 ++++++- tests/live-svelte-ast.test.mjs | 217 +++++ tests/live-svelte-component-accept.test.mjs | 232 +++++ 72 files changed, 9254 insertions(+), 862 deletions(-) create mode 100644 docs/LIVE-REWRITE-PLAN.md create mode 100644 skill/scripts/live/accept-css.mjs create mode 100644 skill/scripts/live/accept-verify.mjs create mode 100644 skill/scripts/live/frameworks/astro.mjs create mode 100644 skill/scripts/live/frameworks/detect-utils.mjs create mode 100644 skill/scripts/live/frameworks/index.mjs create mode 100644 skill/scripts/live/frameworks/journal.mjs create mode 100644 skill/scripts/live/frameworks/nextjs.mjs create mode 100644 skill/scripts/live/frameworks/nuxt.mjs create mode 100644 skill/scripts/live/frameworks/script-src.mjs create mode 100644 skill/scripts/live/frameworks/static-html.mjs create mode 100644 skill/scripts/live/frameworks/sveltekit.mjs create mode 100644 skill/scripts/live/frameworks/tag-strategy.mjs create mode 100644 skill/scripts/live/frameworks/tanstack-start.mjs create mode 100644 skill/scripts/live/frameworks/vite-generic.mjs create mode 100644 skill/scripts/live/roots.mjs create mode 100644 skill/scripts/live/svelte-ast.mjs create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/package.json create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/index.html create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/package.json create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css create mode 100644 tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js create mode 100644 tests/framework-fixtures/monorepo-nested-vite/fixture.json create mode 100644 tests/framework-fixtures/monorepo-nested-vite/gitignore.txt create mode 100644 tests/live-accept-css.test.mjs create mode 100644 tests/live-frameworks.test.mjs create mode 100644 tests/live-roots.test.mjs create mode 100644 tests/live-svelte-ast.test.mjs create mode 100644 tests/live-svelte-component-accept.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c7d4c26..ff7e534e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] workflow_dispatch: + # Nightly full live-e2e matrix. The smoke groups already gate every PR; the + # full sweep is too slow for that, so it runs once a day against main. + schedule: + - cron: '0 7 * * *' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -157,14 +161,16 @@ jobs: name: live-e2e smoke (${{ matrix.group }}) runs-on: ubuntu-latest needs: changes - if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' + if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' timeout-minutes: 15 strategy: fail-fast: true matrix: include: - group: platform - fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit + - group: svelte + fixtures: vite8-sveltekit-stateful - group: react fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain steps: @@ -225,14 +231,16 @@ jobs: name: live-e2e full (${{ matrix.group }}) runs-on: ubuntu-latest needs: changes - if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch' + if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') timeout-minutes: 25 strategy: fail-fast: true matrix: include: - group: platform - fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit + fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router + - group: svelte + fixtures: vite8-sveltekit,vite8-sveltekit-stateful - group: react-a fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion - group: react-b diff --git a/.impeccable/config.json b/.impeccable/config.json index 84e557937..95ab71955 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -3,6 +3,7 @@ "ignoreRules": [], "ignoreFiles": [ "tests/fixtures/**", + "tests/framework-fixtures/**", "tests/detect-antipatterns.test.js" ], "ignoreValues": [ diff --git a/CLAUDE.md b/CLAUDE.md index ba558b181..db9bb2345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,12 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de **Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`. +Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`): + +- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest. +- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r/`, bumped by the server on every done-reply), not by file watching. +- **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free. + The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic. **LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`, which calls Claude (default Haiku 4.5) via `@anthropic-ai/sdk`. Requires `ANTHROPIC_API_KEY` in env; the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL=claude-sonnet-4-6` if Haiku produces unreliable JSON. Caching is on — live.md is the cacheable prefix, and after the first call subsequent fixtures pay only the cache-read rate. Pass rate on a typical sweep is 18/19; the modal fixture's intrinsic state-loss flake is amplified by LLM latency and may need a re-run. **This path hits the API and costs money** — keep it out of CI unless you really want it there. diff --git a/bun.lock b/bun.lock index b6b622322..4e2861ecb 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "ai": "^7.0.14", "archiver": "^8.0.0", "playwright": "^1.59.1", + "svelte": "^5", "zod": "^4.3.6", }, "optionalDependencies": { @@ -74,6 +75,16 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], @@ -82,6 +93,12 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], @@ -90,6 +107,8 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "ai": ["ai@7.0.31", "", { "dependencies": { "@ai-sdk/gateway": "4.0.23", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -102,8 +121,12 @@ "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -142,6 +165,8 @@ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -172,6 +197,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="], + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], @@ -202,6 +229,10 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -270,6 +301,8 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -290,6 +323,10 @@ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -398,6 +435,8 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="], + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], @@ -434,6 +473,8 @@ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/docs/LIVE-REWRITE-PLAN.md b/docs/LIVE-REWRITE-PLAN.md new file mode 100644 index 000000000..431ec046f --- /dev/null +++ b/docs/LIVE-REWRITE-PLAN.md @@ -0,0 +1,165 @@ +# Live v2: architecture plan + +> **Status (2026-07-27): implemented.** P0 through P3 landed in one pass: roots manifest (`skill/scripts/live/roots.mjs`, every live CLI re-anchors via `enterLiveRoot`), mount-ack protocol with per-variant render truth and persistent error card, server-first rehydration, AST scaffolder (`live/svelte-ast.mjs`) with source-preview fallback, unified mechanical accept (`live/accept-css.mjs`, compiler-pruned; `live-complete` refuses dirty source), revisioned preview dirs bumped per publish, attach probe with named root-mismatch diagnosis, monorepo runtime fixture, and e2e failing on preview-tree 404s. One deliberate deviation from section 3.3: the preview tree stays under `node_modules/.impeccable-live` because SvelteKit restricts vite `server.fs.allow` to src/lib, routes, .svelte-kit, and node_modules (verified: `.impeccable/` under the app root 403s); staleness is defeated by per-publish revision directories instead of watcher reliance. P4 (registry, consolidation, control-flow fixtures, nightly matrix) in flight. + +Driven by the 2026-07-25 Codex session in `~/code/agent-reviews` (session `019f9bf4-24a3-7661-afc5-bba7eb1e327f`), where a SvelteKit monorepo live session hit a chain of failures: wrong project root, silent 404 on variant modules, flattened `{#each}` blocks, stale module republish, unrecoverable browser state loss, and an accept that appended CSS instead of merging it. Every finding below was verified against current source; this is not a transcription of the Codex report. + +## 1. Verified findings (Codex claim → root cause in code) + +| # | Codex finding | Verified root cause | +|---|---|---| +| 1 | Wrong monorepo root | `findMonorepoRoot()` (`skill/scripts/context.mjs:262-303`) only recognizes "official" monorepos (workspaces field, turbo/pnpm/nx/lerna markers). A plain repo with a nested `website/` app falls into the non-monorepo branch and roots at cwd. `nearestTargetContextRoot` deliberately ignores `package.json` as a marker. Nothing ever correlates the dev server's root with `projectRoot`. | +| 2 | Published mistaken for rendered | `arrivedVariants` is backfilled from `expectedVariants` on the agent's `--reply done` (`live/session-store.mjs:211-241`). On the component-preview path the browser never sends a mount checkpoint; success calls only `saveSession()` (`live-browser.js:5592`). There is no `mounted` / `mount_failed` anywhere in the protocol. | +| 3 | Silent mount failures | Import catch is `console.error` + `return false` (`live-browser.js:5413-5419`). First-mount failure calls `abortSvelteComponentInjection` (`:5621-5651`), which clears localStorage, resets to PICKING, shows a 5s toast, and tells the server nothing. Variant-switch failure has no user feedback at all (`:4941-4950`). | +| 4 | Svelte `{#each}` flattened | Prop extraction is pure regex (`live/svelte-component.mjs:18,44-88`). `{#each}` / `{/if}` block tokens become scalar string props (`prop0`, `prop5`); a 5-item loop scaffolds as one `
  • `. A second, independent slot-shift bug: `buildSvelteExpressionTextMap` (`live-browser.js:5830-5871`) zips source tokens against live text nodes by index, and block tokens consume slots. | +| 5 | Brittle temp module paths | Modules written to `/node_modules/.impeccable-live/` but imported from `location.origin` root-relative (`live-browser.js:5163-5165`). Two roots that must agree by convention. Manifest/CSS come from the helper's `/source`; the executable module from the dev server. Two transports for one artifact. | +| 6 | Stale republish | Cache-bust is only a client-side `?t=Date.now()` on the leaf module (`live-browser.js:5377`). Vite's watcher ignores `node_modules`, so a rewrite of `vN.svelte` never invalidates the dev server's transform cache. Scaffold is write-once (`svelte-component.mjs:173`). No revision in the path; the runtime shim is memoized forever. | +| 7 | Browser-local state is a single point of failure | Every restore path gates on localStorage first (`live-browser.js:8196-8246`). Server `activeSessions` only enrich an already-known local id. Mount failure deliberately wipes local state, manufacturing the orphaned-durable-session case. | +| 8 | Parent context not discovered | Context root and project root are one variable. In the non-monorepo branch `repoRoot = absCwd` (`context.mjs:213`), so `website/` never looks one directory up for PRODUCT.md / DESIGN.md. The Codex session worked around it with symlinks that are still on disk. | +| 9 | DESIGN panel disagrees with helper | Two causes: the panel is content-driven (needs frontmatter/sidecar, `live-browser.js:11037-11068`) while `hasDesign` is presence-driven; and the server snapshot of context is frozen at module load (`live-server.mjs:61-65`) while `live.mjs` re-resolves per boot and reuses a running server. | +| 10 | Stop leaves `__runtime.js` | Sweeper skips non-directories and `__*` names (`svelte-component.mjs:732-742`). Confirmed still on disk in agent-reviews. Also unswept: accept receipts, session journals, deferred accepts in `os.tmpdir()`. | +| 11 | CSS appended, not merged | `appendCssToSvelteStyle` (`svelte-component.mjs:278-296`) splices the variant CSS before the last ``. No reconciliation exists. Worse: `mergeOriginalTopLevelAttrs` (`:646-681`) copies the original root's classes onto the new markup, guaranteeing stale rules keep matching. | +| 12 | Incomplete param baking | `sanitizeAcceptedSvelteCss` early-returns unless CSS contains `data-impeccable-variant` (`svelte-component.mjs:311-312`), which authoring rules forbid on this path, so the entire steps/toggle pruning pipeline is unreachable dead code. Only `range` vars get substituted; `toggle` bakes the raw JS boolean (`true`) into CSS. | +| 13 | Formatting destroyed | `line.trimStart()` + one flat indent for every markup line (`svelte-component.mjs:544-547`); every CSS line reindented to 2 spaces (`:280`). No formatter anywhere. The Branch-H (HTML/JSX) path preserves relative indent; the Svelte path regressed against its own sibling. | +| 14 | Preview cascade ≠ accepted cascade | Svelte scoping is compile-time per file; a detached preview component inherits none of the route's scoped CSS, so variants reimplement everything. The runtime then injects the variant CSS a second time, re-prefixed and un-hashed (`live-browser.js:5220-5280`), with different specificity than the compiled copy. | +| 18 | Steer affordance | No Send button; Enter-only (`live-browser.js:9558-9712`). Good loading state, no queue-position feedback, 120s timeout blames the wrong component. The element-level Go bar has a visible submit button; the steer bar doesn't. | + +Findings 15-17 (hook anti-pattern gaps, copy quality, visual-collision detection) are design-hook scope, not live scope; tracked separately. + +Structural facts that explain why these all shipped: + +- The 826-line `svelte-component.mjs`, which owns scaffolding, CSS append, and param baking, has **zero direct unit tests**. The only runtime Svelte fixture (`vite8-sveltekit`) has no props, no `{#if}`, no `{#each}`, no `\n' + - open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' - ); -} - -function detectLineEnding(content) { - if (content.includes('\r\n')) return '\r\n'; - if (content.includes('\r')) return '\r'; - return '\n'; -} - -function normalizeLineEndings(content, lineEnding) { - return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); -} - -function readLineEndingAt(content, index) { - if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; - if (content[index] === '\n') return '\n'; - if (content[index] === '\r') return '\r'; - return ''; -} - -function insertTag(content, config, port, filePath, token) { - const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); - // insertBefore: match the LAST occurrence. Anchors like `` naturally - // belong at the end, and the same literal can appear earlier in code blocks - // within rendered documentation pages. - if (config.insertBefore) { - const idx = content.lastIndexOf(config.insertBefore); - if (idx === -1) return content; - return content.slice(0, idx) + block + content.slice(idx); - } - // insertAfter: match the FIRST occurrence — typical anchors like `` or - // `` open near the top of the document. - const idx = content.indexOf(config.insertAfter); - if (idx === -1) return content; - const after = idx + config.insertAfter.length; - // Preserve an existing trailing newline if the anchor already has one. - // Slice the remainder from the original anchor offset, not prefix.length: - // in the no-newline case prefix is one char longer than the anchor (the - // appended '\n'), so slicing by prefix.length would drop the first real - // character after the anchor (#227). - const existingNewline = readLineEndingAt(content, after); - const prefix = content.slice(0, after) + (existingNewline || lineEnding); - const rest = content.slice(after + existingNewline.length); - return prefix + block + rest; -} - -/** - * Remove the live script block. Matches either HTML or JSX comment markers - * regardless of config (so stale tags from a wrong config can still be cleaned). - * - * Indent-preserving: captures any whitespace immediately preceding the opener - * marker and re-emits it in place of the removed block. `insertTag` inserted - * the block *after* the original line's indent and *before* the anchor (e.g. - * ``), which moved the indent onto the opener line and left the anchor - * unindented. Replacing the whole block (plus its trailing newline) with just - * the captured indent hands the indent back to the anchor that follows. - */ -function removeTag(content, _syntax) { - const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, - ]; - for (const pat of patterns) { - let changed = false; - let next = content; - do { - content = next; - next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (/[\r\n]/.test(trailing)) return leadingIndent; - return leadingIndent || trailing || ''; - }); - if (next !== content) changed = true; - } while (next !== content); - if (changed) return next; - } - return content; -} - -// --------------------------------------------------------------------------- -// Content-Security-Policy meta-tag patcher -// -// When the user's HTML carries ``, -// the cross-origin load of /live.js (and the SSE/POST connection back to -// localhost:PORT) is blocked unless the CSP explicitly allows that origin. -// -// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, -// and stash the original `content` value in a `data-impeccable-csp-original` -// attribute (base64) so revert is exact. -// -// On remove: detect the marker attribute, decode it, restore the original -// content value verbatim, drop the marker. -// -// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, -// shared helpers) is NOT patched here — those need framework-specific config -// edits and are handled via the existing detect-csp.mjs reference output. -// Only the in-source meta-tag form gets the auto-patch. -// --------------------------------------------------------------------------- - -const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; - -function findCspMetaTags(content) { - const out = []; - const tagRe = /]*?)\/?>/gis; - let m; - while ((m = tagRe.exec(content)) !== null) { - const attrs = m[1]; - if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; - out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); - } - return out; -} - -function getAttr(attrs, name) { - const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); - const m = attrs.match(re); - return m ? { quote: m[1], value: m[2], full: m[0] } : null; -} - -function appendOriginToDirective(csp, directive, origin) { - const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); - const m = csp.match(re); - if (m) { - const tokens = m[4].trim().split(/\s+/); - if (tokens.includes(origin)) return csp; - return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); - } - // Directive missing — add it. Use 'self' + origin so we don't inadvertently - // narrow the policy compared to the default-src fallback (most users with - // an explicit CSP have 'self' there). - return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; -} - -export function patchCspMeta(content, port) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - const origin = `http://localhost:${port}`; - - // Walk last-to-first so prior splices don't invalidate later indices. - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const attrs = tag.attrs; - if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched - const contentAttr = getAttr(attrs, 'content'); - if (!contentAttr) continue; - - const original = contentAttr.value; - let patched = original; - patched = appendOriginToDirective(patched, 'script-src', origin); - patched = appendOriginToDirective(patched, 'connect-src', origin); - // The shader overlay during 'generating' creates a screenshot via - // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects - // those. Add `blob:` so the overlay doesn't throw a CSP violation. - patched = appendOriginToDirective(patched, 'img-src', 'blob:'); - if (patched === original) continue; - - const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; - const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; - // The tagRe captures any whitespace between the last attribute and the - // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after - // a replace would land it BEFORE that trailing space, leaving a double - // space inside attrs and clobbering the space before `/>`. Split off - // the trailing whitespace, splice the marker into the attribute body, - // and re-append the original trailing whitespace so a self-closing - // `` round-trips byte-for-byte. - const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0]; - const attrsBody = attrs.slice(0, attrs.length - trailingWs.length); - const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs; - const newTag = tag.full.replace(attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - -export function revertCspMeta(content) { - const tags = findCspMetaTags(content); - if (tags.length === 0) return content; - - let result = content; - for (let i = tags.length - 1; i >= 0; i--) { - const tag = tags[i]; - const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); - if (!origAttr) continue; - const contentAttr = getAttr(tag.attrs, 'content'); - if (!contentAttr) continue; - - let originalValue; - try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } - catch { continue; } - - const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; - let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); - // Drop the marker attribute and any single space immediately preceding it. - newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); - const newTag = tag.full.replace(tag.attrs, newAttrs); - - result = result.slice(0, tag.start) + newTag + result.slice(tag.end); - } - return result; -} - // --------------------------------------------------------------------------- // Auto-execute // --------------------------------------------------------------------------- const _running = process.argv[1]; if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + enterLiveRoot(); injectCli(); } -export { insertTag, removeTag, validateConfig, buildTagBlock }; -// patchCspMeta + revertCspMeta are exported above where they're defined. +// Re-exported so long-standing importers (live.mjs, the adapter modules, the +// test suites) keep their entry points while the implementations live in +// live/frameworks/. +export { + buildLiveScriptSrc, + buildTagBlock, + insertTag, + patchCspMeta, + removeTag, + revertCspMeta, + validateConfig, +}; +export { + applyNuxtLiveAdapter, + buildNuxtPlugin, + detectNuxtProject, + removeNuxtLiveAdapter, +} from './live/frameworks/nuxt.mjs'; diff --git a/skill/scripts/live-insert.mjs b/skill/scripts/live-insert.mjs index b4c17cca1..8d5829ea4 100644 --- a/skill/scripts/live-insert.mjs +++ b/skill/scripts/live-insert.mjs @@ -26,6 +26,7 @@ import { scaffoldSvelteComponentInsertSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -286,5 +287,6 @@ Output (JSON): const _running = process.argv[1]; if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) { + enterLiveRoot(); insertCli(); } diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs index 19a8b0f9b..f87a4de45 100644 --- a/skill/scripts/live-poll.mjs +++ b/skill/scripts/live-poll.mjs @@ -14,6 +14,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; // Absolute path to a sibling script in this skill's scripts dir, so runtime // error hints print a directly-runnable command instead of a placeholder. @@ -412,5 +413,6 @@ export function normalizePollTypes(value) { // Auto-execute when run directly const _running = process.argv[1]; if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + enterLiveRoot(); pollCli(); } diff --git a/skill/scripts/live-resume.mjs b/skill/scripts/live-resume.mjs index 74284d48a..04306ff0c 100644 --- a/skill/scripts/live-resume.mjs +++ b/skill/scripts/live-resume.mjs @@ -4,6 +4,7 @@ */ import { createLiveSessionStore } from './live/session-store.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; @@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) { return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort(); } +/** + * The browser's render truth, folded into a small block the agent reads before + * it decides what to do. `arrivedVariants` only says the agent published; + * `renderState` says whether any of it reached a screen. + */ +export function renderSummary(snapshot = {}) { + return { + renderState: snapshot.renderState ?? null, + mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [], + mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [], + }; +} + +export function mountFailureAction(snapshot = {}) { + const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : []; + const latest = failures[failures.length - 1]; + if (!latest) return null; + const where = latest.url ? ` from ${latest.url}` : ''; + const why = latest.error ? ` (${latest.error})` : ''; + return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file for the queued variant_mount_failed event (or republish) so the browser retries.`; +} + function parseArgs(argv) { const out = { id: null }; for (let i = 0; i < argv.length; i++) { @@ -75,20 +98,26 @@ export async function resumeCli() { } const pending = snapshot.pendingEvent || null; - const nextAction = pending - ? pending.type === 'manual_edit_apply' - ? manualApplyResumeHint(pending) - : `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` - : snapshot.phase === 'carbonize_required' - ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` - : snapshot.phase === 'accept_requested' - ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` - : `Inspect ${snapshot.id}; no pending agent event is currently queued.`; + const render = renderSummary(snapshot); + // A failed render outranks the generic pending-event hint: the agent needs to + // know the user is staring at an error card, not at variants. A leased manual + // Apply still outranks both, because abandoning that lease loses user edits. + const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null; + const nextAction = pending?.type === 'manual_edit_apply' + ? manualApplyResumeHint(pending) + : mountAction || (pending + ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.` + : snapshot.phase === 'carbonize_required' + ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.` + : snapshot.phase === 'accept_requested' + ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.` + : `Inspect ${snapshot.id}; no pending agent event is currently queued.`); - console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2)); + console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2)); } const _running = process.argv[1]; if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) { + enterLiveRoot(); resumeCli(); } diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 113ad01b3..14a207508 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs'; import { validateEvent } from './live/event-validation.mjs'; import { selectAvailablePendingEvent } from './live/poll-lanes.mjs'; import { createManualEditRoutes } from './live/manual-edit-routes.mjs'; -import { LIVE_COMMANDS } from './live/vocabulary.mjs'; +import { + LIVE_COMMANDS, + VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST, +} from './live/vocabulary.mjs'; import { getDesignSidecarPath, getLiveDir, @@ -51,24 +54,46 @@ import { } from './live/manual-apply.mjs'; import { applyDeferredSvelteComponentAccepts, + bumpSvelteComponentPreviewRevision, removeAllSvelteComponentSessions, + sweepInactiveSvelteComponentSessions, } from './live/svelte-component.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated -// DESIGN sidecar is project-local at .impeccable/design.json, with legacy -// DESIGN.json fallback for existing projects. -const PROJECT_CONTEXT = loadContext(process.cwd()); -const CONTEXT_DIR = PROJECT_CONTEXT.contextDir; -const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath - ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath) - : null; +// Anchor the whole process on the live roots manifest before anything derives +// a path from cwd. A server started from the wrong directory re-roots itself +// onto the appRoot the boot decided on instead of minting a second project. +const LIVE_ROOTS = enterLiveRoot(process.cwd()); + +// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server +// that outlives an `impeccable document` run (or a context file created after +// boot) reports current truth instead of a boot-time snapshot. The roots +// manifest wins when the ambient resolution misses (nested app inheriting +// repo-level context files). +function resolveProjectContext() { + const ctx = loadContext(process.cwd()); + const designPath = ctx.designPath + ? path.resolve(process.cwd(), ctx.designPath) + : (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null); + const hasProduct = ctx.hasProduct + || !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath)); + return { + ...ctx, + hasProduct, + hasDesign: !!designPath, + resolvedDesignPath: designPath, + contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(), + designContextDir: ctx.designContextDir + || (designPath ? path.dirname(designPath) : null), + }; +} const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s // The browser checkpoints for several unrelated reasons (see checkpointPayload // in live-browser.js). Only these two report that variant availability changed, // and only they may drive variant_progress / the *_reviewable phases. -const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']); +const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST); // --------------------------------------------------------------------------- // Port detection @@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) { generationCompletedAt: snapshot.generationCompletedAt ?? null, generationCanceled: snapshot.generationCanceled === true, cancelReason: snapshot.cancelReason ?? null, + // Render truth, so a browser with no localStorage can rehydrate to the + // same comparison the server already knows about. + mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [], + mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [], + renderState: snapshot.renderState ?? null, }; } @@ -618,7 +648,7 @@ function hasProjectContext() { // PRODUCT.md carries brand voice / anti-references — that's what determines // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate // concern, surfaced by the design panel's own empty state. - return !!PROJECT_CONTEXT.hasProduct; + return !!resolveProjectContext().hasProduct; } function statOrNull(filePath) { @@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } - const mdPath = DESIGN_MD_PATH; - const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd()); + const projectContext = resolveProjectContext(); + const mdPath = projectContext.resolvedDesignPath; + const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd()); const mdStat = statOrNull(mdPath); const jsonStat = statOrNull(jsonPath); @@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) { if (msg.type === 'exit') { cleanupSvelteComponentSessionsBeforeExit(); } - if (msg.type !== 'checkpoint') { + // `variant_mounted` is the happy path: it is journaled above so the + // snapshot carries render truth, but there is nothing for the agent to + // do about it, so it stays out of the poll queue and off the SSE bus. + // `variant_mount_failed` is the opposite: the agent published something + // the browser could not render, and only the agent can fix it, so it + // goes to the queue as a first-class event. + if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') { enqueueEvent(msg); } res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) { const base = { file: normalized }; const metadataFile = normalized; if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base; - if (!metadataFile.includes('node_modules/.impeccable-live/') + if (!metadataFile.includes('.impeccable/live/previews/') + && !metadataFile.includes('node_modules/.impeccable-live/') && !metadataFile.includes('src/lib/impeccable/') && !metadataFile.includes('/.impeccable-live/')) return base; @@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) { // `agent_done` can be the automatic acknowledgement for a carbonize Accept. // New pollers send sourceEventType explicitly; default to generate only for // older callers so a late worker cannot acknowledge a queued Accept. - if (msg.type === 'agent_done' || msg.type === 'done') return 'generate'; + if (msg.type === 'agent_done' || msg.type === 'done') { + // A `done` reply to a mount failure is the republish that unblocks the + // browser. Without this the ack would look for a `generate` that was + // already retired, the mount-failure event would stay queued, and the next + // poll would hand the same failure back to the agent forever. + if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed'; + return 'generate'; + } // `error` is reference/live.md's documented failure reply, and parseReplyArgs // never sets sourceEventType on it (the poller is a fresh process that cannot // know what it leased). Returning undefined here makes acknowledgePendingEvent @@ -1264,6 +1309,15 @@ function handlePollPost(req, res) { return; } const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); + // A publish (done reply carrying a component manifest) snapshots the + // variant files into a fresh revision dir before the browser is told: + // the import path changes every publish, so no transform cache can pin a + // stale compile of a republished module (node_modules is unwatched). + if (replyFileMeta.previewMode === 'svelte-component' + && msg.id + && (msg.type === 'done' || !msg.type)) { + try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ } + } if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() { } } +/** + * A previous run that died without its shutdown hook leaves preview component + * dirs behind. Drop the ones whose session the store no longer considers + * active; anything still active is mid-generation and must survive a restart. + */ +function sweepOrphanSvelteComponentSessionsOnStartup() { + try { + const activeIds = (state.sessionStore?.listActiveSessions() || []) + .map((snapshot) => snapshot?.id) + .filter(Boolean); + const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd()); + if (result.removed.length > 0 || result.removedRoot) { + console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] Svelte component session sweep failed:', err.message); + } +} + +// Accept receipts are a short-lived idempotency record for a single accept. +// Nothing reads one after the session that wrote it is gone, so they only need +// to outlive a crash-and-retry window. +const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; + +function sweepStaleAcceptReceiptsOnStartup() { + try { + const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts'); + if (!fs.existsSync(dir)) return; + const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS; + let removed = 0; + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue; + const file = path.join(dir, name); + try { + if (fs.statSync(file).mtimeMs >= cutoff) continue; + fs.rmSync(file, { force: true }); + removed++; + } catch { /* non-fatal */ } + } + if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`); + } catch (err) { + console.warn('[impeccable] accept receipt retention sweep failed:', err.message); + } +} + function applyLegacyDeferredAcceptsOnStartup() { try { const result = applyDeferredSvelteComponentAccepts(process.cwd()); @@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({ reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); applyLegacyDeferredAcceptsOnStartup(); +sweepOrphanSvelteComponentSessionsOnStartup(); +sweepStaleAcceptReceiptsOnStartup(); restorePendingEventsFromStore(); manualApply.pruneStaleEvidence(); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/skill/scripts/live-status.mjs b/skill/scripts/live-status.mjs index dc98b7386..6ed2b890a 100644 --- a/skill/scripts/live-status.mjs +++ b/skill/scripts/live-status.mjs @@ -5,7 +5,8 @@ import { createLiveSessionStore } from './live/session-store.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; -import { manualApplyResumeHint } from './live-resume.mjs'; +import { manualApplyResumeHint, mountFailureAction, renderSummary } from './live-resume.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; function readServerInfo() { return readLiveServerInfo(process.cwd())?.info || null; @@ -28,6 +29,8 @@ export async function statusCli() { const store = createLiveSessionStore({ cwd: process.cwd() }); const activeSessions = store.listActiveSessions(); const manualApply = findPendingManualApply(server, activeSessions); + const sessions = server?.activeSessions || activeSessions; + const renderFailure = sessions.find((session) => session?.renderState === 'failed') || null; const payload = { liveServer: server ? { status: server.status, @@ -36,14 +39,16 @@ export async function statusCli() { agentPolling: server.agentPolling, pendingEvents: server.pendingEvents, } : null, - activeSessions: server?.activeSessions || activeSessions, - recoveryHint: recoveryHint({ server, manualApply }), + activeSessions: sessions, + render: sessions.map((session) => ({ id: session?.id ?? null, ...renderSummary(session) })), + recoveryHint: recoveryHint({ server, manualApply, renderFailure }), }; console.log(JSON.stringify(payload, null, 2)); } -function recoveryHint({ server, manualApply }) { +function recoveryHint({ server, manualApply, renderFailure }) { if (manualApply) return manualApplyResumeHint(manualApply); + if (renderFailure) return mountFailureAction(renderFailure); if (server) { return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id after manual cleanup.'; } @@ -61,5 +66,6 @@ function findPendingManualApply(server, activeSessions) { const _running = process.argv[1]; if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) { + enterLiveRoot(); statusCli(); } diff --git a/skill/scripts/live-wrap.mjs b/skill/scripts/live-wrap.mjs index 090ad30e9..55e83536f 100644 --- a/skill/scripts/live-wrap.mjs +++ b/skill/scripts/live-wrap.mjs @@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; import { findSourceFile } from './live/source-search.mjs'; +import { resolveSourceTraits } from './live/frameworks/index.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; +import { enterLiveRoot } from './live/roots.mjs'; export async function wrapCli() { const args = process.argv.slice(2); @@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`); .join('\n'); const originalIndented = reindentOriginal(' '); const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); - const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile); - const useFrameworkComponent = useSvelteComponent; + // The registry says which files get component preview; the svelte-component + // module keeps the env escape hatch that turns it off. + const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component' + && shouldUseSvelteComponentInjection(targetFile); // Wrapper attributes differ by syntax. HTML allows plain string attrs; // JSX requires object-literal style and parses string attrs as HTML (which @@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`); let svelteSession = null; let deferredWrapper = null; + let sveltePreviewFallback = null; if (useSvelteComponent) { // Svelte/SvelteKit resets component-local state on markup HMR updates. // Keep generation source-neutral: agents write real variant components // under the generated componentDir, the browser mounts them into the live // DOM, and live-accept.mjs inlines the accepted variant back into the route. - svelteSession = scaffoldSvelteComponentSession({ + // + // The scaffold is AST-based and refuses markup a detached preview cannot + // support (component tags, bind:/use:, await blocks, bound nested each). + // Refusal falls back to the plain source-preview wrapper below: an + // HMR-resetting but CORRECT preview beats a detached wrong one. + const scaffolded = scaffoldSvelteComponentSession({ id, count, sourceFile: relTargetFile, @@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`); originalLines, cwd: process.cwd(), }); - outputFile = path.resolve(process.cwd(), svelteSession.manifestFile); - outputStartLine = 1; - outputEndLine = 1; - insertLine = 1; + if (scaffolded && scaffolded.fallback === 'source-preview') { + sveltePreviewFallback = scaffolded.reason || 'unsupported markup'; + } else { + svelteSession = scaffolded; + outputFile = path.resolve(process.cwd(), svelteSession.manifestFile); + outputStartLine = 1; + outputEndLine = 1; + insertLine = 1; + } + } + if (svelteSession) { + // component preview: outputs already set above } else if (deferSourceWrite) { // Deferred source write: compute the scaffold text but leave source // untouched. The agent replaces the picked element's source range with @@ -396,15 +414,19 @@ The agent should insert variant HTML at insertLine.`); const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/'); - const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null; + const componentPreviewActive = !!svelteSession; + const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null; const componentSession = svelteSession; - const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined; + const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined; const previewMode = componentPreviewMode; console.log(JSON.stringify({ file: outputRelFile, - sourceFile: useFrameworkComponent ? relTargetFile : undefined, + sourceFile: componentPreviewActive ? relTargetFile : undefined, previewMode, + previewFallback: sveltePreviewFallback + ? { from: 'svelte-component', reason: sveltePreviewFallback } + : undefined, // Deferred source write: the wrapper is NOT yet in source. The agent // replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants // spliced at the "insert below this line" marker) in one atomic edit. @@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`); replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined, componentDir: componentSession?.componentDir, propContract: componentSession?.propContract, - sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined, - sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined, + sourceStartLine: componentPreviewActive ? startLine + 1 : undefined, + sourceEndLine: componentPreviewActive ? endLine + 1 : undefined, startLine: outputStartLine, // 1-indexed for the agent // wrapperLines is an array but one element (the original-content slot) // is a `\n`-joined multi-line string, so the actual file-row count is @@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`); insertLine, // 1-indexed: where variants go commentSyntax: commentSyntax, styleMode: componentPreviewMode || styleMode.mode, - styleTag: useFrameworkComponent ? null : styleMode.styleTag, - cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count), + styleTag: componentPreviewActive ? null : styleMode.styleTag, + cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count), cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count), originalLineCount: originalLines.length, })); @@ -630,27 +652,22 @@ function attrEscapeDouble(str) { .replace(/>/g, '>'); } +/** + * Comment syntax, style mode, and preview strategy all come from the framework + * registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX + * comments, `.astro` needs global-prefixed preview CSS because Astro scopes + * component styles away from the generated wrappers, `.svelte` gets component + * preview. See live/frameworks/index.mjs for why extension and not project. + */ function detectCommentSyntax(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.jsx' || ext === '.tsx') { - return { open: '{/*', close: '*/}' }; - } - // HTML, Vue, Svelte, Astro all use HTML comments - return { open: '' }; + return resolveSourceTraits(filePath).commentSyntax === 'jsx' + ? { open: '{/*', close: '*/}' } + : { open: '' }; } function detectStyleMode(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.astro') { - return { - mode: 'astro-global-prefixed', - styleTag: '\n`; } +/** + * Scaffold a component-preview session. The scaffold is AST-based: the app's + * own svelte compiler parses the selected markup, control-flow blocks are + * preserved (an each collection crosses the prop contract as ONE structured + * prop, its loop body verbatim), and constructs a detached preview cannot + * support return `{ fallback: 'source-preview', reason }` so the caller keeps + * the markup inside the route file instead of shipping a wrong preview. + */ export function scaffoldSvelteComponentSession({ id, count, @@ -145,17 +191,31 @@ export function scaffoldSvelteComponentSession({ originalLines, cwd = process.cwd(), }) { + const originalMarkup = originalLines.join('\n'); + + const compiler = loadSvelteCompiler(cwd); + if (!compiler) { + return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' }; + } + const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse); + if (!analysis.ok) { + return { fallback: 'source-preview', reason: analysis.reason }; + } + ensureRuntimeHelper(cwd); const dir = componentSessionDir(id, cwd); fs.mkdirSync(dir, { recursive: true }); - const originalMarkup = originalLines.join('\n'); - const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); - const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + const contract = analysis.contract; + const seededCss = extractMatchingSourceCss( + safeReadSource(path.resolve(cwd, sourceFile)), + originalMarkup, + ); const manifest = { id, previewMode: 'svelte-component', + contractVersion: 2, sourceFile: sourceFile.split(path.sep).join('/'), sourceStartLine, sourceEndLine, @@ -163,7 +223,14 @@ export function scaffoldSvelteComponentSession({ propContract: contract, originalMarkup, componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + // Absolute paths let the browser fall back to /@fs/ imports when the dev + // server's base or root makes root-relative URLs miss, and probe whether + // the preview tree is reachable at all before blaming a variant. + componentDirAbs: dir.split(path.sep).join('/'), runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), + probeModule: `/${SVELTE_PROBE_FILE}`, + probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), }; fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); @@ -171,7 +238,7 @@ export function scaffoldSvelteComponentSession({ for (let n = 1; n <= count; n++) { const variantFile = path.join(dir, `v${n}.svelte`); if (!fs.existsSync(variantFile)) { - fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8'); } } @@ -183,6 +250,59 @@ export function scaffoldSvelteComponentSession({ }; } +function safeReadSource(filePath) { + try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } +} + +/** + * Seed variant stubs with the source component's rules that already style the + * selected markup, so variants start from the real cascade (a detached + * preview inherits none of the route's compile-scoped CSS) instead of + * reimplementing it blind. + */ +export function extractMatchingSourceCss(routeSource, originalMarkup) { + const styleMatch = String(routeSource || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return ''; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return ''; + + const selectorMatches = (prelude) => splitSelectorList(prelude).some((selector) => { + for (const cls of classNames) if (selector.includes(`.${cls}`)) return true; + return false; + }); + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && selectorMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return serializeNodes(pick(parseStylesheet(styleMatch[1]))); +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + const css = seededCss + ? `\n\n` + : `\n\n`; + return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`; +} + export function scaffoldSvelteComponentInsertSession({ id, count, @@ -213,7 +333,11 @@ export function scaffoldSvelteComponentInsertSession({ count, propContract: [], componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + componentDirAbs: dir.split(path.sep).join('/'), runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'), + probeModule: `/${SVELTE_PROBE_FILE}`, + probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'), }; fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); @@ -238,16 +362,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) { if (fs.existsSync(direct)) { return readManifest(direct); } - const root = path.join(cwd, SVELTE_COMPONENT_ROOT); - if (!fs.existsSync(root)) return null; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const candidate = path.join(root, entry.name, 'manifest.json'); - if (!fs.existsSync(candidate)) continue; - try { - const manifest = readManifest(candidate); - if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; - } catch { /* skip */ } + // Legacy location: a session scaffolded by an older version can still be + // accepted after an upgrade. + const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json'); + if (fs.existsSync(legacyDirect)) { + return readManifest(legacyDirect); + } + for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { + const root = path.join(cwd, rootRel); + if (!fs.existsSync(root)) continue; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } } return null; } @@ -451,35 +583,6 @@ function rewriteParamSelectors(selector, paramValues) { return { keep, selector: next }; } -function splitSelectorList(prelude) { - const selectors = []; - let start = 0; - let bracket = 0; - let paren = 0; - let quote = null; - for (let i = 0; i < prelude.length; i++) { - const ch = prelude[i]; - if (quote) { - if (ch === '\\') i++; - else if (ch === quote) quote = null; - continue; - } - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - if (ch === '[') bracket++; - else if (ch === ']') bracket = Math.max(0, bracket - 1); - else if (ch === '(') paren++; - else if (ch === ')') paren = Math.max(0, paren - 1); - else if (ch === ',' && bracket === 0 && paren === 0) { - selectors.push(prelude.slice(start, i)); - start = i + 1; - } - } - selectors.push(prelude.slice(start)); - return selectors; -} function selectorHasVariant(selector, variantNum) { return variantSelectorRegex(variantNum).test(selector); @@ -527,10 +630,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = const rootTag = matchOpeningTag(markup)?.tag || 'div'; const contract = manifest.propContract || []; + const compiler = loadSvelteCompiler(cwd); const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); - const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) - .split('\n') - .map((line) => line.trimEnd()); + + // Restore props back to route expressions. Contract v2 restores through the + // AST so a prop used without braces (each headers, attribute positions) + // still maps back to its original expression; v1 falls back to the textual + // placeholder swap. + let restoredText; + if (Number(manifest.contractVersion) === 2 && compiler) { + const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse); + if (!restored.ok) { + return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase }; + } + restoredText = restored.markup; + } else { + restoredText = substitutePropsWithExprs(mergedMarkup, contract); + } + const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd()); const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); const sourceLines = sourceContent.split('\n'); @@ -541,10 +658,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = } const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = restoredMarkup.map((line) => { - if (line.trim() === '') return ''; - return indent + line.trimStart(); - }); + const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); let newLines = [ ...sourceLines.slice(0, start), @@ -552,25 +666,145 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = ...sourceLines.slice(end + 1), ]; - const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); - const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); - if (bakedCss.length > 0) { - newLines = appendCssToSvelteStyle(newLines, bakedCss); + // Selectors that were already unused before this accept are the user's + // pre-existing code; the pruning pass must not touch them. + const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set(); + + // Bake params (declared kinds from params.json drive branch pruning), then + // MERGE into the component's existing style block: matching selectors are + // replaced, new ones appended. Appending alone is how superseded rules used + // to survive their own replacement. + const declaredParams = readDeclaredParams(manifest, variantNum, cwd); + let variantCss = cssLines.join('\n'); + if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { + // Defensive: strip preview-wrapper selectors that authoring rules forbid + // on this path but an off-spec agent may still emit. + variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); + } + const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); + const cssStats = { replaced: 0, appended: 0, pruned: [] }; + if (bakedCss.trim()) { + const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); + newLines = merged.text.split('\n'); + cssStats.replaced = merged.replaced; + cssStats.appended = merged.appended; + } + + let finalText = newLines.join('\n'); + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune deliberately removed it. This turns any + // parser or reconciler defect into a loud refusal instead of silent damage + // to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, cssStats.pruned); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { + const before = collectAllSelectors(styleBlockText(beforeSource)); + const after = collectAllSelectors(styleBlockText(afterSource)); + const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s))); + const lost = []; + for (const selector of before) { + if (!after.has(selector) && !pruned.has(selector)) lost.push(selector); + } + return lost; +} + +function readDeclaredParams(manifest, variantNum, cwd) { + try { + const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8')); + const list = raw?.[String(variantNum)]; + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +/** + * Merge CSS into a svelte component's top-level style block (created when + * absent), replacing rules whose selectors match and appending the rest. + */ +export function mergeCssIntoSvelteSource(sourceText, incomingCss) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\n`, + replaced, + appended, + }; + } + + const inner = lastMatch[1]; + const { css, replaced, appended } = reconcileCss(inner, incomingCss); + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`; + return { + text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length), + replaced, + appended, + }; +} + +function indentCssBlock(css) { + return String(css || '') + .split('\n') + .map((line) => (line.trim() === '' ? '' : ' ' + line)) + .join('\n'); +} + function inlineSvelteComponentInsertAccept({ manifest, markup, @@ -601,10 +835,7 @@ function inlineSvelteComponentInsertAccept({ const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; - const indentedMarkup = restoredMarkup.map((line) => { - if (line.trim() === '') return ''; - return indent + line.trimStart(); - }); + const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent); let newLines = [ ...sourceLines.slice(0, insertIndex), @@ -612,10 +843,15 @@ function inlineSvelteComponentInsertAccept({ ...sourceLines.slice(insertIndex), ]; - const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); - const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); - if (bakedCss.length > 0) { - newLines = appendCssToSvelteStyle(newLines, bakedCss); + let variantCss = cssLines.join('\n'); + if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) { + variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n'); + } + const declaredParams = readDeclaredParams(manifest, variantNum, cwd); + const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {}); + if (bakedCss.trim()) { + const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss); + newLines = merged.text.split('\n'); } try { @@ -625,8 +861,10 @@ function inlineSvelteComponentInsertAccept({ } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(newLines.join('\n')); return { handled: true, + verify, ...resultBase, }; } @@ -729,18 +967,124 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) { } catch { /* non-fatal */ } } +/** + * Snapshot the agent-authored variant files into a fresh revision directory + * and stamp the manifest. Called by the server on every publish (`done` + * reply) for a component session; the browser imports from the revision dir, + * so the dev server can never serve a stale compile of a republished file. + */ +export function bumpSvelteComponentPreviewRevision(id, cwd = process.cwd()) { + const manifest = findSvelteComponentManifest(id, cwd); + if (!manifest || !manifest.manifestPath) return null; + const sessionDir = path.dirname(manifest.manifestPath); + const revision = Number(manifest.revision || 0) + 1; + const revDirName = `r${revision}`; + const revDir = path.join(sessionDir, revDirName); + try { + fs.mkdirSync(revDir, { recursive: true }); + let entries = []; + try { entries = fs.readdirSync(sessionDir, { withFileTypes: true }); } catch { /* empty */ } + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === 'manifest.json') continue; + fs.copyFileSync(path.join(sessionDir, entry.name), path.join(revDir, entry.name)); + } + // Previous revision dirs are dead the moment a new one exists. + for (const entry of entries) { + if (entry.isDirectory() && /^r\d+$/.test(entry.name) && entry.name !== revDirName) { + try { fs.rmSync(path.join(sessionDir, entry.name), { recursive: true, force: true }); } catch { /* non-fatal */ } + } + } + const relSessionDir = path.relative(cwd, sessionDir).split(path.sep).join('/'); + const updated = { + ...manifest, + revision, + revisionDir: `${relSessionDir}/${revDirName}`, + revisionDirAbs: revDir.split(path.sep).join('/'), + }; + delete updated.manifestPath; + fs.writeFileSync(manifest.manifestPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8'); + return { revision, revisionDir: updated.revisionDir }; + } catch { + return null; + } +} + +/** + * Stop-path sweep. The whole `node_modules/.impeccable-live` tree is + * impeccable-owned and gitignored, so once no session should survive there is + * nothing left worth keeping: the per-session dirs, the generated + * `__runtime.js`, and the parent directory all go. The old per-entry loop + * skipped `__*` entries and the parent, which left the runtime shim and an + * empty directory in every project that ever ran live mode once. + */ export function removeAllSvelteComponentSessions(cwd = process.cwd()) { - const root = path.join(cwd, SVELTE_COMPONENT_ROOT); - if (!fs.existsSync(root)) return; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('__')) continue; + for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { + const root = path.join(cwd, rootRel); + if (!fs.existsSync(root)) continue; try { - fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); } catch { /* non-fatal */ } } } +/** + * Boot-path sweep. A restart must not delete the tree wholesale: sessions + * recorded in the session store may still be mid-generation. Remove only the + * session dirs whose id has no active snapshot, then drop `__runtime.js` and + * the parent directory when nothing is left to serve. + * + * @param {Iterable} activeIds session ids that must be preserved + * @returns {{ removed: string[], removedRoot: boolean, kept: string[] }} + */ +export function sweepInactiveSvelteComponentSessions(activeIds = [], cwd = process.cwd()) { + const result = { removed: [], removedRoot: false, kept: [] }; + const active = new Set(); + for (const id of activeIds || []) { + if (typeof id === 'string' && id) active.add(id); + } + + for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) { + const root = path.join(cwd, rootRel); + if (!fs.existsSync(root)) continue; + + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + continue; + } + + let keptHere = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + if (active.has(entry.name)) { + result.kept.push(entry.name); + keptHere++; + continue; + } + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + result.removed.push(entry.name); + } catch { + // Could not remove it, so it still occupies the tree; treat it as kept + // so the parent directory is not torn out from under it. + result.kept.push(entry.name); + keptHere++; + } + } + + if (keptHere === 0) { + try { + fs.rmSync(root, { recursive: true, force: true }); + result.removedRoot = true; + } catch { /* non-fatal */ } + } + } + return result; +} + export function deferredAcceptsPath(cwd = process.cwd()) { const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); diff --git a/skill/scripts/live/tanstack-adapter.mjs b/skill/scripts/live/tanstack-adapter.mjs index c682914b1..4a1c81a97 100644 --- a/skill/scripts/live/tanstack-adapter.mjs +++ b/skill/scripts/live/tanstack-adapter.mjs @@ -19,7 +19,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { buildLiveScriptSrc } from '../live-inject.mjs'; +import { buildLiveScriptSrc } from './frameworks/script-src.mjs'; export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}'; export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}'; diff --git a/skill/scripts/live/vocabulary.mjs b/skill/scripts/live/vocabulary.mjs index 5c7b0b713..ce4e0927a 100644 --- a/skill/scripts/live/vocabulary.mjs +++ b/skill/scripts/live/vocabulary.mjs @@ -34,3 +34,138 @@ export const LIVE_COMMANDS = [ // Action values accepted by the live event protocol, in palette order. export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value); + +/* + * --------------------------------------------------------------------------- + * Protocol vocabulary + * --------------------------------------------------------------------------- + * The enums below are the wire contract between the browser overlay, the live + * helper server, and the durable session journal. They live here rather than in + * the modules that use them so a value cannot be added to the validator without + * the store and the server seeing it too. + * + * live-browser.js still cannot import this file (it is served raw and injected + * as an IIFE), so its local phase table repeats the agent-phase names. Anything + * the server can broadcast must appear in AGENT_PHASES here first. + */ + +/** + * Phases the live server broadcasts as `agent_phase`, in lifecycle order. + * Every one of these is emitted by `recordAgentPhase()` in live-server.mjs; + * the validator rejects anything else, so a typo in a phase name fails loudly + * instead of quietly ranking as an unknown phase in the browser's progress bar. + */ +export const AGENT_PHASES = Object.freeze([ + 'picked_up', + 'scaffolding', + 'source_ready', + 'scaffold_fallback', + 'generation_ready', + 'first_reviewable', + 'second_reviewable', + 'all_variants_ready', +]); + +/** Event types the helper server accepts from the browser over POST /events. */ +export const CLIENT_EVENT_TYPES = Object.freeze([ + 'generate', + 'accept', + 'discard', + 'checkpoint', + 'agent_phase', + 'variant_mounted', + 'variant_mount_failed', + 'exit', + 'prefetch', + 'manual_edits', + 'steer', + 'carbonize_cleanup', +]); + +/** + * Event types the durable journal applies. A superset of CLIENT_EVENT_TYPES: + * the agent-side helpers (live-poll, live-complete) and the server itself + * append the rest. An event type missing here lands as `unknown_event_type` + * in the snapshot diagnostics. + */ +export const JOURNAL_EVENT_TYPES = Object.freeze([ + 'generate', + 'variant_plan', + 'detector_waivers', + 'agent_phase', + 'variants_ready', + 'agent_done', + 'variant_mounted', + 'variant_mount_failed', + 'checkpoint', + 'accept', + 'accept_intent', + 'manual_edit_apply', + 'steer', + 'steer_done', + 'carbonize_cleanup', + 'discard', + 'discarded', + 'complete', + 'agent_error', +]); + +/** Phases the session store assigns to a snapshot. */ +export const SESSION_PHASES = Object.freeze([ + 'new', + 'generate_requested', + 'variants_ready', + 'carbonize_required', + 'carbonize_cleanup_requested', + 'manual_edit_apply_requested', + 'steer_requested', + 'steer_done', + 'accept_requested', + 'discard_requested', + 'discarded', + 'completed', + 'agent_error', +]); + +/** Phases that retire a session from the active list. */ +export const COMPLETED_SESSION_PHASES = Object.freeze(['completed', 'discarded']); + +/** + * Phases after which a late generation write is a ghost from a canceled cycle. + * The store journals such an event as a diagnostic instead of applying it. + */ +export const GENERATION_FENCED_SESSION_PHASES = Object.freeze([ + 'accept_requested', + 'discard_requested', + 'carbonize_required', + 'completed', + 'discarded', +]); + +/** + * `reason` values carried on checkpoint events. Not validated (an unknown + * reason is journaled, never rejected) because the reason is diagnostic + * breadcrumb, not control flow. Two exceptions drive behavior and are split + * out below. + */ +export const CHECKPOINT_REASONS = Object.freeze([ + 'generate_started', + 'variants_progress', + 'variants_ready', + 'browser_resumed', + 'browser_resumed_svelte_component', + 'param_changed', + 'variant_anchor_missing', + 'component_preview_anchor_missing', + 'steer_input_focused', + 'steer_submitted', + 'steer_send_failed', + 'steer_done', + 'steer_error', +]); + +/** Checkpoint reasons the server reads as variant-publication progress. */ +export const VARIANT_PROGRESS_CHECKPOINT_REASONS = Object.freeze([ + 'variants_progress', + 'variants_ready', +]); diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs index 15be7cf41..112739427 100644 --- a/tests/framework-fixtures.test.mjs +++ b/tests/framework-fixtures.test.mjs @@ -12,7 +12,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -22,6 +22,7 @@ import { detectCsp } from '../skill/scripts/detect-csp.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPTS_DIR = join(__dirname, '..', 'skill', 'scripts'); +const REPO_ROOT = join(__dirname, '..'); const FIXTURES_DIR = join(__dirname, 'framework-fixtures'); function listFixtures() { @@ -46,6 +47,22 @@ function stageFixture(name) { mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true }); writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config)); + // The AST scaffolder resolves the app's svelte compiler from the staged + // root; the runtime suite gets it from a real npm install, the static sweep + // links this repo's devDependency so svelte fixtures take the + // component-preview path here too. + const repoSvelte = join(REPO_ROOT, 'node_modules', 'svelte'); + if (existsSync(repoSvelte) && !existsSync(join(tmp, 'node_modules', 'svelte'))) { + mkdirSync(join(tmp, 'node_modules'), { recursive: true }); + try { + symlinkSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), 'dir'); + } catch { + // Windows without Developer Mode cannot symlink; copying is slower but + // keeps the suite runnable there. + cpSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), { recursive: true }); + } + } + execFileSync('git', ['init', '-q'], { cwd: tmp }); execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp }); execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp }); diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md index bbbaa8c01..a4f871ebb 100644 --- a/tests/framework-fixtures/README.md +++ b/tests/framework-fixtures/README.md @@ -37,6 +37,7 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende }, "runtime": { "styling": "plain-css | tailwind-v4 | styled-components | ...", + "appDir": "website", "install": ["npm", "install"], "devCommand": ["npm", "run", "dev"], "scheme": "http", @@ -44,6 +45,27 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyTimeoutMs": 120000, "pickSelector": "h1.hero-title", + "pickPosition": { "x": 10, "y": 10 }, + "variantSequence": [3, 1, 2], + "acceptedSourcePattern": "]*class=\"[^\"]*\\bexpense-list\\b", + "assertSourceContains": ["{#each expenses as expense, i}"], + "stateProbe": { + "textSelector": "[data-testid='open-count']", + "expectedText": "3 offen", + "windowProperty": "__impeccableStatefulMounts", + "expectedWindowValue": 1, + "expectWindowUnchanged": true + }, + "paramsScenario": { + "variant": 2, + "rangeLabel": "Lead", + "rangeValue": 1.8, + "stepsLabel": "Density", + "stepsOptionLabel": "Snug", + "expectSourceContains": ["line-height: 1.8"], + "expectSourceMissing": ["letter-spacing: 0.14em"] + }, + "componentFailureScenarios": { "variant": 2, "storageLoss": false }, "mode": "insert", "insert": { "anchorSelector": "section#features", @@ -84,10 +106,70 @@ The `runtime` block is optional. Fixtures without it only run the static unit ch 6. Runs a **Steer smoke** step (unless `runtime.steer === false`): submit a message in the global Steer bar, wait for the fake agent to reply `steer_done`, assert the bar unlocks and a `data-impeccable-steer` marker lands in source + DOM. Then continues with pick → Go → cycle → accept. 7. Tears everything down (Playwright close, dev server SIGTERM, live-server stop, tmp rm). +### `runtime.appDir` + +Optional, defaults to `.`. Set it when the served app is **not** the repo root, the shape live mode has to resolve on its own (a CLI package at the root with the site in `website/`, for example). With `appDir` set, the harness: + +- stages `files/` and runs `git init` at the tmp root, as always; +- writes `.impeccable/live/config.json` under `//`, and treats every fixture-relative path in `fixture.json` (`steer.sourceFile`, manual-edit `expectedSourceFile`, and so on) as relative to that app dir; +- runs `runtime.install` and `runtime.devCommand` with the app dir as cwd; +- boots through `live.mjs` **from the tmp root** instead of calling `live-server.mjs` and `live-inject.mjs` directly, so the run exercises root resolution (`skill/scripts/live/roots.mjs`) rather than assuming it. The parsed `live.mjs` payload is exposed as `session.liveBoot`, and `tests/live-e2e.test.mjs` asserts on `roots.appRoot`, `roots.contextRoot`, the persisted `roots.json`, and the repo-root pointer. + +The session object carries both paths: `session.tmp` is the repo root (use it for git and for artifact capture) and `session.appRoot` is the app. They are the same directory for every fixture without `appDir`. + +### Picking, cycling, and the render proof + +`pickSelector` names the element the run picks. The picker resolves whatever is +under the cursor, so a **container whose centre is covered by a child can never +be picked**: add `pickPosition` (`{x, y}` in px from the element's top-left) to +aim at a point the container owns, such as its own padding. + +`variantSequence` (default `[2]`) is the order the run cycles through; the last +entry is the variant it accepts. Every variant the run lands on gets a computed +`font-weight` assertion in fake-agent mode, because the fake agent renders each +variant at a distinct weight (`FAKE_VARIANT_FONT_WEIGHTS` in +`tests/live-e2e/agent.mjs`: 300 / 900 / 600). That turns "variant N is visible" +from a bar-label claim into a render fact, so a sequence like `[3, 1, 2]` proves +all three variants really render. + +`acceptedSourcePattern` overrides the default post-accept source check (an `

    `), and `assertSourceContains` lists strings that must +survive the whole wrap → accept → carbonize cycle. On Svelte component previews +that is how a fixture proves control flow was not flattened: list the `{#each}` +header and the per-item expressions. + +`stateProbe` asserts page state is not lost. `textSelector` / `expectedText` +check rendered state; `windowProperty` with `expectedWindowValue` and +`expectWindowUnchanged` check a counter the app bumps on mount, so a scaffold +that silently remounted the page fails. It runs after preActions, after the +variants land, and (outside component previews) after accept. + +### Extra scenarios + +Beyond the core cycle, a fixture opts into scenarios by declaring their config. +Each one is also gated by `IMPECCABLE_E2E_SCENARIOS`: + +| Scenario name | Enabled by | What it proves | +|---|---|---| +| `params` | `runtime.paramsScenario` | Dial a `range` and a `steps` knob in the real Tune popover, accept, and assert the chosen values are baked into source as literals with the unchosen branch dropped and no `data-p-*` / `var(--p-*)` left behind. | +| `mount-failure` | `runtime.componentFailureScenarios` | Corrupt the published `r/v` revision file, step onto it, and assert the persistent mount-error card appears, the session survives (bar + localStorage intact), and a `variant_mount_failed` event lands in the session journal. Then restore, Retry, and reach the variant again. | +| `republish` | `runtime.componentFailureScenarios` | Re-author every variant and reply `done` again; the browser must mount the new content, which is what the server's revision-dir bump exists to guarantee. | +| `storage-loss` | `runtime.componentFailureScenarios` (unless `storageLoss: false`) | Clear localStorage, reload, and assert the comparison comes back from the server's durable session record alone. | + +`componentFailureScenarios.variant` picks which variant to break or observe. +Set `storageLoss: false` for fixtures whose picked element only exists after +preActions: the reload discards that state, and re-creating it races the +preview mount. + +These scenarios assert on deterministic content, so they skip under +`IMPECCABLE_E2E_AGENT=llm`. + +One gotcha: `tests/framework-fixtures.test.mjs` stages the same fixture flat and writes the live config at the tmp root, so `config.files` has to resolve from both the repo root and the app root. A glob (`"**/index.html"`) satisfies both; a literal `"index.html"` only works from the app root and makes the static sweep report `file_not_found`. + Useful runtime E2E filters: - `IMPECCABLE_E2E_ONLY=[,]` scopes the run to selected fixture names. -- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, and exit probes. +- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, exit, params, and the component failure-injection probes. Names: `core`, `manual`, `annotations`, `exit`, `missed-done`, `params`, `mount-failure`, `republish`, `storage-loss`. - `IMPECCABLE_E2E_TEST_TIMEOUT_MS`, `IMPECCABLE_E2E_INSTALL_TIMEOUT_MS`, and `IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS` tighten CI smoke timeouts without changing fixture metadata. Optional `runtime.steer` fields: @@ -112,6 +194,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea | `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). | | `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. | | `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. | +| `vite8-sveltekit-stateful/` | Svelte 5 route with `$state`, an `{#if}` branch, and an `{#each}` list. Picks the list container, so the component-preview scaffold has to carry the loop across as one `collection` prop and hydrate its items from the live DOM. Also carries the params, failure-injection, and state-preservation probes. | | `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. | | `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). | | `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. | @@ -120,5 +203,6 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea | `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. | | `sveltekit-csp/` | SvelteKit `kit.csp.directives` in `svelte.config.js`. CSP shape `append-arrays`. | | `nuxt-csp/` | Nuxt `routeRules` with literal CSP header in `nuxt.config.ts`. CSP shape `append-string`. | +| `monorepo-nested-vite/` | Repo root is a CLI package with no dev config and no workspaces; the served Vite + React app lives in `website/`. Exercises `runtime.appDir` and live-mode root resolution. | -Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`. +Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`. A fixture with a `runtime` block also needs its name added to the live-e2e matrices in `.github/workflows/ci.yml`: the `live-e2e-full` group list, and one `live-e2e-smoke` group when it should run on every PR. Keep the groups roughly the same size, since they run in parallel and the job times out at 15 minutes. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md new file mode 100644 index 000000000..d3e9e42e4 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md @@ -0,0 +1,39 @@ +--- +name: Nested Website Fixture +description: A one-page site for a command-line tool, set in plain type on paper-white surfaces. +colors: + ink: "#142720" + paper: "#ffffff" + muted: "#555555" + hairline: "#dddddd" +typography: + display: + fontFamily: "system-ui, sans-serif" + fontWeight: 700 + lineHeight: 1.1 + body: + fontFamily: "system-ui, sans-serif" + fontWeight: 400 + lineHeight: 1.5 +rounded: + sm: "6px" + md: "8px" +--- + +# Design System: Nested Website Fixture + +## 1. Overview + +A single scrolling page on a paper-white ground. Hairline borders carry the structure; no fills, no shadows. + +## 2. Color + +Ink for text, paper for the page, muted grey for secondary copy, hairline grey for card borders. + +## 3. Typography + +System UI throughout. The hero title runs at 2rem and bold; body copy stays at the browser default size. + +## 4. Components + +Cards are bordered rectangles with 1rem of padding. Actions are inline text buttons with a hairline border and pill-free geometry. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md new file mode 100644 index 000000000..86564cc0b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md @@ -0,0 +1,24 @@ +# Product + + + +## Platform + +web + +## Users + +Maintainers of a small command-line tool who visit the project site to check what the tool does before installing it. + +## Product Purpose + +The repository ships a CLI. The marketing site in `website/` explains the CLI and is the only browser-facing surface here. + +## Capabilities and Constraints + +The repo root is a package with no dev server and no workspace declaration. Every runnable app lives one level down, in `website/`. + +## Product Principles + +- Say what the tool does before saying how it feels. +- One page, read top to bottom, no navigation chrome. diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/package.json b/tests/framework-fixtures/monorepo-nested-vite/files/package.json new file mode 100644 index 000000000..bd512177b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/package.json @@ -0,0 +1,4 @@ +{ + "name": "fake-cli-tool", + "private": true +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html new file mode 100644 index 000000000..c3fcf9c3a --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html @@ -0,0 +1,11 @@ + + + + + Nested Website Fixture + + +
    + + + diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json b/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json new file mode 100644 index 000000000..91e309b9b --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/package.json @@ -0,0 +1,19 @@ +{ + "name": "monorepo-nested-vite-website", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx new file mode 100644 index 000000000..779f5cd2c --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/App.jsx @@ -0,0 +1,32 @@ +const foundationCards = [ + { label: 'Typography', detail: 'Readable hierarchy' }, + { label: 'Color & Contrast', detail: 'Accessible palettes' }, + { label: 'Interaction', detail: 'Responsive states' }, +]; + +export default function App() { + return ( +
    +
    +

    Nested Website Fixture

    +

    The served app lives in website/, one level below the repo root.

    +
    +
    +
    One
    +
    Two
    +
    +
    + {foundationCards.map((card) => ( +
    + {card.label} +

    {card.detail}

    +
    + ))} +
    +
    + Learn more + Learn more +
    +
    + ); +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx new file mode 100644 index 000000000..f2baba283 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render( + + + , +); diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css new file mode 100644 index 000000000..10606269d --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/src/styles.css @@ -0,0 +1,13 @@ +body { margin: 0; font-family: system-ui, sans-serif; } +.page { padding: 2rem; } +.hero-copy { padding: 0.75rem; margin: -0.75rem -0.75rem 1rem; } +.hero-title { font-size: 2rem; } +.hero-hook { color: #555; } +.feature-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, 1fr); } +.feature-card { padding: 1rem; border: 1px solid #ddd; border-radius: 0.5rem; } +.foundation-grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 1rem; } +.foundation-card { padding: 0.75rem; border: 1px dashed #bbb; } +.foundation-card-label { font-weight: 700; } +.foundation-card-detail { margin: 0.25rem 0 0; color: #555; } +.action-row { display: flex; gap: 0.75rem; margin-top: 1rem; } +.action-row [role="button"] { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 0.375rem; } diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js b/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js new file mode 100644 index 000000000..dd5cfa6f0 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '127.0.0.1', + strictPort: false, + }, +}); diff --git a/tests/framework-fixtures/monorepo-nested-vite/fixture.json b/tests/framework-fixtures/monorepo-nested-vite/fixture.json new file mode 100644 index 000000000..424028816 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/fixture.json @@ -0,0 +1,42 @@ +{ + "name": "Repo root + nested Vite app in website/", + "config": { + "files": ["**/index.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": [ + "website/index.html", + "website/src/App.jsx", + "website/src/main.jsx", + "website/src/styles.css", + "website/vite.config.js" + ], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title in the nested app's source JSX", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "website/src/App.jsx" + } + ], + "runtime": { + "styling": "plain-css", + "appDir": "website", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "pickSelector": "h1.hero-title", + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + }, + "steer": { + "message": "steer-e2e mark hero", + "expectSelector": "h1.hero-title[data-impeccable-steer=\"e2e\"]", + "expectSourceContains": "data-impeccable-steer=\"e2e\"", + "sourceFile": "src/App.jsx" + } + } +} diff --git a/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt b/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/monorepo-nested-vite/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json diff --git a/tests/framework-fixtures/vite8-react-mapped-list/fixture.json b/tests/framework-fixtures/vite8-react-mapped-list/fixture.json index d9e1a1911..61b415db9 100644 --- a/tests/framework-fixtures/vite8-react-mapped-list/fixture.json +++ b/tests/framework-fixtures/vite8-react-mapped-list/fixture.json @@ -20,6 +20,7 @@ "devCommand": ["npx", "vite", "--host", "127.0.0.1"], "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyTimeoutMs": 120000, + "assertSourceContains": ["{item.title}"], "probe": { "expectLiveInit": true, "expectConsoleClean": true diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte index eb9e7286d..6688f43f2 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte @@ -7,11 +7,15 @@ window.__impeccableStatefulMounts = (window.__impeccableStatefulMounts || 0) + 1; }); + const CATALOG = [ + { name: 'Design snack', amount: '$12' }, + { name: 'Studio coffee', amount: '$8' }, + { name: 'Type license', amount: '$44' }, + ]; + function addExpense() { - expenses = [ - ...expenses, - { id: expenses.length + 1, name: 'Design snack', amount: '$12' }, - ]; + const next = CATALOG[expenses.length % CATALOG.length]; + expenses = [...expenses, { id: expenses.length + 1, ...next }]; } @@ -31,10 +35,14 @@

    Fügt die nächste gemeinsame Ausgabe hinzu, dann landet sie hier.

    {:else} -
    - {expenses[0].name} - {expenses[0].amount} -
    +
      + {#each expenses as expense, i} +
    • + {expense.name} + {expense.amount} +
    • + {/each} +
    {/if} @@ -96,18 +104,33 @@ color: #1b3329; } - .empty-card, - .expense-row { + .empty-card { border: 1px solid #16332b; border-radius: 18px; background: rgba(255, 255, 255, 0.62); padding: 26px; } + /* The padding is what the E2E picker aims at: a container whose centre is + covered by a child can never be hovered, so the list keeps a band of its + own around the rows. */ + .expense-list { + display: grid; + gap: 14px; + margin: 0; + padding: 24px; + list-style: none; + font-weight: 500; + } + .expense-row { display: flex; justify-content: space-between; align-items: center; + border: 1px solid #16332b; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + padding: 26px; } .detect-target { diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json index 0fee6a8f8..b83acc5e9 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json @@ -16,11 +16,57 @@ "expectedPreviewMode": "svelte-component" }, { - "name": "wraps stateful expense row through Svelte component preview", - "args": { "classes": "expense-row", "tag": "article" }, + "name": "wraps the each-block list through Svelte component preview", + "args": { "classes": "expense-list", "tag": "ul" }, "expectedFile": "node_modules/.impeccable-live/wraptest1/manifest.json", "expectedSourceFile": "src/routes/+page.svelte", "expectedPreviewMode": "svelte-component" } - ] + ], + "runtime": { + "styling": "plain-css", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "steer": false, + "pickSelector": "ul.expense-list", + "pickPosition": { "x": 10, "y": 10 }, + "variantSequence": [3, 1, 2], + "acceptedSourcePattern": "]*class=\"[^\"]*\\bexpense-list\\b", + "assertSourceContains": [ + "{#each expenses as expense, i}", + "{expense.name}", + "{expense.amount}" + ], + "preActions": [ + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='0']" }, + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='1']" }, + { "type": "click", "selector": "[data-testid='add-expense']" }, + { "type": "wait", "selector": "[data-testid='expense-row'][data-index='2']" } + ], + "stateProbe": { + "textSelector": "[data-testid='open-count']", + "expectedText": "3 offen", + "windowProperty": "__impeccableStatefulMounts", + "expectedWindowValue": 1, + "expectWindowUnchanged": true + }, + "paramsScenario": { + "variant": 2, + "rangeLabel": "Lead", + "rangeValue": 1.8, + "stepsLabel": "Density", + "stepsOptionLabel": "Snug", + "expectSourceContains": ["line-height: 1.8", "letter-spacing: 0.01em"], + "expectSourceMissing": ["letter-spacing: 0.14em"] + }, + "componentFailureScenarios": { "variant": 2, "storageLoss": false }, + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + } + } } diff --git a/tests/framework-fixtures/vite8-sveltekit/fixture.json b/tests/framework-fixtures/vite8-sveltekit/fixture.json index a75956e0a..221ce7197 100644 --- a/tests/framework-fixtures/vite8-sveltekit/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit/fixture.json @@ -22,6 +22,7 @@ "devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"], "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyTimeoutMs": 120000, + "componentFailureScenarios": { "variant": 2 }, "probe": { "expectLiveInit": true, "expectConsoleClean": true diff --git a/tests/live-accept-css.test.mjs b/tests/live-accept-css.test.mjs new file mode 100644 index 000000000..80beaffba --- /dev/null +++ b/tests/live-accept-css.test.mjs @@ -0,0 +1,199 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { compile } from 'svelte/compiler'; +import { + bakeParamValues, + collectAllSelectors, + normalizeSelector, + parseStylesheet, + pruneUnusedSelectors, + reconcileCss, + serializeNodes, + splitSelectorList, + stripParamSelector, + substituteParamVar, +} from '../skill/scripts/live/accept-css.mjs'; +import { verifyAcceptedSource } from '../skill/scripts/live/accept-verify.mjs'; + +describe('accept-time CSS reconciliation', () => { + it('parses rules, at-blocks, and comments with stable round-trip', () => { + const css = `/* note */\n.a { color: red; }\n@media (min-width: 600px) {\n .a { color: blue; }\n .b { margin: 0; }\n}\n@font-face { font-family: X; src: url("a{b}.woff"); }`; + const nodes = parseStylesheet(css); + assert.deepEqual(nodes.map((n) => n.type), ['comment', 'rule', 'at', 'at']); + assert.equal(nodes[2].children.length, 2); + const out = serializeNodes(nodes); + assert.match(out, /@media \(min-width: 600px\)/); + assert.match(out, /a\{b\}\.woff/); + }); + + it('replaces matching selectors instead of appending duplicates', () => { + const existing = `.pit-board { border-top: 1px solid #333; padding: 8px; }\n.pit-board .label { color: gray; }`; + const variant = `.pit-board { padding: 12px; clip-path: polygon(0 0); }\n.pit-board .arrow { width: 10px; }`; + const { css, replaced, appended } = reconcileCss(existing, variant); + assert.equal(replaced, 1); + assert.equal(appended, 1); + // The superseded divider border is gone; the new body wins. + assert.doesNotMatch(css, /border-top/); + assert.match(css, /clip-path/); + assert.match(css, /\.pit-board \.label/); + assert.match(css, /\.pit-board \.arrow/); + // Exactly one .pit-board rule remains. + assert.equal(css.split('.pit-board {').length - 1, 1); + }); + + it('merges inside matching media queries', () => { + const existing = `@media (max-width: 700px) { .row { gap: 4px; } }`; + const variant = `@media (max-width: 700px) { .row { gap: 8px; } .col { gap: 2px; } }`; + const { css } = reconcileCss(existing, variant); + assert.match(css, /gap: 8px/); + assert.doesNotMatch(css, /gap: 4px/); + assert.match(css, /\.col \{ gap: 2px; \}/); + assert.equal(css.split('@media').length - 1, 1); + }); + + it('normalizes selectors for matching', () => { + assert.equal(normalizeSelector('.a > .b, .c'), '.a>.b,.c'); + assert.deepEqual(splitSelectorList('.a[data-x="1,2"], .b:is(.c, .d)'), ['.a[data-x="1,2"]', '.b:is(.c, .d)']); + }); +}); + +describe('param baking', () => { + it('substitutes range vars with paren-aware fallbacks', () => { + const css = `.x { width: calc(var(--p-depth, calc(2px * 3)) + 1px); opacity: var(--p-depth); }`; + const out = substituteParamVar(css, 'depth', '10px'); + assert.equal(out, `.x { width: calc(10px + 1px); opacity: 10px; }`); + }); + + it('keeps only the chosen steps branch and strips the attribute selector', () => { + const css = [ + `:global([data-p-density="airy"]) .grid { gap: 24px; }`, + `:global([data-p-density="snug"]) .grid { gap: 8px; }`, + `.grid { display: grid; }`, + ].join('\n'); + const out = bakeParamValues(css, [ + { id: 'density', kind: 'steps', default: 'airy' }, + ], { density: 'snug' }); + assert.doesNotMatch(out, /24px/); + assert.doesNotMatch(out, /data-p-density/); + assert.match(out, /\.grid \{ gap: 8px; \}/); + assert.match(out, /\.grid \{ display: grid; \}/); + }); + + it('normalizes toggle booleans to 0/1 and resolves presence selectors', () => { + const css = `.t { opacity: var(--p-serif, 0); }\n[data-p-serif] .t { font-family: serif; }`; + const on = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: true }); + assert.match(on, /opacity: 1/); + assert.match(on, /\.t \{ font-family: serif; \}/); + const off = bakeParamValues(css, [{ id: 'serif', kind: 'toggle', default: false }], { serif: false }); + assert.match(off, /opacity: 0/); + assert.doesNotMatch(off, /font-family: serif/); + }); + + it('falls back to declared defaults when no value was sent', () => { + const css = `.x { gap: var(--p-gap, 4px); }`; + const out = bakeParamValues(css, [{ id: 'gap', kind: 'range', default: 12 }], {}); + assert.match(out, /gap: 12/); + }); + + it('bakes undeclared sent values as ranges instead of ignoring them', () => { + const css = `.x { gap: var(--p-mystery, 4px); }`; + const out = bakeParamValues(css, [], { mystery: '9px' }); + assert.match(out, /gap: 9px/); + }); + + it('drops rules whose bodies become empty and strips readiness sentinels', () => { + const css = `.x { --impeccable-variant-ready: 1; }\n.y { color: red; }`; + const out = bakeParamValues(css, [], {}); + assert.doesNotMatch(out, /impeccable-variant-ready/); + assert.doesNotMatch(out, /\.x/); + assert.match(out, /\.y/); + }); + + it('stripParamSelector cleans emptied :global wrappers', () => { + assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'x'), '.b'); + assert.equal(stripParamSelector(':global([data-p-a="x"]) .b', 'a', 'steps', 'y'), null); + assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', true), '.root .b'); + assert.equal(stripParamSelector('.root[data-p-flag] .b', 'flag', 'toggle', false), null); + }); +}); + +describe('compiler-driven pruning', () => { + it('removes selectors svelte reports as unused', () => { + const component = `
    hi
    \n`; + const { source, removed } = pruneUnusedSelectors(component, compile); + assert.equal(removed.includes('.gone'), true); + assert.equal(removed.includes('.alsogone'), true); + assert.doesNotMatch(source, /color: blue/); + assert.doesNotMatch(source, /\.alsogone/); + assert.match(source, /\.kept \{ color: red; \}/); + assert.match(source, /\.kept \.inner \{ font-weight: bold; \}/); + assert.match(source, /\.kept \{ margin: 0; \}/); + // The pruned result still compiles without unused-selector warnings. + const { warnings } = compile(source, { generate: false }); + assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []); + }); + + it('never throws on uncompilable input', () => { + const { source } = pruneUnusedSelectors('
    {broken', () => { throw new Error('nope'); }); + assert.equal(source, '
    {broken'); + }); +}); + +describe('postcondition scanner', () => { + it('flags every class of live-mode leftover with line numbers', () => { + const dirty = [ + '
    x
    ', + '', + '.x { width: var(--p-depth, 4px); }', + '
    y
    ', + '', + ].join('\n'); + const { clean, findings } = verifyAcceptedSource(dirty); + assert.equal(clean, false); + assert.equal(findings.length >= 5, true); + assert.deepEqual(findings.map((f) => f.line).slice(0, 5), [1, 2, 3, 4, 5]); + }); + + it('passes clean source', () => { + const { clean } = verifyAcceptedSource('
    ok
    \n'); + assert.equal(clean, true); + }); +}); + +describe('review regressions: parser boundaries', () => { + it('parses compact CSS with no whitespace between rules (B1)', () => { + const nodes = parseStylesheet('.a{x:1}.b{y:2}.c{z:3}'); + assert.deepEqual(nodes.map((n) => n.prelude), ['.a', '.b', '.c']); + }); + + it('accept-style reconcile survives a minified existing block (B1)', () => { + const { css } = reconcileCss('.pit-board{display:flex}.stage{padding:8px}.footer{color:gray}', '.pit-board { display: grid; }'); + assert.match(css, /\.stage \{ padding:8px \}/); + assert.match(css, /\.footer \{ color:gray \}/); + assert.match(css, /display: grid/); + assert.doesNotMatch(css, /display:flex/); + }); + + it('block-less at-statements do not swallow the following rule (M4)', () => { + const existing = '@import url("t.css");\n.a { color: red; border: 1px solid black; }\n.b { z-index: 1; }'; + const { css, replaced } = reconcileCss(existing, '.a { color: green; }'); + assert.equal(replaced, 1); + assert.doesNotMatch(css, /border: 1px solid black/); + assert.match(css, /color: green/); + assert.match(css, /@import url\("t\.css"\);/); + assert.match(css, /\.b \{ z-index: 1; \}/); + assert.equal(css.split('.a {').length - 1, 1); + }); + + it('keeps sibling declarations when stripping a one-line sentinel (m1)', () => { + const out = bakeParamValues('.x { --impeccable-variant-ready: 1; color: red; padding: 4px; }', [], {}); + assert.match(out, /color: red/); + assert.match(out, /padding: 4px/); + assert.doesNotMatch(out, /impeccable-variant-ready/); + }); + + it('collectAllSelectors sees rules at every nesting level', () => { + const selectors = collectAllSelectors('.a { x: 1; }\n@media (min-width: 10px) { .b { y: 2; } @supports (display: grid) { .c { z: 3; } } }'); + assert.deepEqual([...selectors].sort(), ['.a', '.b', '.c']); + }); +}); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 2106bc1a6..c46a9456f 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -1082,6 +1082,257 @@ describe('live-browser.js regression guards', () => { ); }); + it('acknowledges every successful component mount back to the server', () => { + // `arrivedVariants` counts what the agent published. Without this ack the + // server has no way to tell "the user is comparing three variants" from + // "three modules 404'd and the page is blank". + assert.match( + SOURCE, + /reportVariantMounted\(sessionId, variantNum, moduleUrl\);/, + 'a successful mount must post variant_mounted so the journal carries render truth', + ); + assert.match( + SOURCE, + /function reportVariantMounted\([\s\S]{0,400}?sendEvent\(\{\s*type: 'variant_mounted',/, + 'variant_mounted must go through the normal event POST helper', + ); + }); + + it('reports mount failures to the server instead of only logging them', () => { + assert.match( + SOURCE, + /console\.error\('\[impeccable\] Failed to mount component variant[\s\S]{0,200}?reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);/, + 'event=live_browser.silent_mount_failure actor=browser operation=mount_component_variant risk=agent_never_learns_render_failed expected=variant_mount_failed posted actual=console.error only', + ); + assert.match( + SOURCE, + /function reportVariantMountFailed\([\s\S]{0,900}?sendEvent\(\{ type: 'variant_mount_failed', id: sessionId, variant, url, error: message \}\);/, + 'variant_mount_failed must carry the failed module URL and the error text', + ); + // Both the first mount and a variant switch route through this one catch, + // so the switch path can no longer revert with zero feedback. + assert.match( + SOURCE, + /reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);[\s\S]{0,300}?showMountErrorCard\(sessionId, \{/, + 'a failed mount must raise the persistent error card from the shared catch', + ); + }); + + it('keeps the session alive when a component variant fails to mount', () => { + const abort = SOURCE.match( + /function abortSvelteComponentInjection\(sessionId, details\) \{[\s\S]*?\n \}\n/, + ); + assert.ok(abort, 'abortSvelteComponentInjection must still exist'); + const body = abort[0]; + assert.doesNotMatch( + body, + /clearSession\(\)/, + 'event=live_browser.mount_failure_wipe actor=browser operation=abort_svelte_injection risk=durable_session_orphaned expected=localStorage preserved actual=clearSession() called', + ); + assert.doesNotMatch( + body, + /currentSessionId = null/, + 'the session id is the only handle Retry and a republish have; abort must not drop it', + ); + assert.doesNotMatch( + body, + /setLiveState\('PICKING'\)/, + 'a mount failure is not the end of the session, so the bar must not fall back to PICKING', + ); + assert.doesNotMatch( + body, + /showToast\(/, + 'the 5s toast was replaced by a persistent card; a toast that vanishes reads as no feedback at all', + ); + assert.match(body, /showMountErrorCard\(/, 'abort must raise the persistent error card'); + assert.match(body, /saveSession\(\)/, 'abort must keep the localStorage cache in step with the server'); + }); + + it('gives the manifest fetch failure and the session mismatch the same error card', () => { + assert.doesNotMatch( + SOURCE, + /if \(manifest\.id !== sessionId\) return;/, + 'event=live_browser.silent_manifest_mismatch actor=browser operation=inject_from_manifest risk=bar_stuck_in_generating expected=error card actual=bare return', + ); + assert.match( + SOURCE, + /if \(manifest\.id !== sessionId\) \{[\s\S]{0,700}?showMountErrorCard\(sessionId, \{/, + 'a manifest belonging to another session must surface, not disappear', + ); + assert.match( + SOURCE, + /Failed to mount component-preview variants:[\s\S]{0,400}?reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, manifestPath, err\);/, + 'a manifest that cannot be read is a render failure the agent must hear about', + ); + assert.doesNotMatch( + SOURCE, + /reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, url,/, + 'the /source fetch URL carries the live token and must never be journaled; report the manifest path', + ); + }); + + it('offers a retry that re-runs injection for the same session', () => { + assert.match( + SOURCE, + /function retryMountErrorCard\(\) \{[\s\S]{0,900}?injectSvelteComponentsFromManifest\(manifestPath, sessionId\);/, + 'the Retry button must re-enter the normal injection path rather than restarting the session', + ); + assert.match( + SOURCE, + /function truncateMiddle\(value, max\)/, + 'the card shows the failed module URL truncated in the middle so both ends stay readable', + ); + }); + + it('rehydrates from the server when localStorage has no session', () => { + assert.doesNotMatch( + SOURCE, + /function restoreSessionWithoutWrapper\(reason, activeSessions\) \{\s*const saved = loadSession\(\);/, + 'event=live_browser.storage_gated_restore actor=browser operation=sse_connected risk=durable_session_unreachable expected=server summary can seed a restore actual=localStorage is a gate', + ); + assert.match( + SOURCE, + /const adopted = cached\?\.id \? null : findAdoptableServerSession\(activeSessions\);/, + 'a page with no cached session must be able to adopt the durable one the server reports', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?&& session\.pageUrl\s*&& pageMatchesCurrent\(session\.pageUrl\)/, + 'adoption must require an explicit pageUrl match so it cannot hijack an unrelated route', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?!isTerminalSessionSummary\(session\)/, + 'accepted, discarded, and completed sessions must never be adopted', + ); + }); + + it('carries no agent phase the server cannot emit', () => { + // Every one of these lived in PHASE_RANK and, for most, in a status string + // the user could never see: recordAgentPhase() in live-server.mjs has + // never emitted them. The validator now rejects them outright, so a + // leftover branch here is a branch that cannot run. + for (const retired of [ + 'first_variant_generating', + 'first_variant_validating', + 'remaining_variants_generating', + 'remaining_variants_validating', + 'variant_parameters_generating', + 'variant_parameters_validating', + 'parameters_ready', + ]) { + assert.doesNotMatch( + SOURCE, + new RegExp(retired), + 'event=live_browser.dead_agent_phase actor=browser operation=phase_rank risk=ui_branches_on_phase_nothing_sends phase=' + retired, + ); + } + // The phases the server does emit must all still rank. + for (const live of [ + 'picked_up', 'scaffolding', 'scaffold_fallback', 'source_ready', + 'generation_ready', 'first_reviewable', 'second_reviewable', 'all_variants_ready', + ]) { + assert.match(SOURCE, new RegExp('\\n\\s+' + live + ': \\d+,'), live + ' must keep a rank'); + } + }); + + it('renders the cycling counter through one function with one denominator', () => { + // buildCyclingRow showed visibleVariant/expectedVariants while + // syncCyclingControls wrote shown/arrivedVariants, so the same unchanged + // state rendered as "2/3" or "2/2" depending on which path ran last. + assert.doesNotMatch( + SOURCE, + /visibleVariant \+ '\/' \+ expectedVariants/, + 'event=live_browser.counter_drift actor=browser operation=cycling_counter risk=two_denominators_for_one_counter', + ); + assert.doesNotMatch(SOURCE, /shown \+ '\/' \+ arrivedVariants/); + assert.match( + SOURCE, + /function cyclingCounterText\(\) \{[\s\S]{0,300}?arrivedVariants > 0 \? arrivedVariants : expectedVariants/, + 'the denominator policy is arrived-when-known, expected otherwise, in one place', + ); + const counterAssignments = SOURCE.match(/-variant-counter'\)?;?[\s\S]{0,120}?textContent = ([^;]+);/g) || []; + for (const assignment of counterAssignments) { + assert.match(assignment, /cyclingCounterText\(\)/, 'every counter write goes through cyclingCounterText'); + } + }); + + it('gives the steer bar a visible Send control alongside Enter', () => { + assert.match( + SOURCE, + /pageChatSendBtn = el\('button'/, + 'event=live_browser.steer_send_affordance actor=user operation=steer risk=enter_only_submit_reads_as_dead_input', + ); + assert.match(SOURCE, /pageChatSendBtn\.id = PREFIX \+ '-page-chat-send'/); + assert.match( + SOURCE, + /function syncPageChatSendButton\(\)[\s\S]{0,900}?pageChatSendBtn\.disabled = !visible \|\| !hasText/, + 'Send is disabled with an empty input', + ); + assert.match( + SOURCE, + /function syncPageChatSendButton\(\)[\s\S]{0,900}?const visible = !steerLocked/, + 'Send disappears while a steer is in flight', + ); + // Enter must still submit. + assert.match( + SOURCE, + /if \(e\.key === 'Enter'\) \{\s*e\.preventDefault\(\);\s*submitSteerMessage\(\);/, + 'keyboard submit stays', + ); + assert.match( + SOURCE, + /PREFIX \+ '-page-chat-send'\] \}/, + 'the Send control must be registered as live UI chrome so it is excluded from capture', + ); + }); + + it('says a queued steer is queued instead of pulsing at the user', () => { + assert.match( + SOURCE, + /function steerQueuedBehindGeneration\(\) \{[\s\S]{0,220}?steerLocked && !agentPollingConnected && agentHasWorkInFlight\(\)/, + 'event=live_browser.steer_queue_feedback actor=user operation=steer_during_generation risk=queued_request_reads_as_lost', + ); + assert.match(SOURCE, /Queued behind current generation/); + assert.match( + SOURCE, + /function syncSteerQueueHint\(\)[\s\S]{0,400}?pageChatDotsEl\.style\.display = 'none'/, + 'the queue hint replaces the bare dots rather than sitting beside them', + ); + assert.match(SOURCE, /function syncAgentPollingUi\(connected\) \{[\s\S]{0,140}?syncSteerQueueHint\(\)/); + }); + + it('names the actual cause when a steer times out', () => { + assert.doesNotMatch( + SOURCE, + /Check that live-poll is running and replies with steer_done/, + 'event=live_browser.steer_timeout_copy actor=user operation=steer_timeout risk=blames_live_poll_for_a_busy_agent', + ); + assert.match( + SOURCE, + /function steerTimeoutMessage\(\)[\s\S]{0,900}?steerQueuedBehindGeneration\(\)[\s\S]{0,400}?!agentPollingConnected/, + 'the timeout message branches on agent-busy vs nobody-polling', + ); + }); + + it('distinguishes an empty DESIGN.md from a missing one in the design panel', () => { + assert.match( + SOURCE, + /function designEmptyMessage\(\)[\s\S]{0,700}?designState\.hasMd && !designState\.hasSidecar[\s\S]{0,300}?DESIGN\.md found, no structured tokens to display/, + 'event=live_browser.design_empty_state actor=user operation=open_design_panel risk=present_design_md_reported_as_missing', + ); + assert.match( + SOURCE, + /const beforeCount = body\.childElementCount;/, + 'the empty check must ignore the stale hint and CTA the caller already appended', + ); + assert.doesNotMatch( + SOURCE, + /msgDiv\('empty', 'No design system data available\.'\)/, + 'the bare message may only survive as the genuinely-absent branch of designEmptyMessage', + ); + }); + it('editing focus timeout does not read a stale inline edit row', () => { assert.doesNotMatch( SOURCE, @@ -1094,4 +1345,21 @@ describe('live-browser.js regression guards', () => { 'edit-mode delayed focus should capture the element before scheduling and no-op if editing ended before the timeout fires', ); }); + it('adopts only variant comparisons from the server, never steer or manual sessions', () => { + // A completed-steer session is non-terminal and carries a sourceFile; + // adopting it as a comparison hunts for a variant wrapper that never + // existed and wedges the bar before the user's next pick (found by the + // vite8-react-base-path e2e after server-first rehydration landed). + assert.match( + SOURCE, + /function findAdoptableServerSession\([\s\S]{0,900}?Number\(session\.expectedVariants\) > 0/, + 'adoption must require a generation-shaped session (expectedVariants > 0)', + ); + assert.match( + SOURCE, + /function findAdoptableServerSession\([\s\S]{0,900}?\^\(steer\|manual_edit\)/, + 'adoption must exclude steer and manual-edit phases', + ); + }); + }); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index a6be62570..a232a4ffe 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -22,11 +22,15 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createFakeAgent } from './live-e2e/agent.mjs'; +import { + createFakeAgent, + republishSvelteComponentVariants, + FAKE_VARIANT_FONT_WEIGHTS, +} from './live-e2e/agent.mjs'; import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs'; import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs'; import { @@ -34,11 +38,14 @@ import { assertApplyDockLoading, assertAnnotationUploadEvent, assertSourceApplied, + chooseTuneStep, clickExitLiveMode, + cycleToVariant, clickAccept, clickApplyEdits, clickEditCopy, clickDiscard, + clickMountRetry, clickSaveEdit, clickGo, clickNext, @@ -47,11 +54,18 @@ import { drawAnnotationPinAndStroke, getVisibleVariant, installLiveQueryHelpers, + isMountErrorCardVisible, + openTunePanel, pickElement, + readComputedFontWeight, runLiveChromeBottomBarSmoke, + setTuneRange, waitForApplyDockHidden, waitForBarHidden, + waitForComputedFontWeight, waitForCycling, + waitForMountErrorCard, + waitForMountErrorCardGone, runInsertFlow, waitForHandshake, } from './live-e2e/ui.mjs'; @@ -130,6 +144,33 @@ function shouldRunScenario(name) { return scenarioNames.size === 0 || scenarioNames.has('all') || scenarioNames.has(name); } +// Anything served out of the live preview / runtime tree. A 404 here is never +// benign: it means the variant preview module or its runtime never loaded, and +// the flow silently fell back to the untouched original. +const LIVE_TREE_URL_RE = /\.impeccable-live|impeccable\/live\/preview/i; + +// The two framework dev-mode notices every React fixture emits. +const FRAMEWORK_NOISE_RE = /Download the React DevTools|StrictMode/i; + +// Chromium's resource-failure console line. Only the favicon flavour is +// allowlisted; a favicon is not part of any fixture and its absence proves +// nothing about live mode. +const RESOURCE_404_RE = /Failed to load resource: the server responded with a status of 404/i; +const FAVICON_URL_RE = /favicon(\.[a-z0-9]+)?(\?|$)|\/favicon/i; + +function isLiveTreeUrl(url) { + return LIVE_TREE_URL_RE.test(String(url || '')); +} + +function isBenignConsoleError(entry) { + const text = String(entry || ''); + // Live-tree failures are never allowlisted, whatever else they match. + if (LIVE_TREE_URL_RE.test(text)) return false; + if (FRAMEWORK_NOISE_RE.test(text)) return true; + if (RESOURCE_404_RE.test(text)) return FAVICON_URL_RE.test(text); + return false; +} + before(async () => { if (fixtures.length === 0) return; try { @@ -216,7 +257,11 @@ for (const { name, fixture } of fixtures) { log: (m) => t.diagnostic(m), }); - const { page, tmp, consoleErrors, teardown } = session; + // `tmp` is the staged repo root; `appRoot` is the directory the dev + // server serves. They differ only for fixtures declaring + // `runtime.appDir`. Every fixture-relative source path resolves against + // appRoot — the repo root may not contain a single source file. + const { page, tmp, appRoot, consoleErrors, teardown } = session; const expectedCount = 3; const isInsert = fixture.runtime.mode === 'insert'; const insertCfg = fixture.runtime.insert || {}; @@ -228,15 +273,25 @@ for (const { name, fixture } of fixtures) { ? insertDomSelector : pickSelector; const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7'; - const variantContentSelector = isInsert - ? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy') + // Component previews mount every variant into the same node, so one + // selector serves all three; the HTML/JSX paths keep a div per variant. + const variantContentSelectorFor = (variantNum) => (isInsert + ? (usesSvelteComponentPreview ? '.inserted-copy' : `[data-impeccable-variant="${variantNum}"] .inserted-copy`) : usesSvelteComponentPreview ? pickSelector - : '[data-impeccable-variant="2"] > :first-child'; + : `[data-impeccable-variant="${variantNum}"] > :first-child`); let stateProbeBaseline = null; let sourceFile = null; try { + // 0. Root resolution — only for fixtures whose app is not the repo + // root. live.mjs booted from the repo root and had to find the app + // on its own; everything after this depends on it having done so. + if (session.appDir) { + t.diagnostic(`Asserting resolved roots for nested app ${session.appDir}/`); + assertNestedAppRoots(session); + } + // 1. Handshake t.diagnostic('Waiting for live handshake'); await waitForHandshake(page); @@ -255,7 +310,7 @@ for (const { name, fixture } of fixtures) { const steerTimeouts = agentMode === 'llm' ? { unlockTimeoutMs: 90_000, selectorTimeoutMs: 45_000, runPreActions } : { runPreActions }; - await runSteerSmoke(page, tmp, fixture, (m) => t.diagnostic(m), steerTimeouts); + await runSteerSmoke(page, appRoot, fixture, (m) => t.diagnostic(m), steerTimeouts); } // 2. preActions — fixtures with hidden/conditional content (modals, @@ -278,7 +333,7 @@ for (const { name, fixture } of fixtures) { }); } else { t.diagnostic(`Picking ${pickSelector}`); - await pickElement(page, pickSelector); + await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition }); if (process.env.IMPECCABLE_E2E_DEBUG) { const barText = await page.evaluate(() => { @@ -317,14 +372,20 @@ for (const { name, fixture } of fixtures) { } // 5. Source-side check: wrapper + style + variants are present - sourceFile = await locateSessionFile(tmp); + sourceFile = await locateSessionFile(appRoot); + if (session.appDir) { + assert.ok( + relative(tmp, sourceFile).startsWith(session.appDir + '/'), + `session source must live under ${session.appDir}/, got ${relative(tmp, sourceFile)}`, + ); + } const after = readFileSync(sourceFile, 'utf-8'); const svelteComponentSession = svelteComponentTargetFor(sourceFile); if (svelteComponentSession) { const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte'; - const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`); + const variantFile = join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`); const variantBody = readFileSync(variantFile, 'utf-8'); - const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8'); + const routeBody = readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8'); assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted'); if (isInsert) { assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode'); @@ -351,14 +412,14 @@ for (const { name, fixture } of fixtures) { } if (insertCfg.assertAnchorContains) { const anchorSource = svelteComponentSession - ? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8') + ? readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8') : after; assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched'); } } if (svelteComponentSession) { const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte'; - assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), / expectedCount + 6) { - throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`); - } - if (visible == null || visible < targetVariant) await clickNext(page); - else await clickPrev(page); - visible = await readVisibleVariantForCycle(page); - } - assert.equal(visible, targetVariant, `variant ${targetVariant} visible`); - if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) { - await page.waitForFunction((sel) => { - const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); - const el = query(sel) || document.querySelector(sel); - return el && getComputedStyle(el).fontWeight === '900'; - }, variantContentSelector, { timeout: 5_000 }).catch(() => {}); - const variantWeight = await evaluatePageWithTimeout( + // Render proof, not bar-label proof: the fake agent gives every variant + // a distinct font-weight, so a computed read says which variant the + // user is actually looking at. Checked for each variant the run reaches. + const styleCheckedVariants = new Set(); + const assertVisibleVariantStyle = async (variantNum) => { + if (agentMode !== 'fake') return; + const expected = FAKE_VARIANT_FONT_WEIGHTS[variantNum]; + if (!expected || styleCheckedVariants.has(variantNum)) return; + styleCheckedVariants.add(variantNum); + const selector = variantContentSelectorFor(variantNum); + await waitForComputedFontWeight(page, selector, expected, { timeout: 5_000 }).catch(() => {}); + const variantWeight = await readComputedFontWeight(page, selector).catch(() => null); + if (variantWeight !== expected) { + const styleSnapshot = await evaluatePageWithTimeout( page, (sel) => { const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); const el = query(sel) || document.querySelector(sel); - return el ? getComputedStyle(el).fontWeight : null; - }, - variantContentSelector, - 5_000, - 'variant font-weight read', - ); - if (variantWeight !== '900') { - const styleSnapshot = await evaluatePageWithTimeout( - page, - (sel) => { - const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); - const el = query(sel) || document.querySelector(sel); - const styleEl = document.querySelector('style[data-impeccable-css]'); - const rules = []; - for (const sheet of [...document.styleSheets]) { - if (sheet.ownerNode !== styleEl) continue; - try { - rules.push(...[...sheet.cssRules].map((rule) => rule.cssText)); - } catch (err) { - rules.push(`cssRules error: ${err.message}`); - } + const styleEl = document.querySelector('style[data-impeccable-css]'); + const rules = []; + for (const sheet of [...document.styleSheets]) { + if (sheet.ownerNode !== styleEl) continue; + try { + rules.push(...[...sheet.cssRules].map((rule) => rule.cssText)); + } catch (err) { + rules.push(`cssRules error: ${err.message}`); } - return { - selector: sel, - element: el?.outerHTML || null, - parent: el?.parentElement?.outerHTML?.slice(0, 800) || null, - computedWeight: el ? getComputedStyle(el).fontWeight : null, - styleText: styleEl?.textContent || null, - rules, - }; - }, - variantContentSelector, - 5_000, - 'variant style snapshot', - ).catch((err) => ({ error: err.message })); - t.diagnostic('--- variant style snapshot ---'); - t.diagnostic(JSON.stringify(styleSnapshot, null, 2)); - } - assert.equal( - variantWeight, - '900', - 'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape', - ); - checkedVariantTwoStyle = true; + } + return { + selector: sel, + element: el?.outerHTML || null, + parent: el?.parentElement?.outerHTML?.slice(0, 800) || null, + computedWeight: el ? getComputedStyle(el).fontWeight : null, + styleText: styleEl?.textContent || null, + rules, + }; + }, + selector, + 5_000, + 'variant style snapshot', + ).catch((err) => ({ error: err.message })); + t.diagnostic(`--- variant ${variantNum} style snapshot ---`); + t.diagnostic(JSON.stringify(styleSnapshot, null, 2)); } + assert.equal( + variantWeight, + expected, + `event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant variant=${variantNum} risk=unstyled_live_preview expected=font-weight ${expected} actual=${variantWeight} suggestion=inspect live CSS style mode and selector shape`, + ); + }; + await assertVisibleVariantStyle(visible); + for (const targetVariant of cycleSequence) { + t.diagnostic(`Cycling to variant ${targetVariant}`); + visible = await cycleToVariant(page, targetVariant, expectedCount, { + settleTimeout: agentMode === 'llm' ? 60_000 : 15_000, + }); + assert.equal(visible, targetVariant, `variant ${targetVariant} visible`); + await assertVisibleVariantStyle(targetVariant); } if (reloadVariants && usesSvelteComponentPreview) { @@ -554,11 +604,11 @@ for (const { name, fixture } of fixtures) { const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget }); if (svelteComponentTarget) { assert.equal(existsSync(svelteComponentTarget.manifestPath), false, 'Svelte temp preview session removed after accept'); - const snapshotPath = join(tmp, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`); + const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`); const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')); assert.equal(snapshot.phase, 'completed'); assert.equal(snapshot.sourceFile, svelteComponentTarget.manifest.sourceFile); - assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live/); + assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live|\.impeccable\/live\/previews/); } assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed'); assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed'); @@ -627,11 +677,22 @@ for (const { name, fixture } of fixtures) { await page.waitForSelector(expectSelector, { timeout: 10_000 }); } + // 9c. Network hygiene — nothing under the live preview/runtime tree + // may 404 or fail. A missing `.impeccable-live` module means the + // preview never mounted, which the DOM assertions above can miss + // when the original element still renders. + const liveTreeFailures = (session.failedRequests || []).filter((f) => isLiveTreeUrl(f.url)); + assert.equal( + liveTreeFailures.length, + 0, + `live preview/runtime requests must all succeed, got:\n${ + liveTreeFailures.map((f) => `${f.reason} ${f.url}`).join('\n') + }`, + ); + // 10. Console hygiene — no errors during the whole flow. if (fixture.runtime.probe?.expectConsoleClean) { - const realErrors = consoleErrors.filter((e) => - !/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(e), - ); + const realErrors = consoleErrors.filter((e) => !isBenignConsoleError(e)); if (realErrors.length > 0) { t.diagnostic('--- console errors ---'); for (const e of realErrors) t.diagnostic(e); @@ -667,6 +728,201 @@ for (const { name, fixture } of fixtures) { + // ----------------------------------------------------------------- + // Params end-to-end: knob → accept → baked literal in source. + // + // The Svelte accept pipeline bakes params mechanically, so the unit + // tests already cover the transform. What they cannot cover is that the + // value the user actually dialled in the Tune popover is the value that + // reaches it. This drives the real controls and reads the real source. + // ----------------------------------------------------------------- + if (shouldRunScenario('params') && fixture.runtime.paramsScenario) { + it('bakes tuned param values into the accepted source', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const scenario = fixture.runtime.paramsScenario; + const targetVariant = scenario.variant || 2; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session, sourceFile, svelteComponentTarget } = run; + const { page } = session; + try { + await cycleToVariant(page, targetVariant, 3); + + t.diagnostic('Opening the Tune popover'); + await openTunePanel(page); + const applied = await setTuneRange(page, scenario.rangeLabel, scenario.rangeValue); + assert.equal(applied, scenario.rangeValue, 'range knob took the requested value'); + await chooseTuneStep(page, scenario.stepsLabel, scenario.stepsOptionLabel); + + t.diagnostic(`Accepting variant ${targetVariant} with tuned params`); + await clickAccept(page, { expectedVariant: targetVariant }); + await waitForBarHidden(page); + const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget }); + + for (const needle of scenario.expectSourceContains || []) { + assert.ok( + final.includes(needle), + `accepted source should bake ${JSON.stringify(needle)}; got:\n${final}`, + ); + } + for (const needle of scenario.expectSourceMissing || []) { + assert.equal( + final.includes(needle), + false, + `accepted source should drop the unchosen branch ${JSON.stringify(needle)}; got:\n${final}`, + ); + } + // The generic contract, independent of this fixture's values: every + // preview-only param hook is gone once the source is accepted. + assert.doesNotMatch(final, /data-p-[A-Za-z0-9_-]+/, 'no data-p-* param attributes survive accept'); + assert.doesNotMatch(final, /var\(--p-/, 'no unbaked var(--p-*) survives accept'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + // ----------------------------------------------------------------- + // Failure injection for component previews. + // ----------------------------------------------------------------- + if (fixture.runtime.componentFailureScenarios) { + const failure = fixture.runtime.componentFailureScenarios; + const brokenVariant = failure.variant || 2; + + if (shouldRunScenario('mount-failure')) { + it('surfaces a broken variant without losing the session, and recovers on Retry', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + // Auto-repair off: this scenario is about what the USER sees and can + // do when a publish is unrenderable. An agent that silently + // republishes would hide exactly the surface under test. + const run = await bootComponentScenario({ + t, + name, + fixture, + expectedCount: 3, + agent: createFakeAgent({ autoRepairMountFailures: false }), + }); + const { session, svelteComponentTarget } = run; + const { page, appRoot } = session; + try { + const sessionId = svelteComponentTarget.manifest.id; + const variantPath = revisionVariantPath(appRoot, run.manifestPath, brokenVariant); + const saved = readFileSync(variantPath, 'utf-8'); + // Corrupt rather than delete: a published variant that does not + // compile is the failure an agent actually produces, and it keeps + // the module resolvable so the recovery is about the content. + t.diagnostic(`Corrupting published revision file ${relative(appRoot, variantPath)}`); + writeFileSync(variantPath, '
    { not valid svelte\n', 'utf-8'); + + // One step forward onto the variant whose module is gone. The step + // deliberately does not settle, so drive the click directly. + t.diagnostic(`Stepping onto the broken variant ${brokenVariant}`); + await clickNext(page); + + const cardText = await waitForMountErrorCard(page, { variant: brokenVariant }); + assert.match(cardText, /Retry/, 'the mount-error card offers a Retry'); + + // The session must survive the failure: the old behaviour wiped + // local state and orphaned a session the server still tracked. + const barVisible = await page.evaluate(() => { + const bar = window.__impeccableLiveQuery('#impeccable-live-bar'); + return Boolean(bar) && bar.style.display !== 'none'; + }); + assert.equal(barVisible, true, 'the cycling bar survives a mount failure'); + const stored = await readLiveSessionStorage(page); + assert.equal(stored?.id, sessionId, 'the local session record survives a mount failure'); + + const events = await waitForJournalEvent(appRoot, sessionId, 'variant_mount_failed', 15_000); + const failedEvent = events.at(-1); + assert.equal(failedEvent.variant, brokenVariant, 'the journal names the variant that failed'); + assert.ok(failedEvent.error, 'the journal carries the mount error text'); + + // A failed step reverts to the variant that still renders, so the + // comparison is usable rather than blank; Retry re-imports the + // session, and the repaired variant is reachable again. + t.diagnostic('Restoring the revision file and clicking Retry'); + writeFileSync(variantPath, saved, 'utf-8'); + await clickMountRetry(page); + await waitForMountErrorCardGone(page); + await cycleToVariant(page, brokenVariant, 3, { settleTimeout: 20_000 }); + await waitForComputedFontWeight( + page, + fixture.runtime.pickSelector || 'h1.hero-title', + FAKE_VARIANT_FONT_WEIGHTS[brokenVariant], + { timeout: 20_000 }, + ); + assert.equal(await getVisibleVariant(page), brokenVariant, 'the repaired variant is the visible one'); + assert.equal(await isMountErrorCardVisible(page), false, 'the repaired mount raises no new error'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + if (shouldRunScenario('republish')) { + it('mounts republished variant content instead of a cached compile', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session } = run; + const { page, appRoot } = session; + const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; + try { + await cycleToVariant(page, brokenVariant, 3); + await waitForComputedFontWeight(page, pickSelector, FAKE_VARIANT_FONT_WEIGHTS[brokenVariant]); + const beforeRevision = readManifest(run.manifestPath).revision; + + t.diagnostic('Republishing every variant with new styling'); + await republishSvelteComponentVariants({ + tmp: appRoot, + manifestFile: relative(appRoot, run.manifestPath), + live: session.live, + css: (variantNum, shape) => `${shape.rootSelector} { font-weight: ${100 * variantNum}; }`, + }); + + // A stale transform cache would keep serving the previous compile; + // the server-stamped revision dir is what makes the import path new. + await waitForComputedFontWeight(page, pickSelector, String(100 * brokenVariant), { timeout: 20_000 }); + const afterRevision = readManifest(run.manifestPath).revision; + assert.ok( + afterRevision > beforeRevision, + `republish must bump the preview revision (${beforeRevision} → ${afterRevision})`, + ); + assert.equal(await isMountErrorCardVisible(page), false, 'a clean republish shows no mount error'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + + if (shouldRunScenario('storage-loss') && failure.storageLoss !== false) { + it('rehydrates the comparison from the server after localStorage is cleared', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 }); + const { session } = run; + const { page } = session; + try { + await cycleToVariant(page, brokenVariant, 3); + assert.ok(await readLiveSessionStorage(page), 'a local session record exists before the wipe'); + + t.diagnostic('Clearing localStorage and reloading'); + await page.evaluate(() => localStorage.clear()); + assert.equal(await readLiveSessionStorage(page), null, 'the local session record really was wiped'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForHandshake(page); + if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions); + + // Nothing local is left: reaching CYCLING again can only come from + // the server's durable session record. + await waitForCycling(page, 3, { timeout: 30_000 }); + const restored = await readLiveSessionStorage(page); + assert.equal(restored?.id, run.svelteComponentTarget.manifest.id, 'the adopted session keeps its id'); + assert.equal(restored?.expected, 3, 'the adopted session knows the variant count'); + } finally { + await teardownAndResetBrowser(session.teardown); + } + }); + } + } + if (shouldRunScenario('missed-done') && fixture.runtime.missedDoneReloadScenario) { it('recovers when the preflight reload makes the browser miss the done broadcast', liveE2eTestOptions, async (t) => { if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { @@ -692,7 +948,7 @@ for (const { name, fixture } of fixtures) { atomicDelayMs: 2500, log: (m) => t.diagnostic(m), }); - const { page, tmp, teardown } = session; + const { page, appRoot, teardown } = session; const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; try { await waitForHandshake(page); @@ -731,7 +987,7 @@ for (const { name, fixture } of fixtures) { // this stale copy, forcing the completion-driven fallback through // its retry path — a single no-retry read here strands the tab in // GENERATING forever. - const scaffoldSourceFile = join(tmp, fixture.runtime.missedDoneReloadScenario.sourceFile); + const scaffoldSourceFile = join(appRoot, fixture.runtime.missedDoneReloadScenario.sourceFile); const scaffoldOnlySource = readFileSync(scaffoldSourceFile, 'utf-8'); let staleSourceServed = false; await page.context().route('**/source?token=*', (route) => { @@ -867,7 +1123,7 @@ for (const { name, fixture } of fixtures) { assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists'); assert.match(generateEvent.screenshotPath, /\.impeccable\/live\/annotations\//, 'annotation screenshot is stored under live annotations'); - const sourceFile = await locateSessionFile(session.tmp); + const sourceFile = await locateSessionFile(session.appRoot); const svelteComponentTarget = svelteComponentTargetFor(sourceFile); await clickNext(page); assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate'); @@ -909,6 +1165,163 @@ for (const { name, fixture } of fixtures) { // Helpers // --------------------------------------------------------------------------- +/** + * The params and failure-injection scenarios assert on deterministic variant + * content (exact CSS values, exact font weights). An LLM agent legitimately + * produces neither, so they only run against the fake agent. + */ +function canRunFakeAgentScenario(t) { + if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { + t.skip('manual scenario filter is active'); + return false; + } + if ((process.env.IMPECCABLE_E2E_AGENT || 'fake') !== 'fake') { + t.skip('scenario asserts on deterministic fake-agent output'); + return false; + } + return true; +} + +/** + * Boot a fixture straight to CYCLING on a component-preview session and hand + * back the handles the scenarios need: the manifest, the route source, and the + * session itself. Callers own teardown. + */ +async function bootComponentScenario({ t, name, fixture, expectedCount = 3, agent }) { + const session = await bootFixtureSession({ + name, + fixture, + browser, + agent: agent || createFakeAgent(), + wrapTarget: wrapTargetFromPickedElement, + log: (m) => t.diagnostic(m), + }); + try { + const { page, appRoot } = session; + await waitForHandshake(page); + if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions); + await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title', { + position: fixture.runtime.pickPosition, + }); + await clickGo(page); + await waitForCyclingRobust(page, expectedCount, { + agentMode: 'fake', + preActions: fixture.runtime.preActions, + log: (m) => t.diagnostic(m), + }); + const manifestPath = await locateSessionFile(appRoot); + const svelteComponentTarget = svelteComponentTargetFor(manifestPath); + assert.ok(svelteComponentTarget, `expected a component-preview session, got ${manifestPath}`); + return { + session, + manifestPath, + svelteComponentTarget, + sourceFile: manifestPath, + }; + } catch (err) { + await teardownAndResetBrowser(session.teardown); + throw err; + } +} + +function readManifest(manifestPath) { + return JSON.parse(readFileSync(manifestPath, 'utf-8')); +} + +/** + * The file the browser actually imports for a variant: the server snapshots + * every publish into a fresh `r/` dir and points the manifest at it, so the + * session dir's copy is not what a mount reads. + */ +function revisionVariantPath(appRoot, manifestPath, variantNum) { + const manifest = readManifest(manifestPath); + const dir = manifest.revisionDir || manifest.componentDir; + assert.ok(dir, 'manifest carries a preview directory'); + const extension = manifest.componentExtension || 'svelte'; + return join(appRoot, dir, `v${variantNum}.${extension}`); +} + +/** Poll the session journal for events of a given type. */ +async function waitForJournalEvent(appRoot, sessionId, type, timeoutMs = 15_000) { + const journalPath = join(appRoot, '.impeccable', 'live', 'sessions', `${sessionId}.jsonl`); + const deadline = Date.now() + timeoutMs; + let seen = []; + while (Date.now() < deadline) { + seen = readJournalEvents(journalPath).filter((event) => event?.type === type); + if (seen.length > 0) return seen; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `no ${type} event in ${journalPath} after ${timeoutMs}ms; saw types: ${ + [...new Set(readJournalEvents(journalPath).map((e) => e?.type))].join(', ') || '(none)' + }`, + ); +} + +function readJournalEvents(journalPath) { + if (!existsSync(journalPath)) return []; + return readFileSync(journalPath, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => { + try { return JSON.parse(line)?.event || null; } catch { return null; } + }) + .filter(Boolean); +} + +/** + * Assert the roots live.mjs resolved for an `appDir` fixture. + * + * The boot ran from the staged repo root, which carries no dev-server config + * of its own. Live mode has to pick the nested app, keep PRODUCT.md/DESIGN.md + * discovery at the git root, and leave its session state under the app plus a + * pointer at the repo root. A repo-root server.json means the session forked + * into a second, empty project — the failure this fixture exists to catch. + */ +function assertNestedAppRoots(session) { + const { tmp, appRoot, appDir, liveBoot } = session; + assert.ok(liveBoot, 'appDir fixtures boot through live.mjs and keep its payload'); + assert.equal(liveBoot.ok, true, `live.mjs boot payload: ${JSON.stringify(liveBoot)}`); + + const roots = liveBoot.roots || {}; + assert.ok( + roots.appRoot && roots.appRoot.endsWith(`/${appDir}`), + `roots.appRoot should end with /${appDir}, got ${roots.appRoot}`, + ); + assertSamePath(roots.appRoot, appRoot, 'roots.appRoot'); + assertSamePath(roots.repoRoot, tmp, 'roots.repoRoot'); + assertSamePath(roots.contextRoot, tmp, 'roots.contextRoot'); + assertSamePath(liveBoot.projectRoot, appRoot, 'projectRoot'); + + assert.equal(liveBoot.hasProduct, true, 'PRODUCT.md at the repo root is read from the nested app'); + assert.equal(liveBoot.hasDesign, true, 'DESIGN.md at the repo root is read from the nested app'); + + assert.ok( + existsSync(join(appRoot, '.impeccable/live/roots.json')), + 'the resolved manifest is persisted under the app', + ); + assert.ok( + existsSync(join(appRoot, '.impeccable/live/server.json')), + 'the live server registers itself under the app', + ); + assert.ok( + existsSync(join(tmp, '.impeccable/live/app-root.json')), + 'the repo root gets a pointer to the app', + ); + assert.equal( + existsSync(join(tmp, '.impeccable/live/server.json')), + false, + 'no live session state may be written at the repo root', + ); +} + +function assertSamePath(actual, expected, label) { + const resolve = (p) => { + try { return realpathSync(String(p)); } catch { return String(p); } + }; + assert.equal(resolve(actual), resolve(expected), `${label}: ${actual} !== ${expected}`); +} + function recordGenerateEvents(agent, events) { return { ...agent, @@ -964,6 +1377,11 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error writeFileSync(join(dir, 'error.txt'), String(error?.stack || error?.message || error || ''), 'utf-8'); writeFileSync(join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2), 'utf-8'); writeFileSync(join(dir, 'console-errors.log'), (session.consoleErrors || []).join('\n'), 'utf-8'); + writeFileSync( + join(dir, 'failed-requests.log'), + (session.failedRequests || []).map((f) => `${f.reason} ${f.url}`).join('\n'), + 'utf-8', + ); writeFileSync(join(dir, 'dev-server.log'), session.dev?.log?.() || '', 'utf-8'); writeCommandOutput(dir, 'git-status.txt', tmp, ['status', '--short']); writeCommandOutput(dir, 'git-diff.patch', tmp, ['diff', '--', '.']); @@ -983,6 +1401,7 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error for (const file of walkSources(tmp)) copyFileFromTmp(tmp, file, join(dir, 'sources')); copyDirIfExists(join(tmp, '.impeccable', 'live'), join(dir, 'impeccable-live')); copyDirIfExists(join(tmp, 'node_modules', '.impeccable-live'), join(dir, 'impeccable-live-preview')); + copyDirIfExists(join(tmp, '.impeccable', 'live', 'previews'), join(dir, 'impeccable-live-previews')); if (session.page) { const html = await withCaptureTimeout(session.page.content(), 5_000, 'page content').catch((err) => `capture failed: ${err.message}`); @@ -1176,7 +1595,7 @@ async function runManualScenarioActions(page, actions, { t, fixture, session, de } async function runManualEditStage(page, stage, { t, fixture, session, agentMode, defaultSelector }) { - const { tmp } = session; + const tmp = session.appRoot; if (stage.beforeManualEdit) { await runManualScenarioActions(page, stage.beforeManualEdit, { @@ -1323,7 +1742,7 @@ async function runAcceptedVariantCycle(page, { t, fixture, session, pickSelector await clickNext(page); assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible before manual scenario accept'); await clickAccept(page, { expectedVariant: 2 }); - const sourceFile = await locateSessionFile(session.tmp); + const sourceFile = await locateSessionFile(session.appRoot); await waitForSourceClean(sourceFile, 20_000); await waitForBarHidden(page, { timeout: 10_000 }).catch(() => {}); @@ -1610,6 +2029,7 @@ function svelteComponentTargetFor(filePath) { if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null; const sep = pathSepFor(filePath); const markers = [ + `${sep}.impeccable${sep}live${sep}previews${sep}`, `${sep}node_modules${sep}.impeccable-live${sep}`, `${sep}src${sep}lib${sep}impeccable${sep}`, `${sep}app${sep}.impeccable-live${sep}`, @@ -1712,6 +2132,7 @@ async function locateSessionFile(tmp) { function walkComponentManifests(root) { const results = []; const stack = [ + join(root, '.impeccable/live/previews'), join(root, 'node_modules/.impeccable-live'), join(root, 'src/lib/impeccable'), join(root, 'app/.impeccable-live'), diff --git a/tests/live-e2e/agent.mjs b/tests/live-e2e/agent.mjs index 7b6ea8c85..68d54af8d 100644 --- a/tests/live-e2e/agent.mjs +++ b/tests/live-e2e/agent.mjs @@ -89,13 +89,25 @@ export const STEER_MARKER_VALUE = 'e2e'; * - first variant visible (no display:none), rest hidden by the agent caller * - inner content = single

    per variant */ -export function createFakeAgent() { +export function createFakeAgent({ autoRepairMountFailures = true } = {}) { return { + // Read by runAgentLoop's `variant_mount_failed` handler. Scenarios that + // assert on the persistent mount-error card turn this off so the agent + // does not republish the session out from under them. + autoRepairMountFailures, + /** @type {LiveAgent['generateVariants']} */ async generateVariants(event, context = {}) { if (event.mode === 'insert') { return generateInsertFakeVariants(context); } + // Contract-v2 Svelte component previews get their own author path: the + // scaffolder already wrote stubs whose control flow and prop references + // are correct, so the only honest thing a variant can change is style. + if (context.wrapInfo?.previewMode === 'svelte-component') { + const svelteOutput = await generateSvelteComponentFakeVariants(event, context); + if (svelteOutput) return svelteOutput; + } const text = event.element?.textContent?.trim() || extractText(event.element?.outerHTML) || 'Title'; const tag = (event.element?.tagName || 'h1').toLowerCase(); const cls = (event.element?.classes || ['hero-title']) @@ -105,7 +117,14 @@ export function createFakeAgent() { const preservedAttrs = buildPreservedVariantAttrs(event.element || {}, cls); const elementOpen = `<${tag}${preservedAttrs}>`; const elementClose = ``; - const variantHtml = `${elementOpen}${htmlEscape(text)}${elementClose}`; + // The JSX source may bind its text through an expression (`{item.title}` + // inside a `.map()`). The DOM only ever hands the agent the rendered + // string, so writing that back would freeze one item's text into the + // template for every item. The Svelte path already solves this with a + // prop contract; on the source-preview path the equivalent is to carry + // the original expression through untouched. + const sourceExprInner = await readJsxExpressionInner(context); + const variantHtml = `${elementOpen}${sourceExprInner ?? htmlEscape(text)}${elementClose}`; const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed'; // Variant 1 — red color, with a `range` param tuning hue lightness. @@ -158,20 +177,25 @@ export function createFakeAgent() { // Scoped CSS for most frameworks. Astro component styles are transformed // and scoped by the compiler, so live preview CSS must use a global style // tag plus explicit variant prefixes instead of raw @scope rules. + // Each variant carries a distinct font-weight so the E2E suite can prove + // which variant is actually rendered, not merely which one the bar says + // is selected. Keep these in sync with FAKE_VARIANT_FONT_WEIGHTS. const scopedCss = useAstroGlobalCss ? [ `[data-impeccable-variant="1"] > ${tag} {`, + ' font-weight: 300;', ' color: oklch(var(--p-lightness, 0.5) 0.25 25);', '}', `[data-impeccable-variant="2"] > ${tag} { font-weight: 900; }`, `[data-impeccable-variant="2"][data-p-face="serif"] > ${tag} { font-family: ui-serif, serif; }`, `[data-impeccable-variant="2"][data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`, - `[data-impeccable-variant="3"] > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`, + `[data-impeccable-variant="3"] > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`, `[data-impeccable-variant="3"][data-p-italic] > ${tag} { font-style: italic; }`, ].join('\n') : [ '@scope ([data-impeccable-variant="1"]) {', ` :scope > ${tag} {`, + ' font-weight: 300;', ' color: oklch(var(--p-lightness, 0.5) 0.25 25);', ' }', '}', @@ -181,7 +205,7 @@ export function createFakeAgent() { ` :scope[data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`, '}', '@scope ([data-impeccable-variant="3"]) {', - ` :scope > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`, + ` :scope > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`, ` :scope[data-p-italic] > ${tag} { font-style: italic; }`, '}', ].join('\n'); @@ -256,17 +280,19 @@ function generateInsertFakeVariants(context = {}) { const scopedCss = useAstroGlobalCss ? [ '[data-impeccable-variant="1"] .inserted-copy {', + ' font-weight: 300;', ' color: oklch(var(--p-lightness, 0.5) 0.25 25);', '}', '[data-impeccable-variant="2"] .inserted-copy { font-weight: 900; }', '[data-impeccable-variant="2"][data-p-face="serif"] .inserted-copy { font-family: ui-serif, serif; }', '[data-impeccable-variant="2"][data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }', - '[data-impeccable-variant="3"] .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }', + '[data-impeccable-variant="3"] .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }', '[data-impeccable-variant="3"][data-p-italic] .inserted-copy { font-style: italic; }', ].join('\n') : [ '@scope ([data-impeccable-variant="1"]) {', ' :scope .inserted-copy {', + ' font-weight: 300;', ' color: oklch(var(--p-lightness, 0.5) 0.25 25);', ' }', '}', @@ -276,7 +302,7 @@ function generateInsertFakeVariants(context = {}) { ' :scope[data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }', '}', '@scope ([data-impeccable-variant="3"]) {', - ' :scope .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }', + ' :scope .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }', ' :scope[data-p-italic] .inserted-copy { font-style: italic; }', '}', ].join('\n'); @@ -287,6 +313,187 @@ function generateInsertFakeVariants(context = {}) { }; } +// --------------------------------------------------------------------------- +// Svelte component preview (contract v2) +// +// The scaffolder hands the agent stubs that already carry the selection's +// control flow ({#each}, {#if}) and prop references. A variant that re-derives +// markup from the live DOM would bake one item's rendered text into the loop +// template — exactly the failure the v2 contract exists to prevent. So the fake +// agent behaves like a well-behaved real one: it keeps every stub's script, +// comment, and markup byte-for-byte and rewrites only the \n`; +} + +/** + * Re-author every variant of a live component session and tell the server the + * publish happened, exactly as the poll loop does after a generate. The server + * snapshots a fresh `r/` revision dir on the `done` reply, so the browser + * imports from a path it has never seen and cannot serve a cached compile of + * the previous content. + * + * @param {object} opts + * @param {string} opts.tmp app root (where the manifest path resolves) + * @param {string} opts.manifestFile manifest path relative to `tmp` + * @param {{port: number, token: string}} opts.live + * @param {(variantNum: number, shape: object) => string} opts.css + */ +export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) { + const manifestPath = path.join(tmp, manifestFile); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); + const componentDir = path.join(tmp, manifest.componentDir); + const shape = svelteSelectionShape(manifest.originalMarkup || ''); + const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1; + for (let variantNum = 1; variantNum <= count; variantNum++) { + const file = path.join(componentDir, `v${variantNum}.svelte`); + let source; + try { source = await fs.readFile(file, 'utf-8'); } catch { continue; } + await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8'); + } + const res = await fetch(`http://127.0.0.1:${live.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: live.token, + type: 'done', + sourceEventType: 'generate', + id: manifest.id, + file: manifestFile, + }), + }); + if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`); + return { manifest, shape }; +} + export function insertTargetFromEvent(event) { const anchor = event?.insert?.anchor || {}; const classes = Array.isArray(anchor.classes) @@ -352,6 +559,74 @@ function attrEscape(str, { svelte = false } = {}) { return s; } +/** + * JSX-source counterpart to the Svelte prop contract. + * + * When the picked element's source content is a bare JSX expression (or text + * mixed with expressions), return it verbatim so the variant markup keeps the + * binding instead of the rendered snapshot. Returns null for every other + * shape, including nested elements, so this stays a narrow substitution rather + * than a general source-copy path. + * + * @param {{ wrapInfo?: object, tmp?: string }} context + * @returns {Promise} + */ +async function readJsxExpressionInner(context = {}) { + const wrapInfo = context.wrapInfo; + if (!wrapInfo) return null; + // Svelte previews bind through propContract downstream; leave them alone. + if (wrapInfo.previewMode === 'svelte-component') return null; + if (wrapInfo.commentSyntax?.open !== '{/*') return null; + + const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp); + const inner = extractInnerSourceMarkup(original); + if (inner == null) return null; + const trimmed = inner.trim(); + if (!trimmed || trimmed.includes('<')) return null; + if (!/\{[^{}]+\}/.test(trimmed)) return null; + return trimmed; +} + +/** + * Recover the picked element's source markup from the scaffold. Deferred + * writes carry it in `wrapperBlock`; otherwise the wrapper is already in the + * file and the same block can be read back from disk. + */ +async function readOriginalMarkupFromWrap(wrapInfo, tmp) { + let text = wrapInfo.wrapperBlock; + if (!text && tmp && wrapInfo.file) { + try { + text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'); + } catch { + return null; + } + } + if (!text) return null; + + const lines = String(text).split('\n'); + const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"')); + if (startIdx === -1) return null; + const indent = (lines[startIdx].match(/^\s*/) || [''])[0]; + const closer = `${indent}

    `; + for (let i = startIdx + 1; i < lines.length; i++) { + if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n'); + } + return null; +} + +/** Content between an element's opening and closing tag, or null. */ +function extractInnerSourceMarkup(markup) { + const src = String(markup || '').trim(); + if (!src) return null; + const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/); + if (!open || open[2].trim().endsWith('/')) return null; + const tag = open[1].toLowerCase(); + const closeIdx = src.toLowerCase().lastIndexOf(`$/.test(src.slice(closeIdx))) return null; + return src.slice(open[0].length, closeIdx); +} + /** * Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml * in HTML form; the orchestrator translates per the target file's syntax. @@ -1402,6 +1677,15 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ for (let i = 0; i < output.variants.length; i++) { const variantId = i + 1; const variant = output.variants[i]; + // Contract-v2 path: keep the scaffolded stub (control flow + prop + // references) and swap only its \n`; + const compiled = compile(component, { generate: 'client' }); + assert.ok(compiled.js.code.length > 0); + const fatal = (compiled.warnings || []).filter((w) => /error/i.test(w.code || '')); + assert.deepEqual(fatal, []); + }); + + it('collectRootIdentifiers skips member properties and object keys', () => { + const ast = parse(`

    {fmt(user.name, { width: cols })}

    `, { modern: true }); + const tag = ast.fragment.nodes[0].fragment.nodes[0]; + const roots = collectRootIdentifiers(tag.expression); + assert.deepEqual([...roots].sort(), ['cols', 'fmt', 'user']); + }); + + it('derivePropName picks stable tails', () => { + assert.equal(derivePropName('stages'), 'stages'); + assert.equal(derivePropName('data.stages'), 'stages'); + assert.equal(derivePropName('rows[0].label'), 'label'); + assert.equal(derivePropName('a + b'), 'value'); + }); +}); + +describe('keyed each blocks', () => { + it('records a keyField for member keys and keeps the key in the scaffold', () => { + const src = `
      {#each expenses as expense, i (expense.id)}
    • {expense.name}
    • {/each}
    `; + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, true, res.reason); + const collection = res.contract.find((c) => c.kind === 'collection'); + assert.equal(collection.item.keyField, 'id'); + assert.match(res.markupWithProps, /\(expense\.id\)/); + const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse); + assert.equal(restored.markup, src); + }); + + it('needs no keyField when the key is the item or the index', () => { + for (const src of [ + `
      {#each rows as r (r)}
    • {r.x}
    • {/each}
    `, + `
      {#each rows as r, i (i)}
    • {r.x}
    • {/each}
    `, + ]) { + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, true, `${res.reason} in ${src}`); + const collection = res.contract.find((c) => c.kind === 'collection'); + assert.equal(collection.item.keyField, undefined, src); + } + }); + + it('falls back for keys a detached preview cannot hydrate distinctly', () => { + const cases = [ + [`
      {#each rows as r (globalThing)}
    • {r.x}
    • {/each}
    `, /not derived from the loop item/], + [`
      {#each rows as r (makeKey(r))}
    • {r.x}
    • {/each}
    `, /complex each key/], + [`
      {#each rows as r (r.name)}
    • {r.name}
    • {/each}
    `, /also a displayed field/], + ]; + for (const [src, reason] of cases) { + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, false, `expected fallback for ${src}`); + assert.match(res.reason, reason, src); + } + }); +}); + +describe('review regressions: reserved prop names', () => { + it('never derives a JS reserved word as a prop name (M5)', () => { + for (const [src, expected] of [ + [`
    {item.class}
    `, 'classValue'], + [`
    {cfg.default}
    `, 'defaultValue'], + [`
    {a.for}
    `, 'forValue'], + ]) { + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, true, res.reason); + assert.equal(res.contract[0].prop, expected, src); + // The generated stub must actually compile. + const stub = `${buildPropsScriptV2(res.contract)}\n${res.markupWithProps}\n`; + const compiled = compile(stub, { generate: 'client' }); + assert.ok(compiled.js.code.length > 0, src); + // And restore back to the original expression. + const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse); + assert.equal(restored.markup, src); + } + }); +}); diff --git a/tests/live-svelte-component-accept.test.mjs b/tests/live-svelte-component-accept.test.mjs new file mode 100644 index 000000000..88ae9d9dc --- /dev/null +++ b/tests/live-svelte-component-accept.test.mjs @@ -0,0 +1,232 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, symlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { + extractMatchingSourceCss, + findSvelteComponentManifest, + inlineSvelteComponentAccept, + mergeCssIntoSvelteSource, + reindentPreservingStructure, + scaffoldSvelteComponentSession, +} from '../skill/scripts/live/svelte-component.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_NODE_MODULES = join(__dirname, '..', 'node_modules'); + +const ROUTE_SOURCE = ` + +
    +
      + {#each stages as stage, i} +
    1. + {stage.label} +

      {stage.detail}

      +
    2. + {/each} +
    + +
    + + +`; + +function write(root, rel, content) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); +} + +describe('svelte component scaffold + accept pipeline', () => { + let tmp; + + beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-svelte-accept-'))); + // The scaffolder resolves the app's svelte compiler; link this repo's. + mkdirSync(join(tmp, 'node_modules'), { recursive: true }); + try { + symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp, 'node_modules', 'svelte'), 'dir'); + } catch { + cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp, 'node_modules', 'svelte'), { recursive: true }); + } + write(tmp, 'package.json', JSON.stringify({ name: 'app', dependencies: { svelte: '^5' } })); + write(tmp, 'src/routes/+page.svelte', ROUTE_SOURCE); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + function scaffold(id = 'testacc1') { + // The picked element spans the
      block: lines 10-17 (1-indexed). + const originalLines = ROUTE_SOURCE.split('\n').slice(9, 17); + assert.match(originalLines[0], /
        /); + return scaffoldSvelteComponentSession({ + id, + count: 2, + sourceFile: 'src/routes/+page.svelte', + sourceStartLine: 10, + sourceEndLine: 17, + originalLines, + cwd: tmp, + }); + } + + it('scaffolds a v2 contract with the each collection as one structured prop', () => { + const session = scaffold(); + assert.equal(session.fallback, undefined); + assert.equal(session.manifest.contractVersion, 2); + const collection = session.propContract.find((c) => c.kind === 'collection'); + assert.equal(collection.prop, 'stages'); + assert.equal(collection.item.rootTag, 'li'); + const v1 = readFileSync(join(tmp, session.componentDir, 'v1.svelte'), 'utf-8'); + assert.match(v1, /\{#each stages as stage, i\}/); + assert.match(v1, /\{stage\.label\}/); + assert.match(v1, /let \{ stages = \[\] \} = \$props\(\)/); + // Stub CSS is seeded from the route's matching rules. + assert.match(v1, /border-top: 1px solid #333/); + }); + + it('falls back to source-preview for markup with component tags', () => { + const res = scaffoldSvelteComponentSession({ + id: 'fallb1', + count: 3, + sourceFile: 'src/routes/+page.svelte', + sourceStartLine: 1, + sourceEndLine: 1, + originalLines: [''], + cwd: tmp, + }); + assert.equal(res.fallback, 'source-preview'); + assert.match(res.reason, /component tag/); + }); + + it('accept merges CSS instead of appending: superseded rules are replaced, dead branches pruned', () => { + const session = scaffold('acc2'); + // Agent authors variant 1: arrows instead of divider borders, one param. + write(tmp, join(session.componentDir, 'v1.svelte'), ` + +
          + {#each stages as stage, i} +
        1. + {stage.label} +

          {stage.detail}

          +
        2. + {/each} +
        + + +`); + write(tmp, join(session.componentDir, 'params.json'), JSON.stringify({ + 1: [ + { id: 'depth', kind: 'range', min: 0, max: 20, step: 1, default: 6, label: 'Depth' }, + { id: 'density', kind: 'steps', default: 'airy', label: 'Density', options: [ + { value: 'airy', label: 'Airy' }, { value: 'snug', label: 'Snug' }, + ] }, + ], + })); + + const manifest = findSvelteComponentManifest('acc2', tmp); + const result = inlineSvelteComponentAccept(manifest, 1, { depth: 10, density: 'snug' }, tmp); + assert.equal(result.handled, true, result.error); + + const out = readFileSync(join(tmp, 'src/routes/+page.svelte'), 'utf-8'); + // Loop restored with original expressions, one each block only. + assert.equal(out.split('{#each stages as stage, i}').length - 1, 1); + // Superseded divider border is GONE (replaced, not shadowed). + assert.doesNotMatch(out, /border-top: 1px solid #333/); + assert.match(out, /clip-path/); + // Exactly one .pit-board rule. + assert.equal(out.split('.pit-board {').length - 1, 1); + // Range baked with paren-aware substitution. + assert.match(out, /padding: calc\(10 \+ 2px\)/); + // Steps: chosen branch folded into the .stage rule, other branch dropped, + // no data-p attributes anywhere. + assert.match(out, /margin: 4px/); + assert.equal(out.split(/\.stage \{/).length - 1, 1); + assert.doesNotMatch(out, /margin: 16px/); + assert.doesNotMatch(out, /data-p-/); + assert.doesNotMatch(out, /var\(--p-/); + // The untouched .footer rule survives. + assert.match(out, /\.footer \{ color: gray; \}/); + // Self-check reports clean. + assert.equal(result.verify.clean, true, JSON.stringify(result.verify.findings)); + }); + + it('preserves the variant markup indentation structure', () => { + const session = scaffold('acc3'); + write(tmp, join(session.componentDir, 'v1.svelte'), ` + +
          + {#each stages as stage, i} +
        1. +
          + {stage.label} +
          +
        2. + {/each} +
        + + +`); + const manifest = findSvelteComponentManifest('acc3', tmp); + const result = inlineSvelteComponentAccept(manifest, 1, null, tmp); + assert.equal(result.handled, true, result.error); + const out = readFileSync(join(tmp, 'src/routes/+page.svelte'), 'utf-8'); + const lines = out.split('\n'); + const deepIdx = lines.findIndex((l) => l.includes('
        ')); + const labelIdx = lines.findIndex((l) => l.includes('span class="label"')); + const deepIndent = lines[deepIdx].match(/^\s*/)[0].length; + const labelIndent = lines[labelIdx].match(/^\s*/)[0].length; + // Nested structure survives: label sits deeper than its parent div. + assert.equal(labelIndent > deepIndent, true, `expected nesting, got ${deepIndent} vs ${labelIndent}`); + }); + + it('reindentPreservingStructure keeps relative depth', () => { + const out = reindentPreservingStructure([' ', ' ', ' '], ' '); + assert.deepEqual(out, [' ', ' ', ' ']); + }); + + it('mergeCssIntoSvelteSource creates a style block when none exists', () => { + const { text } = mergeCssIntoSvelteSource('
        hi
        ', '.x { color: red; }'); + assert.match(text, /`; + return { + text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length), + removed, + }; +} + export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) { const before = collectAllSelectors(styleBlockText(beforeSource)); const after = collectAllSelectors(styleBlockText(afterSource)); diff --git a/tests/live-svelte-component-accept.test.mjs b/tests/live-svelte-component-accept.test.mjs index 88ae9d9dc..dbe67ec56 100644 --- a/tests/live-svelte-component-accept.test.mjs +++ b/tests/live-svelte-component-accept.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { extractMatchingSourceCss, + removeSelectorsFromSvelteSource, findSvelteComponentManifest, inlineSvelteComponentAccept, mergeCssIntoSvelteSource, @@ -230,3 +231,125 @@ describe('svelte component scaffold + accept pipeline', () => { assert.doesNotMatch(css, /\.footer/); }); }); + +describe('review regressions: preview-truth supersession (the Pitch mangle)', () => { + const PITCH_SOURCE = ` + +
        +
        + {#each verdicts as verdict} +
        +

        {verdict.label}

        +

        {verdict.detail}

        +
        + {/each} +
        +
        + + +`; + + it('removes seeded rules the variant did not re-declare and orders new base rules before media blocks', () => { + const tmp2 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-pitch-mangle-'))); + try { + mkdirSync(join(tmp2, 'node_modules'), { recursive: true }); + try { + symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), 'dir'); + } catch { + cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), { recursive: true }); + } + write(tmp2, 'package.json', JSON.stringify({ name: 'app' })); + write(tmp2, 'src/lib/Pitch.svelte', PITCH_SOURCE); + + // Picked element: the .decisions block (lines 10-17, 1-indexed). + const lines = PITCH_SOURCE.split('\n'); + const startLine = lines.findIndex((l) => l.includes('class="decisions"')) + 1; + const endLine = lines.findIndex((l, i) => i >= startLine && l.trim() === '
        ' && lines[i + 1]?.includes('')) + 1; + const originalLines = lines.slice(startLine - 1, endLine); + + const session = scaffoldSvelteComponentSession({ + id: 'pitchm1', + count: 1, + sourceFile: 'src/lib/Pitch.svelte', + sourceStartLine: startLine, + sourceEndLine: endLine, + originalLines, + cwd: tmp2, + }); + assert.equal(session.fallback, undefined, session.reason); + // Seeded selectors recorded for accept-time supersession. + assert.equal(session.manifest.seededSelectors.includes('.decisions'), true); + + // The agent's variant: a NEW class, no re-declaration of .decisions. + write(tmp2, join(session.componentDir, 'v1.svelte'), ` + +
        + {#each verdicts as verdict} +
        +

        {verdict.label}

        +

        {verdict.detail}

        +
        + {/each} +
        + + +`); + const manifest = findSvelteComponentManifest('pitchm1', tmp2); + const result = inlineSvelteComponentAccept(manifest, 1, null, tmp2); + assert.equal(result.handled, true, result.error); + const out = readFileSync(join(tmp2, 'src/lib/Pitch.svelte'), 'utf-8'); + + // The superseded grid rules are GONE: they never applied in the + // preview the user approved, and the root keeps the old class. + assert.doesNotMatch(out, /grid-template-columns: repeat\(3, 1fr\)/); + assert.doesNotMatch(out, /\.decisions > \.cell/); + assert.equal(result.css.superseded.includes('.decisions'), true); + // The untouched sibling rule survives. + assert.match(out, /\.pitch \{ padding: 40px; \}/); + // Source media block survives for the surviving class... + assert.match(out, /\.pitch \{ padding: 16px; \}/); + // ...and no longer carries the superseded selector. + assert.doesNotMatch(out, /\.decisions \{ grid-template-columns: 1fr; \}/); + // New base rules sit BEFORE the source's @media block (cascade order). + const baseIdx = out.indexOf('.disposition-board {'); + const mediaIdx = out.indexOf('@media (max-width: 700px)'); + assert.equal(baseIdx > -1 && mediaIdx > -1 && baseIdx < mediaIdx, true, + `expected base rules before media, got base@${baseIdx} media@${mediaIdx}`); + assert.equal(result.verify.clean, true, JSON.stringify(result.verify.findings)); + } finally { + rmSync(tmp2, { recursive: true, force: true }); + } + }); + + it('keeps seeded rules the variant re-declares', () => { + const { text, removed } = removeSelectorsFromSvelteSource('
        x
        \n', new Set(['.b'])); + assert.match(text, /\.a \{ color: red; \}/); + assert.doesNotMatch(text, /color: blue/); + assert.deepEqual(removed, ['.b']); + }); +}); From b4f1c1786e7f23b55923f55f9661c640fb11e3f7 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Jul 2026 19:03:39 -0700 Subject: [PATCH 13/29] docs: trim live.md hot path from 740 to 330 lines First-time setup (config schema, framework table, adapters, drift, the whole CSP flow) moves to reference/live-setup.md, loaded only when the boot reports config_missing/config_invalid or cspChecked is absent. The per-session prose is compressed without dropping any pinned phrase, MUST rule, schema, or example; the boot payload documentation now names the inlined surface brief. All live-reference pins and both prose gates pass. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/reference/live-setup.md | 102 ++++++ skill/reference/live.md | 633 ++++++---------------------------- 2 files changed, 212 insertions(+), 523 deletions(-) create mode 100644 skill/reference/live-setup.md diff --git a/skill/reference/live-setup.md b/skill/reference/live-setup.md new file mode 100644 index 000000000..d767c3ee2 --- /dev/null +++ b/skill/reference/live-setup.md @@ -0,0 +1,102 @@ +One-time live-mode project setup. Loaded from [live.md](live.md) only when `live.mjs` reports `config_missing` / `config_invalid`, when `configDrift` needs handling, or when the config lacks `cspChecked`. Not part of the per-session hot path. + +## Write the config + +Create the file at the `path` the boot reported (default `.impeccable/live/config.json`): + +```json +{ + "files": ["", "", ...], + "exclude": ["", ...], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} +``` + +`files` is the inject target: **the HTML files the browser actually loads**, not necessarily source (tracked vs generated does not matter here; wrap has its own generated-file guard). Entries are literal paths or globs. `exclude` (optional) skips files a `files` glob would otherwise include (email templates, demo fixtures). `cspChecked` records that the CSP step below has run; absent on first setup. + +**Hard-excluded paths (cannot be overridden):** `**/node_modules/**` and `**/.git/**`; injecting there would instrument third-party code. + +**Glob syntax:** `**` matches any number of segments (including zero), `*` matches within a segment, `?` matches one character. Paths are project-root-relative with forward slashes. + +| Framework | `files` | `insertBefore` | `commentSyntax` | +|-----------|---------|----------------|-----------------| +| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `` | `html` | +| Next.js (App Router) | `["app/layout.tsx"]` | `` | `jsx` | +| Next.js (Pages) | `["pages/_document.tsx"]` | `` | `jsx` | +| Nuxt | `["app.vue"]` | `` | `html` | +| Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | +| TanStack Router (SPA, Vite) | `["index.html"]` | `` | `html` | +| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `"]` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` glob over the served dir | `` | `html` | + +Pick an anchor that exists in every file (`` almost always works); `insertAfter` matches after a line instead. For multi-page sites prefer a glob so new pages are picked up automatically. For sites whose pages are rebuilt by a generator, the inject survives only until the next regeneration: re-run `live.mjs` after each build (accept is unaffected; it writes true source via the fallback flow). + +**Framework adapters (auto-detected at inject time).** Every inject records what it wrote in `.impeccable/live/inject-journal.json`; the next inject or remove heals artifacts a crash or wrong-directory stop left behind. SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw ` + +
        hi
        + + + + +`); + const broken = compileCheckVariants('gate0001', tmp3); + assert.equal(broken.ok, false); + assert.equal(broken.checked, 1); + assert.match(broken.failures[0].message, /single top-level/); + assert.match(broken.failures[0].file, /gate0001\/v1\.svelte/); + assert.equal(typeof broken.failures[0].line, 'number'); + + // Merged into one block: the gate opens. + write(tmp3, join(session.componentDir, 'v1.svelte'), ` + +
        hi
        + + +`); + const fixed = compileCheckVariants('gate0001', tmp3); + assert.equal(fixed.ok, true, JSON.stringify(fixed.failures)); + } finally { + rmSync(tmp3, { recursive: true, force: true }); + } + }); +}); From 24d69675e034bdfe3fbc99b04bcf73c18dc48c35 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 14:22:57 -0700 Subject: [PATCH 19/29] fix: mixed loop/outer expressions fall back; globals are neither free nor bound cursor[bot]: an expression mixing loop bindings with outer free names (fmt(r.label) where fmt lives in the route script) was left verbatim, so the detached preview referenced an undeclared identifier and failed at mount, past the compile gate, because globals make it legal to the compiler. Such expressions now mark the analysis unsupported and the session takes source-preview mode. A globals allowlist makes Math/JSON and friends count as neither free nor bound, which also fixes a latent bug where a pure-global expression minted a nonsense prop. Won't-fix on the same pass: the live-setup.md filename cross-reference matches the repo's established reference-link convention. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/svelte-ast.mjs | 56 +++++++++++++++++++++++++++---- tests/live-svelte-ast.test.mjs | 22 ++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/skill/scripts/live/svelte-ast.mjs b/skill/scripts/live/svelte-ast.mjs index be9d67ee6..3462b9760 100644 --- a/skill/scripts/live/svelte-ast.mjs +++ b/skill/scripts/live/svelte-ast.mjs @@ -177,15 +177,50 @@ function exprText(source, node) { return source.slice(node.start, node.end); } -function isFree(node, scopes) { +// Identifiers that resolve in ANY module scope. They are neither hydratable +// props nor evidence of route coupling, so they count as neither free nor +// bound: `{Math.round(x)}` must not mint a prop named `round`, and +// `{fmt(stage.label)}` must not pass as global-only. +const GLOBAL_IDENTIFIERS = new Set([ + 'Math', 'JSON', 'Date', 'Intl', 'Number', 'String', 'Boolean', 'Array', + 'Object', 'Map', 'Set', 'Promise', 'RegExp', 'NaN', 'Infinity', 'undefined', + 'isNaN', 'isFinite', 'parseInt', 'parseFloat', 'encodeURIComponent', + 'decodeURIComponent', 'console', 'window', 'document', 'navigator', + 'location', 'structuredClone', 'crypto', +]); + +function classifyRoots(node, scopes) { const roots = collectRootIdentifiers(node); - if (roots.size === 0) return false; // literal-only: nothing to hydrate + let bound = 0; + let free = 0; for (const name of roots) { - for (const scope of scopes) { - if (scope.has(name)) return false; - } + if (GLOBAL_IDENTIFIERS.has(name)) continue; + if (scopes.some((scope) => scope.has(name))) bound++; + else free++; } - return true; + return { bound, free }; +} + +function isFree(node, scopes) { + const { bound, free } = classifyRoots(node, scopes); + return free > 0 && bound === 0; +} + +/** + * An expression mixing loop-bound and outer free identifiers (e.g. + * `{fmt(stage.label)}` where `fmt` lives in the route script) can neither + * become a prop (the bound part varies per item) nor survive detachment + * verbatim (the free name is undeclared in the preview and throws at mount, + * past the compile gate, because globals make it legal to the compiler). + * Source-preview mode is the only correct home for it. + */ +function failOnMixedExpression(node, scopes, analysis, source) { + const { bound, free } = classifyRoots(node, scopes); + if (bound > 0 && free > 0) { + analysis.fail(`expression mixing loop and outer identifiers ({${exprText(source, node).slice(0, 60)}}) requires source-preview mode`); + return true; + } + return false; } /** @@ -214,6 +249,7 @@ function analyzeNode(node, analysis, scopes) { case 'Comment': return; case 'ExpressionTag': { + if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return; if (isFree(node.expression, scopes)) { const text = exprText(analysis.source, node.expression); const entry = analysis.propFor(text, 'text'); @@ -223,6 +259,7 @@ function analyzeNode(node, analysis, scopes) { return; } case 'HtmlTag': { + if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return; if (isFree(node.expression, scopes)) { const text = exprText(analysis.source, node.expression); const entry = analysis.propFor(text, 'raw'); @@ -235,6 +272,7 @@ function analyzeNode(node, analysis, scopes) { // travels with the markup and stays valid only if its inputs do. if (node.declaration) { for (const decl of node.declaration.declarations || []) { + if (decl.init && failOnMixedExpression(decl.init, scopes, analysis, analysis.source)) return; if (decl.init && isFree(decl.init, scopes)) { const text = exprText(analysis.source, decl.init); const entry = analysis.propFor(text, 'text'); @@ -245,6 +283,7 @@ function analyzeNode(node, analysis, scopes) { return; } case 'EachBlock': { + if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return; if (isFree(node.expression, scopes)) { const text = exprText(analysis.source, node.expression); const item = describeEachItem(node, analysis.source); @@ -282,6 +321,7 @@ function analyzeNode(node, analysis, scopes) { return; } case 'IfBlock': { + if (failOnMixedExpression(node.test, scopes, analysis, analysis.source)) return; if (isFree(node.test, scopes)) { const text = exprText(analysis.source, node.test); // The browser hydrates a free condition from what the live page @@ -297,6 +337,7 @@ function analyzeNode(node, analysis, scopes) { return; } case 'KeyBlock': { + if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return; if (isFree(node.expression, scopes)) { const text = exprText(analysis.source, node.expression); const entry = analysis.propFor(text, 'text'); @@ -366,6 +407,7 @@ function analyzeAttributes(node, analysis, scopes) { const parts = Array.isArray(attr.value) ? attr.value : [attr.value]; for (const part of parts) { if (!part || part.type !== 'ExpressionTag') continue; + if (failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) return; if (!isFree(part.expression, scopes)) continue; const text = exprText(analysis.source, part.expression); const kind = HANDLER_ATTR_RE.test(attr.name) ? 'handler' : 'text'; @@ -376,6 +418,7 @@ function analyzeAttributes(node, analysis, scopes) { } case 'ClassDirective': { const expr = attr.expression; + if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return; if (expr && isFree(expr, scopes)) { const text = exprText(analysis.source, expr); // The directive's class name is literal, so the live DOM answers @@ -417,6 +460,7 @@ function analyzeAttributes(node, analysis, scopes) { case 'OnDirective': { // Legacy on:click syntax; treat like handler attributes. const expr = attr.expression; + if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return; if (expr && isFree(expr, scopes)) { const text = exprText(analysis.source, expr); const entry = analysis.propFor(text, 'handler'); diff --git a/tests/live-svelte-ast.test.mjs b/tests/live-svelte-ast.test.mjs index 6da7898bd..a68f27e8a 100644 --- a/tests/live-svelte-ast.test.mjs +++ b/tests/live-svelte-ast.test.mjs @@ -236,3 +236,25 @@ describe('review regressions: directives', () => { assert.equal(res.ok, true, res.reason); }); }); + +describe('review regressions: mixed and global identifiers', () => { + it('falls back for expressions mixing loop bindings with outer names', () => { + const src = `
          {#each rows as r}
        • {fmt(r.label)}
        • {/each}
        `; + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, false); + assert.match(res.reason, /mixing loop and outer identifiers/); + }); + + it('treats known globals as neither free nor bound', () => { + // Global + bound: stays verbatim, no prop, no fallback. + const okRes = analyzeSvelteMarkup(`
          {#each rows as r}
        • {Math.round(r.score)}
        • {/each}
        `, parse); + assert.equal(okRes.ok, true, okRes.reason); + assert.equal(okRes.contract.some((c) => c.prop === 'round'), false); + assert.match(okRes.markupWithProps, /\{Math\.round\(r\.score\)\}/); + + // Pure-global expression at top level: no prop minted either. + const topRes = analyzeSvelteMarkup(`

        {JSON.stringify(navigator.language)}

        `, parse); + assert.equal(topRes.ok, true, topRes.reason); + assert.equal(topRes.contract.length, 0); + }); +}); From 9a3f5aa34b7e1e6cb8b916e3cfee1564ab32e632 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 14:33:11 -0700 Subject: [PATCH 20/29] fix: portable port probe for live-server liveness greptile-apps[bot]: the win32 branch skipped the port probe entirely (bash /dev/tcp is not portable), so a reused pid on Windows still classified as a running helper. The probe is now a spawned node one-liner that behaves identically on every platform, which also drops the bash dependency for minimal Linux environments; the ps identity check remains only for legacy server.json records without a port. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index ac8750068..1adab11d4 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -313,16 +313,23 @@ function hasLiveServer(appRoot) { // Liveness alone misclassifies a REUSED pid (helper died without removing // server.json, the OS handed the pid to something else, even another node // process). The decisive signal is the recorded PORT: a real helper is - // listening on it, a pid squatter is not. - if (Number.isInteger(port) && port > 0 && process.platform !== 'win32') { + // listening on it, a pid squatter is not. The probe is a spawned node + // one-liner so it works identically on every platform (no bash, no ps). + if (Number.isInteger(port) && port > 0) { try { - execFileSync('bash', ['-c', `exec 3<>/dev/tcp/127.0.0.1/${port}`], { timeout: 1500, stdio: 'ignore' }); + execFileSync(process.execPath, ['-e', [ + "const s = require('node:net').connect({ host: '127.0.0.1', port: Number(process.argv[1]), timeout: 800 });", + "s.on('connect', () => { s.destroy(); process.exit(0); });", + "s.on('timeout', () => { s.destroy(); process.exit(1); });", + "s.on('error', () => process.exit(1));", + ].join(''), String(port)], { timeout: 3000, stdio: 'ignore' }); return true; } catch { return false; } } - if (process.platform === 'win32') return true; // no cheap portable probe + // Legacy server.json without a port: best-effort process identity check. + if (process.platform === 'win32') return true; try { const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }); return /live-server|\b(node|bun)\b/.test(command); From 16a84bc3908cbe72311d947b5888d4d2909e4b13 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 14:50:28 -0700 Subject: [PATCH 21/29] fix: authenticate the live-server liveness probe greptile-apps[bot] escalated the identity ladder to a pid AND port both coincidentally reused by different processes. The definitive terminator was available all along: the helper serves an authenticated endpoint and server.json records the token, so the probe now requires a 200 from /status?token=... over HTTP. Nothing but our helper can answer that, which closes the entire misidentification class rather than the next rung. The regression test hosts its responder in a child process (the probe is execFileSync, so a same-process responder can never accept while the parent's event loop is blocked; production helpers are always separate processes). This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 26 ++++++++++++++------------ tests/live-roots.test.mjs | 29 ++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index 1adab11d4..b786c3e8b 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -300,35 +300,37 @@ function readPointerEntries(repoRoot) { function hasLiveServer(appRoot) { let pid; let port; + let token; try { const info = JSON.parse(fs.readFileSync(path.join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8')); if (!info || typeof info.pid !== 'number') return false; pid = info.pid; port = Number(info.port); + token = typeof info.token === 'string' ? info.token : null; process.kill(pid, 0); } catch (err) { // EPERM: the process exists but is not signalable by this user. if (err?.code !== 'EPERM') return false; } - // Liveness alone misclassifies a REUSED pid (helper died without removing - // server.json, the OS handed the pid to something else, even another node - // process). The decisive signal is the recorded PORT: a real helper is - // listening on it, a pid squatter is not. The probe is a spawned node - // one-liner so it works identically on every platform (no bash, no ps). - if (Number.isInteger(port) && port > 0) { + // Liveness alone misclassifies a REUSED pid, and a bare TCP connect + // misclassifies a coincidental listener on a reused port. The decisive + // signal is IDENTITY: the helper answers its authenticated /status + // endpoint with the token server.json records; nothing else on that port + // can. The probe is a spawned node one-liner so it works identically on + // every platform. + if (Number.isInteger(port) && port > 0 && token) { try { execFileSync(process.execPath, ['-e', [ - "const s = require('node:net').connect({ host: '127.0.0.1', port: Number(process.argv[1]), timeout: 800 });", - "s.on('connect', () => { s.destroy(); process.exit(0); });", - "s.on('timeout', () => { s.destroy(); process.exit(1); });", - "s.on('error', () => process.exit(1));", - ].join(''), String(port)], { timeout: 3000, stdio: 'ignore' }); + "const req = require('node:http').get({ host: '127.0.0.1', port: Number(process.argv[1]), path: '/status?token=' + encodeURIComponent(process.argv[2]), timeout: 1200 }, (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); });", + "req.on('timeout', () => { req.destroy(); process.exit(1); });", + "req.on('error', () => process.exit(1));", + ].join(''), String(port), token], { timeout: 4000, stdio: 'ignore' }); return true; } catch { return false; } } - // Legacy server.json without a port: best-effort process identity check. + // Legacy server.json without a port/token: best-effort identity check. if (process.platform === 'win32') return true; try { const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }); diff --git a/tests/live-roots.test.mjs b/tests/live-roots.test.mjs index ccc93d325..3a5704215 100644 --- a/tests/live-roots.test.mjs +++ b/tests/live-roots.test.mjs @@ -1,10 +1,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; -import { createServer } from 'node:net'; import { fileURLToPath } from 'node:url'; import { discoverAppCandidates, @@ -212,13 +211,25 @@ describe('review regressions: multi-app pointer', () => { writeRootsManifest(a); writeRootsManifest(b); // B booted last: a naive pointer now points at B - // A's helper server is the one alive: a real listener on a real port - // (the liveness check probes the recorded port, so a bare pid is not - // enough to count as running). - const srv = createServer(); - await new Promise((resolve) => srv.listen(0, '127.0.0.1', resolve)); + // A's helper server is the one alive: an authenticated /status + // responder on a real port, hosted in a CHILD process because the + // probe is execFileSync and a same-process responder could never + // accept while the event loop is blocked (production helpers are + // always separate processes). + const responder = spawn(process.execPath, ['-e', [ + "const s = require('node:http').createServer((q, r) => {", + " const ok = q.url === '/status?token=t';", + " r.writeHead(ok ? 200 : 401, { 'Content-Type': 'application/json' });", + " r.end('{}');", + "});", + "s.listen(0, '127.0.0.1', () => console.log(s.address().port));", + ].join('\n')], { stdio: ['ignore', 'pipe', 'ignore'] }); + const livePort = await new Promise((resolve, reject) => { + responder.stdout.once('data', (chunk) => resolve(Number(String(chunk).trim()))); + responder.once('error', reject); + setTimeout(() => reject(new Error('responder never became ready')), 5000); + }); try { - const livePort = srv.address().port; write(repo, 'siteA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: livePort, token: 't' })); write(repo, 'siteB/.impeccable/live/server.json', JSON.stringify({ pid: 999999999, port: 2, token: 't' })); @@ -226,7 +237,7 @@ describe('review regressions: multi-app pointer', () => { assert.equal(resolved.source, 'pointer'); assert.equal(resolved.manifest.appRoot, join(repo, 'siteA')); } finally { - await new Promise((resolve) => srv.close(resolve)); + responder.kill(); } } finally { rmSync(repo, { recursive: true, force: true }); From 6997e4bdb571836411adcf12383daf2b6ee147cc Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 15:03:10 -0700 Subject: [PATCH 22/29] fix: no unauthenticated path in live-server liveness greptile-apps[bot]: the legacy fallback (server.json without port or token) accepted a pid-only record on Windows without identity. Every server.json this codebase has ever written records port and token, so a record without them is malformed or foreign; it now classifies as not live and resolution falls to the durable-session tier, the correct recovery path for a crashed helper. The ps-based identity heuristic is gone with it: authentication or nothing. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index b786c3e8b..3c0a32e9e 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -330,14 +330,12 @@ function hasLiveServer(appRoot) { return false; } } - // Legacy server.json without a port/token: best-effort identity check. - if (process.platform === 'win32') return true; - try { - const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }); - return /live-server|\b(node|bun)\b/.test(command); - } catch { - return false; - } + // Every server.json this codebase has ever written records port + token + // (see writeLiveServerInfo). A record without them is malformed or foreign + // and cannot be authenticated, so it does not count as a live helper; + // resolution falls to the durable-session tier, which is the correct + // recovery path for a stopped or crashed helper anyway. + return false; } const TERMINAL_SESSION_PHASES = new Set(['completed', 'discarded']); From 39f233ac24ccd526a9089ab0ad2fc5d76b4aab22 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 15:33:16 -0700 Subject: [PATCH 23/29] fix: hydrate attribute-bound each values and guard style directives Addresses two cursor review findings: - {#each} bodies whose bound values appear in attributes (href={link.href}, src={item.img}) now record attr slots; the browser hydrates them from the rendered attribute so component previews no longer mount with empty links. Single-expression attributes hydrate exactly; mixed values stay unhydrated as before. A new slot classifier also refuses shapes that would crash a shallow hydration item (deep paths, method calls, bare item renders) and routes them to source-preview mode instead. - Style directives now run the mixed loop/outer identifier check before the free-identifier param check, so style:width={base + r.pct} falls back instead of minting a broken param. Tests: attr-slot analysis units, crashy/lossy fallback units, an attribute- bound anchor in the stateful SvelteKit fixture asserted through accept, and a mountedDomProbe e2e hook that reads the hydrated href off the mounted variant DOM (verified to fail when hydration is disabled). AI-assisted (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live-browser.js | 10 + skill/scripts/live/svelte-ast.mjs | 174 +++++++++++++++--- .../files/src/routes/+page.svelte | 7 +- .../vite8-sveltekit-stateful/fixture.json | 110 +++++++++-- tests/live-e2e.test.mjs | 26 +++ tests/live-svelte-ast.test.mjs | 36 ++++ 6 files changed, 320 insertions(+), 43 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index db05ec54f..b32a4fac2 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -5446,6 +5446,16 @@ const texts = collectVisibleTexts(itemEl).filter((t) => !statics.has(t)); const item = {}; slots.forEach((slot, i) => { item[slot.key] = texts[i] != null ? texts[i] : ''; }); + // Attribute-bound values (href={link.href}) hydrate from the + // rendered attribute on the live item element or a descendant. + for (const slot of entry.item.attrSlots || []) { + if (item[slot.key] != null || !slot.tag) continue; + const sel = slot.tag + (slot.classes || []).map((c) => '.' + cssEscapeIdent(c)).join(''); + let el = null; + try { el = itemEl.matches(sel) ? itemEl : itemEl.querySelector(sel); } catch { el = null; } + const value = el ? el.getAttribute(slot.attr) : null; + if (value != null) item[slot.key] = value; + } // Keyed each: the key field is never rendered, so hydrate it with a // unique per-index value or Svelte throws each_key_duplicate. if (entry.item.keyField && item[entry.item.keyField] == null) { diff --git a/skill/scripts/live/svelte-ast.mjs b/skill/scripts/live/svelte-ast.mjs index 3462b9760..72733cbfd 100644 --- a/skill/scripts/live/svelte-ast.mjs +++ b/skill/scripts/live/svelte-ast.mjs @@ -434,6 +434,12 @@ function analyzeAttributes(node, analysis, scopes) { // Unlike ClassDirective, a style directive stores its value in // attribute shape: `true` for the shorthand, else an array of parts. const parts = attr.value === true ? [] : (Array.isArray(attr.value) ? attr.value : [attr.value]); + for (const part of parts) { + if (part?.type === 'ExpressionTag' + && failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) { + return; + } + } const dynamic = parts.some((part) => part?.type === 'ExpressionTag' && isFree(part.expression, scopes)); const shorthandFree = attr.value === true && isFree({ type: 'Identifier', name: attr.name }, scopes); if (dynamic || shorthandFree) { @@ -485,9 +491,6 @@ function analyzeAttributes(node, analysis, scopes) { function describeEachItem(node, source) { const body = node.body; const rootEl = (body?.nodes || []).find((n) => n.type === 'RegularElement'); - const bound = new Set(); - if (node.context) collectPatternNames(node.context, bound); - if (node.index) bound.add(node.index); const textSlots = []; const staticTexts = []; @@ -508,34 +511,162 @@ function describeEachItem(node, source) { } }; collectStatics(body); - const walkForSlots = (fragment, scopes) => { + const attrSlots = []; + // The hydration item is a SHALLOW object whose string fields are the exact + // property names the markup accesses, filled from the rendered page. That + // model supports one item access per slot, optionally wrapped in a global + // transform ({Math.round(r.score)} hydrates `score`). Shapes it cannot + // represent split two ways: CRASHY ones would throw at mount time against a + // shallow item (deep paths like r.meta.label, method calls like r.format()) + // and force the source-preview fallback; LOSSY ones render wrong but safe + // (bare {r}, multi-access expressions that would double their text) and + // also fall back in text position, where the damage is visible. + const boundAs = (name, scopeInfos) => { + for (let i = scopeInfos.length - 1; i >= 0; i--) { + const info = scopeInfos[i]; + if (info.indexName === name) return 'index'; + if (info.itemName === name) return 'item'; + if (info.names.has(name)) return 'field'; + } + return null; + }; + const slotKeysOf = (expression, scopeInfos) => { + const keys = new Set(); + let crashy = false; + let lossy = false; + let touches = false; + const visit = (node, ctx) => { + if (!node || typeof node !== 'object' || crashy) return; + if (Array.isArray(node)) { + for (const item of node) visit(item, {}); + return; + } + switch (node.type) { + case 'Identifier': { + const kind = boundAs(node.name, scopeInfos); + if (!kind) return; + touches = true; + if (kind === 'index') return; // the runtime each provides it + if (kind === 'item') { lossy = true; return; } // bare item reference + if (ctx.callee) { crashy = true; return; } // field() on a hydrated string + keys.add(node.name); // destructured context field + return; + } + case 'MemberExpression': { + if ( + !node.computed + && node.object?.type === 'Identifier' + && boundAs(node.object.name, scopeInfos) === 'item' + && node.property?.type === 'Identifier' + ) { + touches = true; + // item.a.b or item.method(): a shallow string field throws here. + if (ctx.memberObject || ctx.callee) { crashy = true; return; } + keys.add(node.property.name); + return; + } + visit(node.object, { memberObject: true }); + if (node.computed) visit(node.property, {}); + return; + } + case 'CallExpression': + visit(node.callee, { callee: true }); + for (const arg of node.arguments || []) visit(arg, {}); + return; + case 'ArrowFunctionExpression': + case 'FunctionExpression': { + // Closures cannot hydrate; only lossy when they capture the item. + const roots = collectRootIdentifiers(node); + if ([...roots].some((name) => boundAs(name, scopeInfos))) { touches = true; lossy = true; } + return; + } + case 'Property': + if (node.computed) visit(node.key, {}); + visit(node.value, {}); + return; + default: { + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue; + visit(node[key], {}); + } + } + } + }; + visit(expression, {}); + if (crashy) return { crashy: true }; + if (lossy || keys.size > 1) return { lossy: true }; + if (!touches || keys.size === 0) return { skip: true }; + return { key: [...keys][0] }; + }; + const staticClassesOf = (el) => { + const classes = []; + for (const attr of el?.attributes || []) { + if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) { + for (const part of attr.value) { + if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean)); + } + } + } + return classes; + }; + const scopeInfoOf = (eachNode) => { + const names = new Set(); + if (eachNode.context) collectPatternNames(eachNode.context, names); + return { + names, + itemName: eachNode.context?.type === 'Identifier' ? eachNode.context.name : null, + indexName: eachNode.index || null, + }; + }; + const walkForSlots = (fragment, scopeInfos) => { for (const child of fragment?.nodes || []) { if (child.type === 'ExpressionTag') { - const roots = collectRootIdentifiers(child.expression); - const referencesItem = [...roots].some((name) => scopes.some((s) => s.has(name))); - if (referencesItem) { - textSlots.push({ - key: derivePropName(exprText(source, child.expression)), - expr: exprText(source, child.expression), - }); + const slot = slotKeysOf(child.expression, scopeInfos); + if (slot.crashy || slot.lossy) { nestedUnsupported = true; continue; } + if (slot.skip) continue; + textSlots.push({ key: slot.key, expr: exprText(source, child.expression) }); + } else if (child.type === 'RegularElement' || child.type === 'SvelteElement') { + // Bound values in ATTRIBUTES (href={link.href}, src={item.img}) are + // part of the item too: the browser reads the rendered attribute off + // the live element, so the preview does not mount with empty links. + // Only a single-expression attribute hydrates exactly; a mixed value + // ("card {r.status}") stays unhydrated because the rendered attribute + // is not separable into its parts, which was the prior behavior. + for (const attr of child.attributes || []) { + if (attr.type !== 'Attribute' || attr.value === true) continue; + if (HANDLER_ATTR_RE.test(attr.name)) continue; // functions cannot hydrate + const parts = Array.isArray(attr.value) ? attr.value : [attr.value]; + const exprParts = parts.filter((part) => part?.type === 'ExpressionTag'); + for (const part of exprParts) { + const slot = slotKeysOf(part.expression, scopeInfos); + if (slot.crashy) { nestedUnsupported = true; continue; } + if (slot.skip || slot.lossy) continue; + if (parts.length !== 1) continue; // mixed static+dynamic value + attrSlots.push({ + key: slot.key, + expr: exprText(source, part.expression), + attr: attr.name, + tag: child.name || null, + classes: staticClassesOf(child), + }); + } } + walkForSlots(child.fragment, scopeInfos); + continue; } else if (child.type === 'EachBlock') { const roots = collectRootIdentifiers(child.expression); - const boundNested = [...roots].some((name) => scopes.some((s) => s.has(name))); + const boundNested = [...roots].some((name) => boundAs(name, scopeInfos)); if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet - const innerBound = new Set(); - if (child.context) collectPatternNames(child.context, innerBound); - if (child.index) innerBound.add(child.index); - walkForSlots(child.body, [...scopes, innerBound]); + walkForSlots(child.body, [...scopeInfos, scopeInfoOf(child)]); } else if (child.type === 'IfBlock') { - walkForSlots(child.consequent, scopes); - if (child.alternate) walkForSlots(child.alternate, scopes); + walkForSlots(child.consequent, scopeInfos); + if (child.alternate) walkForSlots(child.alternate, scopeInfos); } else if (child.fragment) { - walkForSlots(child.fragment, scopes); + walkForSlots(child.fragment, scopeInfos); } } }; - walkForSlots(body, [bound]); + walkForSlots(body, [scopeInfoOf(node)]); const staticClasses = []; for (const attr of rootEl?.attributes || []) { @@ -550,6 +681,7 @@ function describeEachItem(node, source) { rootTag: rootEl?.name || null, rootClasses: staticClasses, textSlots, + attrSlots, staticTexts, nestedUnsupported, }; @@ -635,7 +767,7 @@ export function analyzeSvelteMarkup(markup, parse) { } for (const entry of analysis.contract) { if (entry.kind === 'collection' && entry.item?.nestedUnsupported) { - return { ok: false, reason: 'nested per-item each blocks require source-preview mode' }; + return { ok: false, reason: 'per-item content (nested blocks or expressions) this preview cannot hydrate requires source-preview mode' }; } } diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte index 6688f43f2..ad4cb43fd 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/files/src/routes/+page.svelte @@ -8,9 +8,9 @@ }); const CATALOG = [ - { name: 'Design snack', amount: '$12' }, - { name: 'Studio coffee', amount: '$8' }, - { name: 'Type license', amount: '$44' }, + { name: 'Design snack', amount: '$12', doc: '/receipts/snack' }, + { name: 'Studio coffee', amount: '$8', doc: '/receipts/coffee' }, + { name: 'Type license', amount: '$44', doc: '/receipts/type' }, ]; function addExpense() { @@ -40,6 +40,7 @@
      1. {expense.name} {expense.amount} + Beleg
      2. {/each} diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json index b83acc5e9..945c90668 100644 --- a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json @@ -1,23 +1,38 @@ { "name": "Vite 8 + SvelteKit stateful page", "config": { - "files": ["src/app.html"], + "files": [ + "src/app.html" + ], "insertBefore": "", "commentSyntax": "html" }, - "sourceFiles": ["DESIGN.md", "src/app.html", "src/routes/+page.svelte", "src/routes/+layout.svelte", "svelte.config.js", "vite.config.js"], + "sourceFiles": [ + "DESIGN.md", + "src/app.html", + "src/routes/+page.svelte", + "src/routes/+layout.svelte", + "svelte.config.js", + "vite.config.js" + ], "generatedFiles": [], "wrapCases": [ { "name": "wraps hero title through Svelte component preview", - "args": { "classes": "hero-title", "tag": "h1" }, + "args": { + "classes": "hero-title", + "tag": "h1" + }, "expectedFile": "node_modules/.impeccable-live/wraptest0/manifest.json", "expectedSourceFile": "src/routes/+page.svelte", "expectedPreviewMode": "svelte-component" }, { "name": "wraps the each-block list through Svelte component preview", - "args": { "classes": "expense-list", "tag": "ul" }, + "args": { + "classes": "expense-list", + "tag": "ul" + }, "expectedFile": "node_modules/.impeccable-live/wraptest1/manifest.json", "expectedSourceFile": "src/routes/+page.svelte", "expectedPreviewMode": "svelte-component" @@ -25,27 +40,65 @@ ], "runtime": { "styling": "plain-css", - "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], - "devCommand": ["npx", "vite", "dev", "--host", "127.0.0.1"], + "install": [ + "npm", + "install", + "--no-audit", + "--no-fund", + "--loglevel=error" + ], + "devCommand": [ + "npx", + "vite", + "dev", + "--host", + "127.0.0.1" + ], "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyTimeoutMs": 120000, "steer": false, "pickSelector": "ul.expense-list", - "pickPosition": { "x": 10, "y": 10 }, - "variantSequence": [3, 1, 2], + "pickPosition": { + "x": 10, + "y": 10 + }, + "variantSequence": [ + 3, + 1, + 2 + ], "acceptedSourcePattern": "]*class=\"[^\"]*\\bexpense-list\\b", "assertSourceContains": [ "{#each expenses as expense, i}", "{expense.name}", - "{expense.amount}" + "{expense.amount}", + "href={expense.doc}" ], "preActions": [ - { "type": "click", "selector": "[data-testid='add-expense']" }, - { "type": "wait", "selector": "[data-testid='expense-row'][data-index='0']" }, - { "type": "click", "selector": "[data-testid='add-expense']" }, - { "type": "wait", "selector": "[data-testid='expense-row'][data-index='1']" }, - { "type": "click", "selector": "[data-testid='add-expense']" }, - { "type": "wait", "selector": "[data-testid='expense-row'][data-index='2']" } + { + "type": "click", + "selector": "[data-testid='add-expense']" + }, + { + "type": "wait", + "selector": "[data-testid='expense-row'][data-index='0']" + }, + { + "type": "click", + "selector": "[data-testid='add-expense']" + }, + { + "type": "wait", + "selector": "[data-testid='expense-row'][data-index='1']" + }, + { + "type": "click", + "selector": "[data-testid='add-expense']" + }, + { + "type": "wait", + "selector": "[data-testid='expense-row'][data-index='2']" + } ], "stateProbe": { "textSelector": "[data-testid='open-count']", @@ -60,13 +113,32 @@ "rangeValue": 1.8, "stepsLabel": "Density", "stepsOptionLabel": "Snug", - "expectSourceContains": ["line-height: 1.8", "letter-spacing: 0.01em"], - "expectSourceMissing": ["letter-spacing: 0.14em"] + "expectSourceContains": [ + "line-height: 1.8", + "letter-spacing: 0.01em" + ], + "expectSourceMissing": [ + "letter-spacing: 0.14em" + ] + }, + "componentFailureScenarios": { + "variant": 2, + "storageLoss": false }, - "componentFailureScenarios": { "variant": 2, "storageLoss": false }, "probe": { "expectLiveInit": true, "expectConsoleClean": true - } + }, + "mountedDomProbe": [ + { + "selector": "ul.expense-list a.expense-doc", + "attr": "href", + "expect": "/receipts/snack" + }, + { + "selector": "ul.expense-list strong.expense-name", + "expect": "Design snack" + } + ] } } diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index 36d08a5b2..24e0f0c12 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -512,6 +512,32 @@ for (const { name, fixture } of fixtures) { ); }; await assertVisibleVariantStyle(visible); + // Optional fixture hook: assert attribute/text values inside the + // MOUNTED variant DOM. Component previews hydrate collection items + // from the rendered page (text slots and attribute slots); a probe + // here catches a preview that mounts but with empty hydrated values, + // which every other assertion (style, counter, accept) misses. + if (Array.isArray(fixture.runtime.mountedDomProbe)) { + for (const probe of fixture.runtime.mountedDomProbe) { + const actual = await evaluatePageWithTimeout( + page, + ({ sel, attr }) => { + const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s)); + const el = query(sel) || document.querySelector(sel); + if (!el) return null; + return attr ? el.getAttribute(attr) : (el.textContent || '').trim(); + }, + { sel: probe.selector, attr: probe.attr || null }, + 5_000, + 'mounted DOM probe', + ); + assert.equal( + actual, + probe.expect, + `mounted variant DOM: ${probe.selector}${probe.attr ? ` [${probe.attr}]` : ' text'}`, + ); + } + } for (const targetVariant of cycleSequence) { t.diagnostic(`Cycling to variant ${targetVariant}`); visible = await cycleToVariant(page, targetVariant, expectedCount, { diff --git a/tests/live-svelte-ast.test.mjs b/tests/live-svelte-ast.test.mjs index a68f27e8a..31f0bd020 100644 --- a/tests/live-svelte-ast.test.mjs +++ b/tests/live-svelte-ast.test.mjs @@ -258,3 +258,39 @@ describe('review regressions: mixed and global identifiers', () => { assert.equal(topRes.contract.length, 0); }); }); + +describe('review regressions: attribute slots and hydration honesty', () => { + it('records attribute-bound values as attr slots', () => { + const src = ``; + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, true, res.reason); + const item = res.contract.find((c) => c.kind === 'collection').item; + assert.deepEqual(item.textSlots.map((s) => s.key), ['text']); + assert.deepEqual(item.attrSlots, [{ key: 'href', expr: 'link.href', attr: 'href', tag: 'a', classes: ['nav-link'] }]); + }); + + it('falls back for per-item expressions the shallow item cannot represent', () => { + for (const src of [ + `
          {#each rows as r}
        • {r.meta.label}
        • {/each}
        `, + `
          {#each rows as r}
        • {r.format()}
        • {/each}
        `, + `
          {#each rows as r}
        • {r}
        • {/each}
        `, + ]) { + const res = analyzeSvelteMarkup(src, parse); + assert.equal(res.ok, false, `expected fallback for ${src}`); + assert.match(res.reason, /cannot hydrate/); + } + }); + + it('index-only expressions need no slot', () => { + const res = analyzeSvelteMarkup(`
          {#each rows as r, i}
        • {i}: {r.name}
        • {/each}
        `, parse); + assert.equal(res.ok, true, res.reason); + const item = res.contract.find((c) => c.kind === 'collection').item; + assert.deepEqual(item.textSlots.map((s) => s.key), ['name']); + }); + + it('style directives mixing loop and outer names fall back', () => { + const res = analyzeSvelteMarkup(`
          {#each rows as r}
        • x
        • {/each}
        `, parse); + assert.equal(res.ok, false); + assert.match(res.reason, /mixing loop and outer identifiers/); + }); +}); From b9c1d86d6880c3cc474117ffe336ca87b972f931 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 15:57:33 -0700 Subject: [PATCH 24/29] fix: reject a valueless --target instead of falling back to implicit selection A trailing --target, an empty --target=, or --target followed by another flag used to degrade into implicit root selection, letting a mutating helper (poll, accept, complete) act on the most recent live app instead of the one the caller tried to name. consumeTargetArg now throws on those shapes and enterLiveRoot exits with a clear error before any session state can be touched. Unit tests cover the malformed shapes and a subprocess test proves the helper body never runs. AI-assisted (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 26 ++++++++++++++++++----- tests/live-roots.test.mjs | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index 3c0a32e9e..a34b68c1e 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -442,13 +442,22 @@ export function resolveLiveRoots(cwd = process.cwd(), { targetPath = null } = {} export function consumeTargetArg(argv = process.argv) { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === '--target' && typeof argv[i + 1] === 'string') { + if (arg === '--target') { const value = argv[i + 1]; + // A --target with no usable value must not degrade into implicit root + // selection: these helpers mutate session state, and "the most recent + // app" is exactly what the caller was trying NOT to get. + if (typeof value !== 'string' || value === '' || value.startsWith('--')) { + throw new Error('--target requires a path value (use --target or --target=)'); + } argv.splice(i, 2); return value; } if (typeof arg === 'string' && arg.startsWith('--target=')) { const value = arg.slice('--target='.length); + if (value === '') { + throw new Error('--target requires a path value (use --target or --target=)'); + } argv.splice(i, 1); return value; } @@ -462,12 +471,19 @@ export function consumeTargetArg(argv = process.argv) { * with the boot. An explicit `--target ` on the helper's command line * overrides pointer resolution, which is what disambiguates a repo with * several live apps (the multi-app warning names this escape hatch, so it - * has to actually work on every helper). Returns the manifest. Never - * throws; on selection ambiguity it stays in the current directory (the - * boot flow handles prompting). + * has to actually work on every helper). Returns the manifest. On selection + * ambiguity it stays in the current directory (the boot flow handles + * prompting); a malformed --target exits with an error instead of silently + * falling back to implicit selection, which could mutate the wrong app. */ export function enterLiveRoot(cwd = process.cwd()) { - const targetPath = consumeTargetArg(process.argv); + let targetPath; + try { + targetPath = consumeTargetArg(process.argv); + } catch (err) { + console.error(`[impeccable live] ${err.message}`); + process.exit(1); + } const resolved = resolveLiveRoots(cwd, targetPath ? { targetPath } : {}); if (!resolved.manifest) return null; const appRoot = resolved.manifest.appRoot; diff --git a/tests/live-roots.test.mjs b/tests/live-roots.test.mjs index 3a5704215..2f5f8d127 100644 --- a/tests/live-roots.test.mjs +++ b/tests/live-roots.test.mjs @@ -6,6 +6,7 @@ import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { + consumeTargetArg, discoverAppCandidates, findGitRoot, resolveLiveRoots, @@ -322,6 +323,45 @@ describe('review regressions: helper --target', () => { rmSync(repo, { recursive: true, force: true }); } }); + + it('rejects a --target with no usable value instead of falling back to implicit selection', () => { + for (const argv of [ + ['node', 'live-complete.mjs', '--target'], + ['node', 'live-complete.mjs', '--target='], + ['node', 'live-complete.mjs', '--target', '--id'], + ]) { + assert.throws(() => consumeTargetArg([...argv]), /--target requires a path value/); + } + // Well-formed values still parse and are consumed. + const argv = ['node', 'live-complete.mjs', '--target', 'appB', '--id', 'x']; + assert.equal(consumeTargetArg(argv), 'appB'); + assert.deepEqual(argv, ['node', 'live-complete.mjs', '--id', 'x']); + }); + + it('enterLiveRoot exits with an error on a valueless --target rather than picking an app', () => { + const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-target-bad-'))); + try { + mkdirSync(join(repo, '.git'), { recursive: true }); + write(repo, 'appA/vite.config.js', 'export default {};'); + const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'appA/vite.config.js') }).manifest; + writeRootsManifest(a); + write(repo, 'appA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: 1, token: 't' })); + + const res = spawnSync(process.execPath, [ + '-e', + `import(${JSON.stringify(ROOTS_MODULE)}).then((m) => { + process.argv.push('--target'); + m.enterLiveRoot(); + console.log('reached:' + process.cwd()); + });`, + ], { cwd: repo, encoding: 'utf-8' }); + assert.notEqual(res.status, 0, 'malformed --target must not proceed'); + assert.match(res.stderr, /--target requires a path value/); + assert.doesNotMatch(res.stdout, /reached:/, 'helper body must not run'); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); describe('review regressions: pid reuse', () => { From 69456364b288ab5cf526a1c2432aa1eb732bcfc2 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 15:57:33 -0700 Subject: [PATCH 25/29] fix: self-discard orphaned variant sessions instead of freezing the picker Fixes #439. When a cycling session is abandoned and the wrapped region is then edited or regenerated out of the source file, the resumed page used to sit in GENERATING forever with the picker disarmed; the only recovery was a manual live-complete --discarded. Now a resumed CYCLING session whose wrapper cannot be found in source retries the read a few times (HMR or an agent write may be mid-flight), then discards itself, clears local state, and re-arms the picker with a toast. GENERATING restores are exempt: deferred-wrapper flows legitimately have no wrapper in source until the agent's write lands. The browser tags the discard event orphaned:true; the server terminalizes that session directly (phase discarded) and keeps the event out of the agent poll queue, since there is no source cleanup left to perform and the normal discard flow would just fail against the missing scaffolding. New e2e scenario on vite8-react-plain drives the full repro: cycle, revert source externally, reload, assert self-discard, terminal durable phase, and a working picker afterward. AI-assisted (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live-browser.js | 44 ++++++++++++- skill/scripts/live-server.mjs | 14 +++- .../vite8-react-plain/fixture.json | 45 +++++++++++-- tests/live-e2e.test.mjs | 66 +++++++++++++++++++ 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index b32a4fac2..2a4d50def 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6169,6 +6169,22 @@ const COMPLETED_SOURCE_FALLBACK_RETRIES = 3; const COMPLETED_SOURCE_FALLBACK_RETRY_MS = 1200; + /** + * Terminal recovery for a session whose source-side scaffolding no longer + * exists. The discard event is best-effort: with no agent polling it parks + * the durable session in discard_requested, which no resume path adopts; + * with an agent attached it triggers the normal discard finalization. + */ + function discardOrphanedSession(reason) { + const sessionId = currentSessionId; + if (!sessionId) return; + console.warn('[impeccable] Discarding orphaned session ' + sessionId + ': ' + reason); + sendEvent({ type: 'discard', id: sessionId, orphaned: true }).catch(() => {}); + markSessionHandled(); + cleanup({ instantChrome: true }); + showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000); + } + /** * No-HMR fallback: fetch the raw source file from the live server, * parse it, extract the variant wrapper, and inject it into the live DOM. @@ -6205,6 +6221,25 @@ srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { console.warn('[impeccable] Variant wrapper not found in source file.'); + // A resumed cycling session whose wrapper is gone from source is an + // ORPHAN: the file was edited or regenerated out from under it, so + // no reload, HMR push, or server restart can ever complete it, and + // the frozen picker it leaves behind used to need a manual + // live-complete --discarded. Retry a few reads first (an agent + // rewrite or HMR patch may be mid-flight), then self-discard and + // hand the surface back to the picker. + if (opts.orphanDiscard && sessionId === currentSessionId) { + const attempt = opts._orphanAttempt || 0; + if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { + setTimeout(() => { + if (sessionId !== currentSessionId) return; + if (state !== 'GENERATING' && state !== 'CYCLING') return; + injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 }); + }, COMPLETED_SOURCE_FALLBACK_RETRY_MS); + } else { + discardOrphanedSession('variant wrapper missing from source'); + } + } return; } @@ -8802,7 +8837,14 @@ void main() { ? currentPreviewFile : (currentSourceFile || currentPreviewFile); if (restoreFile) { - injectVariantsFromSource(restoreFile, currentSessionId); + // A restored CYCLING session promises variants already written into + // source; if they are not there (after retries), the session is an + // orphan and must self-discard instead of freezing the picker (#439). + // GENERATING restores make no such promise: deferred-wrapper flows + // legitimately have no wrapper in source until the agent's write lands. + injectVariantsFromSource(restoreFile, currentSessionId, { + orphanDiscard: savedState === 'CYCLING' && !isFrameworkComponentPreviewMode(currentPreviewMode), + }); return true; } diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 62929ed90..4f0381fbc 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -1038,13 +1038,25 @@ function createRequestHandler({ detectScript, liveScriptParts }) { if (msg.type === 'exit') { cleanupSvelteComponentSessionsBeforeExit(); } + // An ORPHANED discard is the browser reporting that the session's + // wrapper no longer exists in source (edited or regenerated away). + // There is no cleanup for an agent to perform, and asking one to run + // the normal discard flow would just fail against the missing + // scaffolding, so the server terminalizes the session itself and the + // event stays out of the poll queue. + const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true; + if (orphanedDiscard && state.sessionStore && msg.id) { + try { + state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true }); + } catch { /* the discard_requested phase already left the resumable set */ } + } // `variant_mounted` is the happy path: it is journaled above so the // snapshot carries render truth, but there is nothing for the agent to // do about it, so it stays out of the poll queue and off the SSE bus. // `variant_mount_failed` is the opposite: the agent published something // the browser could not render, and only the agent can fix it, so it // goes to the queue as a first-class event. - if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') { + if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted' && !orphanedDiscard) { enqueueEvent(msg); } res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/tests/framework-fixtures/vite8-react-plain/fixture.json b/tests/framework-fixtures/vite8-react-plain/fixture.json index 69074a9f1..e2bda75de 100644 --- a/tests/framework-fixtures/vite8-react-plain/fixture.json +++ b/tests/framework-fixtures/vite8-react-plain/fixture.json @@ -1,23 +1,45 @@ { "name": "Vite 8 + React + plain CSS", "config": { - "files": ["index.html"], + "files": [ + "index.html" + ], "insertBefore": "", "commentSyntax": "html" }, - "sourceFiles": ["index.html", "src/App.jsx", "src/main.jsx", "src/styles.css", "vite.config.js"], + "sourceFiles": [ + "index.html", + "src/App.jsx", + "src/main.jsx", + "src/styles.css", + "vite.config.js" + ], "generatedFiles": [], "wrapCases": [ { "name": "wraps hero title in source JSX", - "args": { "classes": "hero-title", "tag": "h1" }, + "args": { + "classes": "hero-title", + "tag": "h1" + }, "expectedFile": "src/App.jsx" } ], "runtime": { "styling": "plain-css", - "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], - "devCommand": ["npx", "vite", "--host", "127.0.0.1"], + "install": [ + "npm", + "install", + "--no-audit", + "--no-fund", + "--loglevel=error" + ], + "devCommand": [ + "npx", + "vite", + "--host", + "127.0.0.1" + ], "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", "readyTimeoutMs": 120000, "probe": { @@ -33,7 +55,13 @@ "manualEditScenarios": [ { "name": "React headless manual Apply hard batch", - "element": { "selector": "main.page", "position": { "x": 4, "y": 4 } }, + "element": { + "selector": "main.page", + "position": { + "x": 4, + "y": 4 + } + }, "applyTimeoutMs": 300000, "refreshAfterApply": true, "expectApplyLoading": true, @@ -148,6 +176,9 @@ ], "expectedStashCount": 13 } - ] + ], + "orphanedWrapperScenario": { + "sourceFile": "src/App.jsx" + } } } diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index 24e0f0c12..65320db81 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -1058,6 +1058,72 @@ for (const { name, fixture } of fixtures) { }); } + if (shouldRunScenario('orphan') && fixture.runtime.orphanedWrapperScenario) { + it('self-discards an orphaned session when its wrapper is edited out of source', liveE2eTestOptions, async (t) => { + if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) { + t.skip('manual scenario filter is active'); + return; + } + // Repro of issue #439: a cycling session is abandoned (no Accept or + // Discard), the wrapped region is edited out of source, and the page + // reloads. The resumed session used to freeze the picker forever; + // recovery required a manual live-complete --discarded. It must now + // self-discard and hand the surface back to the picker. + const agent = createFakeAgent(); + const session = await bootFixtureSession({ + name, + fixture, + browser, + agent, + wrapTarget: wrapTargetFromPickedElement, + log: (m) => t.diagnostic(m), + }); + const { page, appRoot, teardown } = session; + const cfg = fixture.runtime.orphanedWrapperScenario; + const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; + try { + await waitForHandshake(page); + const sourceFile = join(appRoot, cfg.sourceFile); + const pristine = readFileSync(sourceFile, 'utf-8'); + + await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition }); + await clickGo(page); + await waitForCyclingRobust(page, 3, { timeout: 60_000, log: (m) => t.diagnostic(m) }); + const saved = await readLiveSessionStorage(page); + assert.ok(saved?.id, 'cycling session persisted to local storage'); + + t.diagnostic('Restoring pristine source (simulated external edit that removes the wrapper)'); + writeFileSync(sourceFile, pristine); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForHandshake(page); + + // Resume adopts the cycling session, the source read finds no + // wrapper, retries, then self-discards: local session cleared. + const deadline = Date.now() + 30_000; + for (;;) { + const current = await readLiveSessionStorage(page); + if (!current?.id) break; + if (Date.now() > deadline) throw new Error('orphaned session was never discarded'); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + t.diagnostic('Orphaned session discarded; verifying durable phase + picker rearm'); + + const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${saved.id}.snapshot.json`); + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')); + assert.equal( + snapshot.phase, + 'discarded', + 'an orphaned discard is terminalized server-side without agent involvement', + ); + + // The regression that mattered: the picker must arm again. + await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition }); + } finally { + await teardownAndResetBrowser(teardown); + } + }); + } + if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) { const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || ''; for (const scenario of fixture.runtime.manualEditScenarios) { From a83d767cf91d8783c7d92cdb64d6da0452343495 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 17:38:46 -0700 Subject: [PATCH 26/29] fix: stop cross-project live session leakage and stale-adapter 401s Field session on a nested SvelteKit app surfaced a self-reinforcing leak: localStorage is per-origin, two projects reused 127.0.0.1:5174, and a React project's leftover cycling session was resumed inside the Svelte project. Its checkpoints then materialized a ghost session in the new project's durable store that kept reattaching after every discard, and a stale adapter module 401'd on live.js, hiding the picker. Four fixes: - Server: only session-creating events (generate, steer) may mint a journal. Progress events (checkpoints, mount acks, accept/discard) for unknown ids are refused with 404 unknown_session and never enqueued, so foreign browser state cannot create ghost sessions. Browser sends are gated so progress never overtakes its own creating POST (the Go-time checkpoint and generate are concurrent fetches; the first sweep caught the out-of-order arrival breaking every SvelteKit flow). Steer checkpoints now follow the steer event for the same reason. - Browser: saved sessions carry the server's appRoot; a session stamped by another project is dropped at load time. Unstamped legacy state is caught by the unknown_session refusal, which clears local state and re-arms the picker with an explanatory toast. - SvelteKit adapter: the layout import carries a token-derived revision query so a helper restart changes the module specifier and no Vite client/SSR cache can serve an adapter with a rotated-out token; live-inject --port reads the running helper's token from server.json instead of writing an unauthenticated live.js URL; script load failures log an actionable console error; and adapter removal is byte-exact (the old regex swallowed the next line's indentation). - live.mjs resolves surface briefs from appRoot, then contextRoot, then repoRoot, matching context.mjs in nested-app repos. Tests: server unknown-session rejection units, adapter revision/ byte-exact-removal units, and a foreign-session e2e scenario that seeds another project's localStorage state and asserts it is cleared, no ghost journal materializes, and picking still works. AI-assisted (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live-browser.js | 59 ++++++++++++-- skill/scripts/live-inject.mjs | 14 +++- skill/scripts/live-server.mjs | 21 +++++ skill/scripts/live.mjs | 12 ++- skill/scripts/live/browser-script-parts.mjs | 7 +- skill/scripts/live/session-store.mjs | 12 +++ skill/scripts/live/sveltekit-adapter.mjs | 62 ++++++++++++--- .../vite8-react-plain/fixture.json | 3 +- tests/live-e2e.test.mjs | 76 ++++++++++++++++++ tests/live-frameworks.test.mjs | 60 ++++++++++++++ tests/live-server.test.mjs | 79 +++++++++++++++++++ 11 files changed, 383 insertions(+), 22 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 2a4d50def..9788c211c 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -21,6 +21,7 @@ const TOKEN = window.__IMPECCABLE_TOKEN__; const PORT = window.__IMPECCABLE_PORT__; + const APP_ROOT = window.__IMPECCABLE_APP_ROOT__ || null; if (!TOKEN || !PORT) { window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init return; @@ -7132,6 +7133,13 @@ if (currentSessionId) saveSession(); } + // Progress events must never overtake the event that CREATES their session: + // the Go-time checkpoint and the generate POST are concurrent fetches, and + // when the checkpoint lands first the server rightly refuses it as + // unknown_session — which must mean "foreign leftovers", not "you raced + // your own Go click". The gate serializes creation before progress. + let sessionCreationGate = Promise.resolve(); + function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { @@ -7142,15 +7150,42 @@ console.debug('[impeccable] Dropped optional live event:', err); return null; } - return fetch('http://localhost:' + PORT + '/events', { + const doSend = () => fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), }).then(async res => { if (res.ok) return res; const body = await res.json().catch(() => ({})); + // The server refused to journal progress for a session it has never + // seen: this browser is carrying state from another project or a + // wiped store (two apps sharing a localhost port). Continuing to + // report it would freeze the picker behind a session that can never + // complete, so drop the local state and hand the surface back. + if (body.error === 'unknown_session' && msg.type === 'checkpoint' + && msg.id && msg.id === currentSessionId) { + abandonForeignSession(msg.id); + return null; + } return handleFailure(new Error(body.error || ('HTTP ' + res.status + ' ' + res.statusText))); }).catch(handleFailure); + + if (msg.type === 'generate' || msg.type === 'steer') { + const creation = doSend(); + sessionCreationGate = creation.then(() => {}, () => {}); + return creation; + } + return sessionCreationGate.then(doSend); + } + + let abandonedForeignSessionId = null; + function abandonForeignSession(sessionId) { + if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return; + abandonedForeignSessionId = sessionId; + console.warn('[impeccable] The live server has no record of session ' + sessionId + '; clearing stale local state.'); + markSessionHandled(); + cleanup({ instantChrome: true }); + showToast('A saved live session belonged to a different project, so it was cleared. Pick an element to start fresh.', 6000); } function checkpointPayload(reason) { @@ -8894,6 +8929,7 @@ void main() { // it here would overwrite the Go-time value every time state changes. sessionState.saveSession({ id: currentSessionId, + appRoot: APP_ROOT || undefined, state, action: selectedAction, count: selectedCount, @@ -8915,7 +8951,17 @@ void main() { } function loadSession() { - return sessionState.loadSession(); + const saved = sessionState.loadSession(); + // localStorage is per-origin, and two projects routinely reuse the same + // localhost port. A saved session stamped with another project's appRoot + // is that project's leftover, never a session this server can complete; + // resuming it freezes the picker behind an unfinishable banner. + if (saved?.appRoot && APP_ROOT && saved.appRoot !== APP_ROOT) { + console.warn('[impeccable] Ignoring saved live session from another project (' + saved.appRoot + ').'); + sessionState.clearSession(); + return null; + } + return saved; } function clearSession() { @@ -10205,10 +10251,11 @@ void main() { const id = id8(); steerRequestId = id; steerPendingMessage = text; - if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); - sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); + // Checkpoints follow the steer event, never precede it: the steer event + // is what creates the session journal server-side, and a checkpoint for + // a not-yet-created session is rejected as unknown_session. sendEvent({ type: 'steer', id, @@ -10216,9 +10263,11 @@ void main() { pageUrl: location.href, }).then((res) => { if (!res) { - sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + return; } + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); }); } diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 65455c3f4..81848010b 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -205,9 +205,19 @@ Output (JSON): process.exit(1); } // Optional server token: appended to the /live.js src so the token-gated - // /live.js handler authorizes the browser fetch. `live.mjs` always passes it. + // /live.js handler authorizes the browser fetch. `live.mjs` always passes + // it; a manual `--port`-only invocation reads the running helper's token + // from server.json instead of writing an unauthenticated URL that 401s. const tokenIdx = args.indexOf('--token'); - const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined; + let token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined; + if (!token) { + try { + const info = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'server.json'), 'utf-8')); + // A record for a DIFFERENT port is a stale or foreign helper; its token + // would 401 just the same, so only adopt a matching one. + if (info?.token && Number(info.port) === port) token = info.token; + } catch { /* no running helper recorded; keep legacy tokenless behavior */ } + } // Reconcile before writing anything. Artifacts this run is about to own are // kept (so a repeat inject stays byte-idempotent); artifacts left behind by diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 4f0381fbc..bfad7a245 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -91,6 +91,12 @@ function resolveProjectContext() { } const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// The browser events allowed to mint a NEW session journal. `generate` starts +// a variant session at Go; `steer` mints its own request id. Every other +// id-carrying event must land on an existing session (see the unknown_session +// gate in the /events handler). +const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']); // The browser checkpoints for several unrelated reasons (see checkpointPayload // in live-browser.js). Only these two report that variant availability changed, // and only they may drive variant_progress / the *_reviewable phases. @@ -730,6 +736,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { port: state.port, vocabulary: LIVE_COMMANDS, commandPrefix: IMPECCABLE_COMMAND_PREFIX, + appRoot: process.cwd(), parts, }); res.writeHead(200, { @@ -1020,6 +1027,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) { res.end(JSON.stringify({ ok: true })); return; } + // Only the events that START a session may create its journal. + // Everything else (checkpoints, mount acks, accept/discard) must + // reference a session THIS store already knows: appendEvent creates a + // journal for any id it is handed, so without this gate a browser + // resuming another project's session from per-origin storage (two + // apps sharing a localhost port) materializes a ghost session here + // that keeps reattaching after every discard. + if (msg.id && state.sessionStore + && !SESSION_CREATING_EVENT_TYPES.has(msg.type) + && !state.sessionStore.has(msg.id)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unknown_session', id: msg.id })); + return; + } const missedCompletion = detectMissedGenerationCompletion(msg); if (state.sessionStore && msg.id) { try { diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 306de3cae..b04d98f50 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -169,12 +169,20 @@ The agent should then: let surfaceBrief = null; let surfaceBriefPath = null; try { - const resolvedBrief = resolveSurfaceBrief(roots.appRoot, liveTarget.absoluteTargetPath || null); - if (resolvedBrief?.brief) { + // Briefs live under .impeccable/surfaces, which in a nested-app repo sits + // at the CONTEXT or repo root, not the app root; context.mjs already finds + // them there, and live must not report "no brief" for the same project. + const briefRoots = [roots.appRoot, roots.contextRoot, roots.repoRoot] + .filter(Boolean) + .filter((dir, i, arr) => arr.findIndex((other) => path.resolve(other) === path.resolve(dir)) === i); + for (const briefRoot of briefRoots) { + const resolvedBrief = resolveSurfaceBrief(briefRoot, liveTarget.absoluteTargetPath || null); + if (!resolvedBrief?.brief) continue; surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path); surfaceBriefPath = resolvedBrief.brief.path ? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path) : null; + break; } } catch { /* briefs are optional context */ } console.log(JSON.stringify({ diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs index b77f6a542..5925136fb 100644 --- a/skill/scripts/live/browser-script-parts.mjs +++ b/skill/scripts/live/browser-script-parts.mjs @@ -32,10 +32,15 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re })); } -export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) { +export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + `window.__IMPECCABLE_PORT__ = ${port};\n` + + // Project identity for browser-side session storage. localStorage is + // keyed by ORIGIN, and two projects routinely share a localhost port + // across time; saved sessions carry this value so a resume can tell a + // foreign project's leftovers from its own. + `window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` + `window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` + // Canonical command vocabulary (values + labels + icons). live-browser.js // builds its action picker from this instead of an inline copy. diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs index 0d1715e7c..a017cb157 100644 --- a/skill/scripts/live/session-store.mjs +++ b/skill/scripts/live/session-store.mjs @@ -119,6 +119,18 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) persist(normalized.id, next, prior.nextSeq + 1); return next; }, + /** + * True when a journal exists for the id in either root. appendEvent + * CREATES a journal for any id it is handed, so callers that should only + * ever touch existing sessions (browser checkpoints, mount acks) check + * here first — otherwise a stale id from another project's browser + * storage materializes a ghost session in this store. + */ + has(id) { + if (!id || typeof id !== 'string') return false; + return fs.existsSync(getJournalPath(rootDir, id)) + || fs.existsSync(getJournalPath(legacyRootDir, id)); + }, /** * Read-only. `live-status` and `live-resume` call this against a session a * running server owns; writing the snapshot file here made every read a diff --git a/skill/scripts/live/sveltekit-adapter.mjs b/skill/scripts/live/sveltekit-adapter.mjs index 5cec5c5cb..e94c54f1e 100644 --- a/skill/scripts/live/sveltekit-adapter.mjs +++ b/skill/scripts/live/sveltekit-adapter.mjs @@ -7,6 +7,7 @@ * actual live UI remains the shared plain-DOM browser chrome. */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -14,6 +15,28 @@ export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot export const SVELTE_LAYOUT_MARKER_OPEN = ''; export const SVELTE_LAYOUT_MARKER_CLOSE = ''; export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';"; +// Matches the import at ANY revision (or none). [ \t]* bounds only, never +// \s*: a greedy \s* after the statement swallowed the next line's +// indentation on removal, leaving a formatting scar in user layouts. +const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm; + +/** + * The import specifier carries a token-derived revision query. The adapter + * component embeds the helper token, and Vite (client AND SSR) can keep + * serving a stale compiled module after the file is rewritten on a helper + * restart; the browser then requests /live.js with a rotated-out token and + * gets a 401 with no picker. A changed specifier is a different module id, + * which no cache survives. + */ +export function svelteRootImportLine(rev) { + if (!rev) return SVELTE_ROOT_IMPORT; + return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';"; +} + +export function svelteAdapterRev(token) { + if (!token) return null; + return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8); +} export function detectSvelteKitProject(cwd = process.cwd(), config = null) { const appHtml = findSvelteKitAppHtml(cwd, config); @@ -50,7 +73,7 @@ export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, co fs.mkdirSync(path.dirname(layoutAbs), { recursive: true }); const layoutExisted = fs.existsSync(layoutAbs); const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout(); - const after = patchSvelteLayout(before); + const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) }); fs.writeFileSync(layoutAbs, after, 'utf-8'); return { @@ -94,15 +117,27 @@ export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null }; } -export function patchSvelteLayout(content) { +export function patchSvelteLayout(content, { rev = null } = {}) { let out = String(content || ''); - if (!out.includes(SVELTE_ROOT_IMPORT)) { - const scriptMatch = out.match(/]*)?>/i); - if (scriptMatch) { - const insertAt = scriptMatch.index + scriptMatch[0].length; - out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt); - } else { - out = `\n\n` + out; + const importLine = svelteRootImportLine(rev); + if (!out.includes(importLine)) { + // An import at an older revision is replaced in place, keeping its + // indentation; only a layout with no impeccable import gets an insert. + let replaced = false; + out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => { + if (replaced) return ''; + replaced = true; + const indent = (line.match(/^[ \t]*/) || [''])[0]; + return indent + importLine + '\n'; + }); + if (!replaced) { + const scriptMatch = out.match(/]*)?>/i); + if (scriptMatch) { + const insertAt = scriptMatch.index + scriptMatch[0].length; + out = out.slice(0, insertAt) + '\n ' + importLine + out.slice(insertAt); + } else { + out = `\n\n` + out; + } } } @@ -131,8 +166,8 @@ export function unpatchSvelteLayout(content) { 'g', ); out = out.replace(blockRe, '$1'); - out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), ''); - out = out.replace(/ + +{@render children()} +`; + + it('stamps the import with a token-derived revision and swaps it on rotation', () => { + const revA = adapter.svelteAdapterRev('token-a'); + const revB = adapter.svelteAdapterRev('token-b'); + assert.match(revA, /^[0-9a-f]{8}$/); + assert.notEqual(revA, revB, 'a rotated token must change the module specifier'); + + const patchedA = adapter.patchSvelteLayout(LAYOUT, { rev: revA }); + assert.ok(patchedA.includes(`ImpeccableLiveRoot.svelte?impeccable-live=${revA}'`), 'import carries the revision'); + + // A re-apply after helper restart replaces the import IN PLACE: exactly + // one import, at the new revision, same indentation. A stale specifier + // is a cached module with a rotated-out token, which 401s on live.js. + const patchedB = adapter.patchSvelteLayout(patchedA, { rev: revB }); + const importCount = (patchedB.match(/import ImpeccableLiveRoot/g) || []).length; + assert.equal(importCount, 1, 'rotation must not stack imports'); + assert.ok(patchedB.includes(`?impeccable-live=${revB}'`)); + assert.ok(!patchedB.includes(`?impeccable-live=${revA}'`)); + assert.match(patchedB, /\n import ImpeccableLiveRoot/, 'replacement keeps the original indentation'); + }); + + it('removal restores the layout byte-for-byte, including neighbor indentation', () => { + // The field failure: the old removal regex used \s* and swallowed the + // NEXT line's indentation, de-indenting the user's stylesheet import. + for (const rev of [null, adapter.svelteAdapterRev('some-token')]) { + const patched = adapter.patchSvelteLayout(LAYOUT, { rev }); + assert.notEqual(patched, LAYOUT, 'patch must change the layout'); + const restored = adapter.unpatchSvelteLayout(patched); + assert.equal(restored, LAYOUT, `removal must be byte-exact (rev=${rev})`); + } + }); + + it('removal of a created-from-scratch layout leaves no script husk', () => { + const patched = adapter.patchSvelteLayout('', { rev: adapter.svelteAdapterRev('t') }); + const restored = adapter.unpatchSvelteLayout(patched); + assert.doesNotMatch(restored, /ImpeccableLiveRoot|impeccable-live-svelte/); + assert.doesNotMatch(restored, / + +
        +
        Intro copy stays here.
        +
          + {#each items as item} +
        • {item.name}
        • + {/each} +
        +
        + + +`; + + it('keeps a superseded selector whose class is still used outside the replaced region', () => { + const tmp4 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-shared-class-'))); + try { + mkdirSync(join(tmp4, 'node_modules'), { recursive: true }); + try { + symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp4, 'node_modules', 'svelte'), 'dir'); + } catch { + cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp4, 'node_modules', 'svelte'), { recursive: true }); + } + write(tmp4, 'package.json', JSON.stringify({ name: 'app' })); + write(tmp4, 'src/lib/Shared.svelte', SHARED_SOURCE); + + // Pick the
          block. Its items use .card, and so does + // the intro div OUTSIDE the pick. + const lines = SHARED_SOURCE.split('\n'); + const startLine = lines.findIndex((l) => l.includes('class="list"')) + 1; + const endLine = lines.findIndex((l) => l.trim() === '
        ') + 1; + const originalLines = lines.slice(startLine - 1, endLine); + + const session = scaffoldSvelteComponentSession({ + id: 'shared01', + count: 1, + sourceFile: 'src/lib/Shared.svelte', + sourceStartLine: startLine, + sourceEndLine: endLine, + originalLines, + cwd: tmp4, + }); + assert.equal(session.fallback, undefined, session.reason); + assert.equal(session.manifest.seededSelectors.includes('.card'), true, 'the pick uses .card, so it seeds'); + + // The variant re-declares .list but NOT .card. + write(tmp4, join(session.componentDir, 'v1.svelte'), ` + +
          + {#each items as item} +
        • {item.name}
        • + {/each} +
        + + +`); + const manifest = findSvelteComponentManifest('shared01', tmp4); + const result = inlineSvelteComponentAccept(manifest, 1, null, tmp4); + assert.equal(result.handled, true, result.error); + const out = readFileSync(join(tmp4, 'src/lib/Shared.svelte'), 'utf-8'); + + // .card is shared with the intro div outside the replaced region: + // removing it would strip styling from markup this accept never + // touched, so it must survive despite not being re-declared. + assert.match(out, /\.card \{ border: 1px solid #999; border-radius: 8px; \}/); + assert.equal(result.css.superseded.includes('.card'), false); + // The re-declared .list took the variant's shape. + assert.match(out, /\.list \{ display: flex/); + } finally { + rmSync(tmp4, { recursive: true, force: true }); + } + }); +}); From 0c18cbc9ef36f39f7c906ed02cfd37f2bf77d49d Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 18:05:41 -0700 Subject: [PATCH 28/29] fix: stop treating the child combinator as a prelude boundary when pruning removeSelectorAt walked backward to find the rule prelude and stopped at any '>', added so the walk would not escape past the `; + const { source, removed } = pruneUnusedSelectors(component, compile); + assert.deepEqual(removed, ['.orphan']); + assert.match(source, /\.wrap > \.item \{ font-weight: bold; \}/); + assert.match(source, /\.wrap \{ padding: 4px; \}/); + const { warnings } = compile(source, { generate: false }); + assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []); + }); + + it('removes a fully unused combinator rule without leaving a dangling fragment', () => { + // The corruption shape: after a mid-prelude cut, every remaining fragment + // equals the flagged selector, so the whole-rule branch deleted from the + // cut point and left `.wrap >` dangling in source. + const component = `

        x

        \n`; + const { source } = pruneUnusedSelectors(component, compile); + assert.doesNotMatch(source, /\.orphan/); + assert.doesNotMatch(source, /\.wrap >\s*\{/, 'no dangling combinator fragment'); + assert.doesNotMatch(source, /\.wrap >\s*$/m, 'no dangling combinator line'); + assert.match(source, /\.wrap \{ padding: 4px; \}/); + const { warnings } = compile(source, { generate: false }); + assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []); + }); }); describe('postcondition scanner', () => { From 6c7f7b5cc001848e1ce32be57be206dc2adcd434 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 28 Jul 2026 18:17:50 -0700 Subject: [PATCH 29/29] fix: scope each keys during restore and fail loudly on an unenterable app root Two review findings: - restoreSvelteMarkup visited an {#each} key with outer scopes only, so a contract prop sharing a loop binding name rewrote the key: with prop name -> user.name and loop context "name", the key (name.id) became (user.name.id) in the accepted route. The key evaluates per item, so it is now visited with the loop context and index bound. Regression test verified failing on the previous code. - enterLiveRoot silently kept the ambient working directory when the resolved appRoot no longer existed or chdir failed, letting a helper derive server, session, and source paths from the wrong project. Both cases now exit with a clear error naming the app root and the --target escape hatch. AI-assisted (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 18 ++++++++++++++++-- skill/scripts/live/svelte-ast.mjs | 5 ++++- tests/live-svelte-ast.test.mjs | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index a34b68c1e..1e27d9a6c 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -487,8 +487,22 @@ export function enterLiveRoot(cwd = process.cwd()) { const resolved = resolveLiveRoots(cwd, targetPath ? { targetPath } : {}); if (!resolved.manifest) return null; const appRoot = resolved.manifest.appRoot; - if (path.resolve(cwd) !== path.resolve(appRoot) && isDir(appRoot)) { - try { process.chdir(appRoot); } catch { /* keep current cwd */ } + if (path.resolve(cwd) !== path.resolve(appRoot)) { + // Failing to land on the resolved appRoot must be fatal: a helper that + // silently keeps its ambient cwd derives server, session, and source + // paths from a different project and mutates the wrong state. A manifest + // pointing at a deleted directory is stale ambient truth, not a reason + // to guess. + if (!isDir(appRoot)) { + console.error(`[impeccable live] resolved app root does not exist: ${appRoot} (stale roots manifest? re-run the live boot, or pass --target )`); + process.exit(1); + } + try { + process.chdir(appRoot); + } catch (err) { + console.error(`[impeccable live] could not enter app root ${appRoot}: ${err.message}`); + process.exit(1); + } } return resolved.manifest; } diff --git a/skill/scripts/live/svelte-ast.mjs b/skill/scripts/live/svelte-ast.mjs index 72733cbfd..06e18b62e 100644 --- a/skill/scripts/live/svelte-ast.mjs +++ b/skill/scripts/live/svelte-ast.mjs @@ -844,10 +844,13 @@ export function restoreSvelteMarkup(markup, contract, parse) { break; case 'EachBlock': { visitExpr(node.expression, nextScopes); - if (node.key) visitExpr(node.key, nextScopes); const bound = new Set(); if (node.context) collectPatternNames(node.context, bound); if (node.index) bound.add(node.index); + // The key evaluates per item, so the loop context and index are in + // scope there. Visiting it with outer scopes only let a contract + // prop that shares a loop binding's name rewrite the key. + if (node.key) visitExpr(node.key, [...nextScopes, bound]); walk(node.body, [...nextScopes, bound]); if (node.fallback) walk(node.fallback, nextScopes); break; diff --git a/tests/live-svelte-ast.test.mjs b/tests/live-svelte-ast.test.mjs index 31f0bd020..736cf5a80 100644 --- a/tests/live-svelte-ast.test.mjs +++ b/tests/live-svelte-ast.test.mjs @@ -294,3 +294,25 @@ describe('review regressions: attribute slots and hydration honesty', () => { assert.match(res.reason, /mixing loop and outer identifiers/); }); }); + +describe('review regressions: each-key restore scoping', () => { + it('leaves a key that reads the loop binding alone when a prop shares its name', () => { + // The corruption shape: prop `name` maps back to `user.name`, and the + // loop context is ALSO called `name`. The key evaluates per item, so its + // `name` is the loop binding, never the prop; restoring it used to write + // `(user.name.id)` into the route. + const contract = [{ prop: 'name', expr: 'user.name', kind: 'text' }]; + const markup = `

        {name}

        +
          + {#each people as name (name.id)} +
        • {name.first}
        • + {/each} +
        `; + const restored = restoreSvelteMarkup(markup, contract, parse); + assert.equal(restored.ok, true, restored.reason); + assert.match(restored.markup, /

        \{user\.name\}<\/p>/, 'free usage restores to the expression'); + assert.match(restored.markup, /\(name\.id\)/, 'the key keeps the loop binding'); + assert.doesNotMatch(restored.markup, /\(user\.name\.id\)/); + assert.match(restored.markup, /\{name\.first\}/, 'the body keeps the loop binding'); + }); +});