Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 1098879103 Fix: skip recoverEmptyCycling in injectVariantsFromSource when GENERATING
Server-side preflight writes the scaffold to source before variants are
ready, triggering a full Astro page reload via HMR. After reload the
browser calls injectVariantsFromSource, finds the empty scaffold wrapper
(no variants yet), and calls recoverEmptyCycling which destroys the
session. When the agent then writes real variants and posts done, there
is no active session to receive it, so the browser never enters CYCLING.

Fix: in GENERATING state, an empty wrapper is expected (the agent is
still writing variants). Return early without touching the session so the
next HMR or done SSE can deliver the full variant set.

Fixes the intermittent astro-vite7 CYCLING timeout in live-e2e smoke.
2026-07-18 21:50:14 +00:00
copilot-swe-agent[bot]andGitHub a3d7009e33 Initial plan 2026-07-18 21:19:43 +00:00
Paul Bakaus e0afd4fcea Drop the progressive benchmark, remove dead wrap scaffolding
Review fallout from removing progressive publication.

The Live benchmark existed to compare atomic against progressive delivery:
compareModelBackedReports measures goToFirstVariantMs improvement of one
over the other. With progressive gone it measures nothing against nothing.
Worse, benchmark-live.mjs still passed `progressive` to bootFixtureSession,
which no longer accepts it, so `--delivery progressive` was silently
ignored and would have emitted reports labeled progressive that actually
ran atomic. Silent wrong data is worse than a crash. It was built for
progressive, so it goes with progressive: benchmark-live.mjs, its lib, its
test, and the bench:live script. If an atomic latency baseline is wanted
later, that is a smaller thing built on purpose.

live-wrap.mjs: sourceOriginalLines was assigned and never read.

Both found by review bots on #381 (Copilot).

Assisted-by: Claude Code
2026-07-18 14:11:07 -07:00
Paul Bakaus 3600edc5e9 Live: polling rework, source locks, preflight scaffolding, Vue previews
Carved out of #371, minus progressive publication. Everything here works
against real project source the way main's Live already does: the agent
writes variants into the file the browser loaded, HMR fires, Accept
promotes and carbonizes. Nothing is staged anywhere.

Poll lanes. Events now carry an explicit priority: accept/discard/exit
ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A
long generate can no longer sit in front of the Accept the user just
clicked. leaseEvent claims its lease before awaiting, so a slow prepare
cannot hand the same event to two pollers.

Source locks. A per-file mutex around every accept and discard path, keyed
on a digest of the absolute path. Staleness is decided by owner-pid
liveness rather than mtime, so a wedged lock clears when its owner dies
instead of after an arbitrary timeout, and a slow-but-live accept is never
stolen from. Only the owning process can release a lock.

Preflight scaffolding. The server runs live-wrap (or live-insert) before
the poll returns and hands the result back as event.scaffold. That walk is
measured at ~7.6s on a large repo; moving it off the agent's critical path
removes a deterministic tool round trip without touching the generated
design. Falls back cleanly to the agent running the helper itself.

Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching
the existing Svelte component path: variants compile as real SFCs from a
dev-only directory so the route is never rewritten during generation, and
Vite mounts them without invalidating page state. Accept is the only route
write. Includes a Vue attr tokenizer that normalizes shorthand bindings
(@x, :x, #x) to their canonical forms.

Accept hardening. Every thrown failure now returns mode: 'error' rather
than an ambiguous unhandled result, so a real failure is never classified
as a deliberate manual handoff and silently dropped. The marker search
skips node_modules/.git/dist/build/.impeccable.

Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs.

Assisted-by: Claude Code
2026-07-18 14:11:07 -07:00
Paul BakausandClaude 97dbaad4a4 Flag inset stripes written with the two-length box-shadow form
box-shadow takes <length>{2,4}: only the two offsets are required, so
`inset 4px 0 red` is valid and paints the same single-edge stripe as
`inset 4px 0 0 red`. The scan demanded a third length, so the short form was
silently missed.

Blur and spread now default to 0 when omitted, which is exactly the stripe shape
the rule looks for. The neutral-color and blur/spread exclusions still hold:
`inset 4px 0 #000` and `inset 4px 0 5px var(--brand-accent)` both pass. Fixture
covers both orders of the short form plus those two exclusions, and fails against
the previous regex.

Third false negative found in this rule (after trailing `inset` and literal
neutral colors), all from the same cause: the scan was written against one
spelling of the syntax rather than the grammar.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 12:24:16 -07:00
38 changed files with 69 additions and 3356 deletions
-9
View File
@@ -71,15 +71,6 @@
],
"createdAt": "2026-06-15T23:37:38.170Z",
"reason": "Generic slop card intentionally uses Inter for the before-state comparison"
},
{
"rule": "design-system-font-size",
"value": "*",
"files": [
"skill/scripts/live-browser.js"
],
"createdAt": "2026-07-17T00:00:00.000Z",
"reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget"
}
]
},
-2
View File
@@ -67,8 +67,6 @@ Conventions: wrap the identifying heading text in straight double quotes inside
Recent history favors short, imperative subjects such as `Fix: ...`, `Add ...`, `Improve ...`, or `Bump ...`. Keep commits focused and explain the user-facing impact when it is not obvious. PRs should summarize what changed, list validation performed, and call out whether generated provider output was intentionally omitted or intentionally refreshed. Include screenshots for visible `site/` changes and mention affected providers when transform behavior changes.
**Do not bump manifest versions or add changelog entries in a feature PR.** Bumping is a release step: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened yet. Land the code; the maintainer bumps `package.json` / `.claude-plugin/plugin.json` / `extension/manifest.json` and writes `site/pages/changelog.astro` when cutting the release (see **Releases**). The only PR that touches a manifest version is one whose purpose is the release itself.
## Contributing, Issue, and PR Guidelines
This repo is issue-first for outside contributions. If you are not `pbakaus` or `abdulwahabone`, do not open a PR unless a maintainer has first discussed the change in an issue and asked for, or clearly approved, a PR. For unapproved work, open an issue or comment on an existing issue and wait for maintainer direction. Unsolicited PRs may be closed without review.
+1 -5
View File
@@ -68,9 +68,7 @@ Editorial brief is at `docs/STYLE.md`. Read it before editing the homepage, sub-
The build's `validateProse` step (in `scripts/build.js`) enforces a denylist: em dashes (`—` and HTML entities), the `--` em-dash substitute, `load-bearing`, `highest-leverage`, `biggest unlock`, `seamless`, `robust`, `delve`, `elevate`, `empower`, `underscore`, `pivotal`, `tapestry`, `data-driven`, `reflex defaults`, `collapses into monoculture`, `in today's`, `gone are the days`, `whether you're`, `let's dive in`, `in summary`, `in conclusion`, `moreover`, `furthermore`. Each rule prints a rationale and a suggested replacement when it fires. **Do not silently work around the regex.** If a banned word has earned a real meaning here, raise it as a `docs/STYLE.md` amendment.
`validateProse` scans `site/components/`, `site/content/`, `site/layouts/`, `site/pages/`, `README.md`, `README.npm.md` (extensions `.html`, `.md`, `.js`, `.mjs`, `.css`, `.astro`). It exempts `site/pages/slop/`, because the slop catalog documents every anti-pattern by example and has to contain the specimens.
**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not `skill/scripts/**` code or comments) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `skill/scripts/*.mjs` code comment does not.
The validator scans `site/pages/`, `site/content/`, `site/components/`, `site/layouts/`, `README.md`, `README.npm.md`. It deliberately skips `skill/` because LLM-facing reference instructions sometimes need technical phrasings the marketing copy can't.
The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass.
@@ -250,8 +248,6 @@ bun run build:browser
## Versioning
**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself.
There are three independently versioned components. Only bump the one(s) that actually changed:
**CLI** (npm package):
+1 -3
View File
@@ -226,14 +226,12 @@ function addValue(cwd, args) {
if (parsed.reason) existing.reason = parsed.reason;
if (parsed.files.length) existing.files = parsed.files;
} else {
// rule, value, files, createdAt, reason — the same order the normalizers emit,
// so a fresh entry survives the next write untouched.
const entry = {
rule: parsed.rule,
value: parsed.value,
createdAt: new Date().toISOString(),
};
if (parsed.files.length) entry.files = parsed.files;
entry.createdAt = new Date().toISOString();
if (parsed.reason) entry.reason = parsed.reason;
config.ignoreValues.push(entry);
}
+17 -121
View File
@@ -43,48 +43,23 @@ function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
// CSS named colors whose channels are equal (achromatic). Anything outside
// this set falls through to the format parsers, and an unrecognized spelling
// stays non-neutral so a real accent is never skipped.
const NEUTRAL_COLOR_KEYWORDS = new Set([
'transparent', 'currentcolor',
'black', 'white', 'gray', 'grey', 'silver',
'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey',
'gainsboro', 'whitesmoke',
]);
function hexChannels(color) {
const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i);
if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)];
const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i);
if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16));
return null;
}
/**
* Neutrality test for colors as written in source CSS.
*
* shared/color.mjs's isNeutralColor only parses the computed function forms a
* browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every
* other spelling as chromatic so an unknown format is never silently skipped.
* That default is wrong for authored CSS, where `#000` and `black` are the
* normal spellings: calling it directly reports a plain black hairline as a
* colored stripe. Handle hex and named neutrals here, then defer.
*/
function isNeutralAuthoredColor(rawColor) {
const c = String(rawColor || '').trim().toLowerCase();
if (!c) return false;
if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const channels = hexChannels(c);
if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30;
return false;
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
return isNeutralAuthoredColor(m[1]);
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
@@ -370,88 +345,12 @@ const REGEX_ANALYZERS = [
];
// ---------------------------------------------------------------------------
// Structural CSS checks used by source files whose styles are not parsed by
// the static HTML engine.
// ---------------------------------------------------------------------------
const CHROMATIC_SHADOW_TOKEN_RE = /(?:^|-)(?:accent|kinpaku|patina|gold|red|orange|amber|yellow|lime|green|emerald|teal|cyan|blue|indigo|violet|purple|magenta|pink|rose|coral|aqua|mint|burgundy|crimson|scarlet)(?:-|$)/i;
function insetStripeColorIsChromatic(rawColor) {
const color = String(rawColor || '').trim().replace(/\s*!important\s*$/i, '');
if (/^(?:currentcolor|transparent|inherit|unset)$/i.test(color)) return false;
const variable = color.match(/^var\(\s*(--[\w-]+)/i);
if (variable) return CHROMATIC_SHADOW_TOKEN_RE.test(variable[1]);
if (!/^(?:#|rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color\(|[a-z]+$)/i.test(color)) return false;
return !isNeutralAuthoredColor(color);
}
/**
* Blank out comment bodies while preserving every byte offset (and therefore
* every line number) so commented-out CSS is not scanned as live rules.
*/
function blankCssComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '));
}
function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
const content = blankCssComments(rawContent);
const findings = [];
const ruleRe = /([^{};]+)\{([^{}]*)\}/g;
let match;
while ((match = ruleRe.exec(content)) !== null) {
// The selector group is `[^{};]+`, which greedily absorbs the whitespace and
// newlines trailing the previous rule. Advance past that run before deriving
// the line, or every rule after the first reports the preceding line.
const selectorStart = match.index + (match[1].length - match[1].trimStart().length);
const selector = match[1].trim().replace(/\s+/g, ' ');
if (!selector) continue;
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
const width = match[2].match(/(?:^|;)\s*(?:width|inline-size)\s*:\s*(\d+(?:\.\d+)?)px/i);
if (width && Number(width[1]) <= 40) continue;
const declaration = match[2].match(/(?:^|;)\s*box-shadow\s*:\s*([^;]+)/i);
if (!declaration || !/\binset\b/i.test(declaration[1])) continue;
for (const rawLayer of declaration[1].split(/,(?![^(]*\))/)) {
const layer = rawLayer.trim();
// `inset` is order-independent inside a box-shadow layer: `inset 4px 0 0 red`
// and `4px 0 0 red inset` paint the same stripe, and requiring it first
// silently missed the second spelling. Strip it only as a standalone
// keyword, so a color token such as var(--inset-accent) survives intact;
// an unchanged layer had no inset keyword and is not our shape.
const body = layer.replace(/(^|\s)inset(?=\s|$)/i, '$1').trim();
if (body === layer) continue;
const shadow = body.match(/^(-?\d*\.?\d+)(px)?\s+(-?\d*\.?\d+)(px)?\s+(-?\d*\.?\d+)(px)?(?:\s+(-?\d*\.?\d+)(px)?)?\s+(.+)$/i);
if (!shadow) continue;
const x = Number(shadow[1]);
const y = Number(shadow[3]);
const blur = Number(shadow[5]);
const spread = shadow[7] == null ? 0 : Number(shadow[7]);
if ((x !== 0 && !shadow[2]) || (y !== 0 && !shadow[4]) || blur !== 0 || spread !== 0) continue;
const ax = Math.abs(x);
const ay = Math.abs(y);
if (!((ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0))) continue;
if (!insetStripeColorIsChromatic(shadow[9])) continue;
const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
const line = lineOffset + content.slice(0, selectorStart).split('\n').length;
findings.push(finding('side-tab', filePath, `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, line));
break;
}
}
return findings;
}
// ---------------------------------------------------------------------------
// Style block extraction (Astro/Vue/Svelte <style> blocks)
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (ext !== '.astro' && ext !== '.vue' && ext !== '.svelte') return [];
if (ext !== '.vue' && ext !== '.svelte') return [];
const blocks = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let m;
@@ -578,9 +477,8 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'source',
}));
if (cssLike.has(ext)) findings.push(...scanInsetStripeCss(content, filePath));
// Extract and scan <style> blocks from Astro/Vue/Svelte components.
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
@@ -595,7 +493,6 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'style-block',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
}
// Extract and scan CSS-in-JS template literals
@@ -613,7 +510,6 @@ function detectText(content, filePath, options = {}) {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
}
if (options?.designSystem) {
+3 -7
View File
@@ -346,16 +346,12 @@ export function normalizeIgnoreValueEntries(entries) {
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
// Key order is rule, value, files, createdAt, reason and must stay that way:
// normalizing runs on every write, so emitting a different order than the one
// already on disk rewrites every untouched entry and churns the diff. Keep in
// step with normalizeIgnoreValueEntries in skill/scripts/hook-lib.mjs.
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
-1
View File
@@ -68,7 +68,6 @@
"smoke:hooks": "node scripts/smoke-provider-hooks.mjs",
"bench:detector": "node scripts/benchmark-detector.mjs",
"bench:detector:browser": "node scripts/benchmark-detector.mjs --browser",
"bench:live": "node scripts/benchmark-live.mjs",
"audit": "bun audit --audit-level=moderate",
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
"postpack": "cp README.repo.md README.md && rm README.repo.md",
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, positiveIntFlag } from './lib/cli-args.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const iterations = positiveIntFlag(args.iterations, 5);
const fixture = args.fixture ? String(args.fixture) : 'vite8-react-plain';
const metricsFile = path.join(os.tmpdir(), 'impeccable-live-control-' + process.pid + '.jsonl');
try {
for (let index = 0; index < iterations; index += 1) {
execFileSync('bun', ['run', 'test:live-e2e'], {
cwd: root,
stdio: 'ignore',
timeout: 120_000,
env: {
...process.env,
IMPECCABLE_E2E_ONLY: fixture,
IMPECCABLE_E2E_SCENARIOS: 'progressive',
IMPECCABLE_E2E_METRICS_FILE: metricsFile,
},
});
}
const rows = readMetrics(metricsFile);
console.log(JSON.stringify({
fixture,
iterations: rows.length,
measuredAt: new Date().toISOString(),
acceptToPicking: summarize(rows.map((row) => row.acceptToPickingMs)),
nextGoToPickup: summarize(rows.map((row) => row.nextGoToPickupMs)),
samples: rows,
}, null, 2));
} finally {
try { fs.unlinkSync(metricsFile); } catch {}
}
/**
* Read the metrics the e2e run appended. Fail loudly rather than reporting a
* summary of nothing: an absent file means the run never produced a sample, and
* an ENOENT stack or a `{"medianMs": null}` report both read as "measured" when
* nothing was measured at all.
*/
function readMetrics(file) {
let raw;
try {
raw = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
throw new Error(`no metrics were recorded at ${file}. Did the e2e run emit IMPECCABLE_E2E_METRICS_FILE rows?`);
}
const rows = raw.trim().split('\n').filter(Boolean).map((line, index) => {
try {
return JSON.parse(line);
} catch (error) {
throw new Error(`metrics line ${index + 1} is not valid JSON: ${error.message}`);
}
});
if (rows.length === 0) throw new Error(`metrics file ${file} is empty; nothing to summarize`);
return rows;
}
function summarize(values) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
// Distinguish "every sample was missing this metric" from a real measurement.
// percentile() on an empty array reads sorted[-1] and yields NaN, which
// JSON.stringify turns into null and silently passes for a result.
if (sorted.length === 0) return { samples: 0, medianMs: null, p95Ms: null, minMs: null, maxMs: null };
return {
samples: sorted.length,
medianMs: percentile(sorted, 0.5),
p95Ms: percentile(sorted, 0.95),
minMs: sorted[0],
maxMs: sorted.at(-1),
};
}
function percentile(sorted, p) {
const index = (sorted.length - 1) * p;
const lower = Math.floor(index);
const upper = Math.ceil(index);
return Math.round((sorted[lower] * (1 - (index - lower)) + sorted[upper] * (index - lower)) * 100) / 100;
}
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, positiveIntFlag } from './lib/cli-args.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const liveScript = path.join(root, 'skill/scripts/live.mjs');
const serverScript = path.join(root, 'skill/scripts/live-server.mjs');
const args = parseArgs(process.argv.slice(2));
const iterations = positiveIntFlag(args.iterations, 10);
const fixture = args.fixture ? String(args.fixture) : 'vite8-react-plain';
const fixtureDir = path.join(root, 'tests/framework-fixtures', fixture, 'files');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-live-init-'));
try {
fs.cpSync(fixtureDir, tmp, { recursive: true });
fs.writeFileSync(path.join(tmp, 'PRODUCT.md'), '# Product\n\nA realistic Live initialization benchmark fixture.\n');
fs.writeFileSync(path.join(tmp, 'DESIGN.md'), '# Design\n\nUse the fixture\'s existing type, color, and component system.\n');
fs.mkdirSync(path.join(tmp, '.impeccable/live'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.impeccable/live/config.json'), JSON.stringify({
files: ['index.html'],
insertBefore: '</body>',
commentSyntax: 'html',
cspChecked: true,
}, null, 2) + '\n');
const cold = [];
for (let i = 0; i < iterations; i += 1) {
stop();
cold.push(runLive());
}
stop();
runLive();
const warm = [];
for (let i = 0; i < iterations; i += 1) warm.push(runLive());
console.log(JSON.stringify({
fixture,
iterations,
measuredAt: new Date().toISOString(),
cold: summarize(cold),
warm: summarize(warm),
samples: { cold, warm },
}, null, 2));
} finally {
stop();
fs.rmSync(tmp, { recursive: true, force: true });
}
function runLive() {
const start = performance.now();
const stdout = execFileSync(process.execPath, [liveScript], {
cwd: tmp,
encoding: 'utf-8',
timeout: 15_000,
});
const elapsed = performance.now() - start;
const result = JSON.parse(stdout);
if (!result.ok) throw new Error('live init failed: ' + stdout);
return round(elapsed);
}
function stop() {
try {
execFileSync(process.execPath, [serverScript, 'stop'], {
cwd: tmp,
stdio: 'ignore',
timeout: 5_000,
});
} catch {}
}
function summarize(samples) {
const sorted = [...samples].sort((a, b) => a - b);
return {
medianMs: percentile(sorted, 0.5),
p95Ms: percentile(sorted, 0.95),
minMs: sorted[0],
maxMs: sorted.at(-1),
};
}
function percentile(sorted, value) {
if (sorted.length === 1) return sorted[0];
const index = (sorted.length - 1) * value;
const lower = Math.floor(index);
const upper = Math.ceil(index);
const weight = index - lower;
return round(sorted[lower] * (1 - weight) + sorted[upper] * weight);
}
function round(value) {
return Math.round(value * 100) / 100;
}
-284
View File
@@ -1,284 +0,0 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createFakeAgent } from '../tests/live-e2e/agent.mjs';
import { createLlmAgent, resolveLlmAgentConfig } from '../tests/live-e2e/agents/llm-agent.mjs';
import { bootFixtureSession, FIXTURES_DIR } from '../tests/live-e2e/session.mjs';
import {
clickDiscard,
clickGo,
drawAnnotationPinAndStroke,
pickElement,
waitForCycling,
waitForHandshake,
} from '../tests/live-e2e/ui.mjs';
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum } from './lib/cli-args.mjs';
import {
buildInteractionRun,
assembleSplitProgressiveOutput,
createBenchmarkReport,
createTraceRecorder,
mergeBenchmarkReports,
} from './lib/live-benchmark.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const fixtureName = String(args.fixture || 'vite8-react-plain');
const iterations = positiveIntFlag(args.iterations, 5);
const agentMode = resolveEnum(args.agent, ['fake', 'llm'], 'fake', '--agent');
const scenario = resolveEnum(args.scenario, ['plain', 'annotated'], 'plain', '--scenario');
const delivery = resolveEnum(args.delivery, ['atomic', 'progressive'], 'atomic', '--delivery');
const simulatedTailMs = positiveIntFlag(args.simulatedTailMs, 0);
const quiet = boolFlag(args.quiet);
const outputPath = args.output ? resolve(ROOT, String(args.output)) : null;
const fixture = JSON.parse(await readFile(join(FIXTURES_DIR, fixtureName, 'fixture.json'), 'utf-8'));
if (!fixture.runtime) throw new Error(`fixture ${fixtureName} has no runtime configuration`);
if (fixture.runtime.mode === 'insert') throw new Error('live benchmark currently measures replace-mode fixtures only');
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: !boolFlag(args.headed) });
const recorder = createTraceRecorder();
let session;
try {
const agentInfo = await resolveAgent(agentMode, args);
if (delivery === 'progressive' && agentMode === 'llm') {
agentInfo.agent = createSplitProgressiveAgent(agentInfo.agent);
}
session = await bootFixtureSession({
name: fixtureName,
fixture,
browser,
agent: agentInfo.agent,
wrapTarget: wrapTargetFromPickedElement,
trace: recorder.trace,
progressive: delivery === 'progressive',
progressiveDelayMs: delivery === 'progressive' ? simulatedTailMs : 0,
atomicDelayMs: delivery === 'atomic' ? simulatedTailMs : 0,
log: quiet ? () => {} : (message) => process.stderr.write(`[live-bench] ${message}\n`),
});
recorder.mark('setup.handshake.start');
session.page.on('request', (request) => {
if (!request.url().endsWith('/events') || request.method() !== 'POST') return;
let payload;
try { payload = request.postDataJSON(); } catch { return; }
if (payload?.type === 'generate' && payload.id) {
recorder.mark('browser.generate_post', {
id: payload.id,
hasScreenshotPath: typeof payload.screenshotPath === 'string' && payload.screenshotPath.length > 0,
commentCount: Array.isArray(payload.comments) ? payload.comments.length : 0,
strokeCount: Array.isArray(payload.strokes) ? payload.strokes.length : 0,
});
}
});
await waitForHandshake(session.page);
recorder.mark('setup.handshake.end');
await installBrowserTimingProbe(session.page);
const runs = [];
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
for (let iteration = 1; iteration <= iterations; iteration += 1) {
await pickElement(session.page, pickSelector, { resetPickMode: iteration > 1 });
if (scenario === 'annotated') {
await drawAnnotationPinAndStroke(session.page, { comment: 'Benchmark annotation' });
}
await resetBrowserTimingProbe(session.page, iteration);
const goStarted = recorder.mark('ui.go.start', { iteration, scenario });
const firstVariant = waitForFirstVariant(session.page).then(() => {
recorder.mark('browser.first_variant', { iteration, scenario });
});
await clickGo(session.page);
recorder.mark('ui.generating_visible', { iteration, scenario });
await firstVariant;
await waitForCycling(session.page, 3, { timeout: agentMode === 'llm' ? 150_000 : 30_000 });
recorder.mark('browser.all_variants', { iteration, scenario });
const browserTiming = await readBrowserTimingProbe(session.page);
const run = buildInteractionRun(recorder.events, {
iteration,
scenario,
goStartedAt: goStarted.at,
browserTiming,
});
assertScenarioEvidence(run, scenario);
runs.push(run);
if (!quiet) process.stderr.write(formatRun(runs.at(-1)) + '\n');
await clickDiscard(session.page);
await waitForReset(session.page);
}
const report = createBenchmarkReport({
fixture: fixtureName,
agent: agentMode,
provider: agentInfo.provider,
model: agentInfo.model,
scenario,
runs,
events: recorder.events,
harnessProbe: args.harnessProbe || null,
delivery,
promptMode: agentInfo.promptMode,
simulation: simulatedTailMs > 0 ? { remainingGenerationMs: simulatedTailMs } : null,
});
let output = report;
if (outputPath && args.append) {
try {
const existing = JSON.parse(await readFile(outputPath, 'utf-8'));
const previousReports = Array.isArray(existing.reports) ? existing.reports : [existing];
output = mergeBenchmarkReports([...previousReports, report]);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
const json = JSON.stringify(output, null, 2) + '\n';
if (outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, json, 'utf-8');
process.stderr.write(`[live-bench] wrote ${outputPath}\n`);
}
process.stdout.write(json);
} finally {
if (session) await session.teardown();
await browser.close().catch(() => {});
}
async function resolveAgent(mode, options) {
if (mode === 'fake') return { agent: createFakeAgent(), provider: 'deterministic', model: null, promptMode: null };
const config = resolveLlmAgentConfig({
provider: options.provider,
model: options.model,
});
const agent = await createLlmAgent({
config,
includeLiveSpec: false,
log: (message) => process.stderr.write(`[live-bench:llm] ${message}\n`),
});
if (!agent) {
throw new Error(`LLM benchmark provider=${config.provider} requires ${config.requiredEnv}. Pass it in the environment; .env files are not read implicitly.`);
}
return { agent, provider: config.provider, model: config.model, promptMode: 'synthetic-element-contract' };
}
function createSplitProgressiveAgent(agent) {
const firstBySession = new Map();
return {
...agent,
async generateFirstVariant(event, context) {
const first = await agent.generateVariants({
...event,
count: 1,
progressive: { phase: 'first', totalCount: event.count },
}, context);
firstBySession.set(event.id, first);
return first;
},
async generateRemainingVariants(event, context) {
const first = firstBySession.get(event.id) || context.firstOutput;
const remaining = await agent.generateVariants({
...event,
count: event.count,
progressive: {
phase: 'remaining',
totalCount: event.count,
firstVariant: first?.variants?.[0] || null,
omitFirstVariantCss: true,
},
}, context);
firstBySession.delete(event.id);
return assembleSplitProgressiveOutput(first, remaining);
},
};
}
async function waitForFirstVariant(page) {
const handle = await page.waitForFunction(() => {
const wrappers = [...document.querySelectorAll('[data-impeccable-variant]')];
return wrappers.some((element) => element.getAttribute('data-impeccable-variant') !== 'original');
}, undefined, { timeout: 150_000 });
await handle.dispose();
}
async function waitForReset(page) {
await page.waitForFunction(() => !document.querySelector('[data-impeccable-variants]'), undefined, { timeout: 30_000 });
await page.waitForTimeout(100);
}
async function installBrowserTimingProbe(page) {
await page.evaluate(() => {
const state = { iteration: 0, goAt: null, generateAt: null };
window.__IMPECCABLE_LIVE_BENCH_TIMING__ = state;
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
root.addEventListener('click', (event) => {
const button = event.composedPath().find((node) =>
node?.getAttribute?.('aria-label') === 'Generate variants'
);
if (button) state.goAt = performance.now();
}, true);
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init) => {
try {
const url = typeof input === 'string' ? input : input?.url;
if (String(url || '').endsWith('/events') && init?.method === 'POST') {
const payload = typeof init.body === 'string' ? JSON.parse(init.body) : null;
if (payload?.type === 'generate') state.generateAt = performance.now();
}
} catch { /* measurement must never affect Live */ }
return originalFetch(input, init);
};
});
}
async function resetBrowserTimingProbe(page, iteration) {
await page.evaluate((nextIteration) => {
const state = window.__IMPECCABLE_LIVE_BENCH_TIMING__;
if (!state) return;
state.iteration = nextIteration;
state.goAt = null;
state.generateAt = null;
}, iteration);
}
async function readBrowserTimingProbe(page) {
return page.evaluate(() => {
const state = window.__IMPECCABLE_LIVE_BENCH_TIMING__;
return state ? { ...state } : null;
});
}
function assertScenarioEvidence(run, currentScenario) {
const evidence = run.annotationEvidence;
if (currentScenario === 'annotated') {
if (!evidence?.screenshotPath || evidence.comments < 1 || evidence.strokes < 1) {
throw new Error(`iteration ${run.iteration}: annotated generate payload lost screenshot/comments/strokes`);
}
return;
}
if (evidence?.screenshotPath) {
throw new Error(`iteration ${run.iteration}: plain generate payload unexpectedly included screenshotPath`);
}
}
function wrapTargetFromPickedElement(event) {
const element = event.element || {};
return {
elementId: element.id || undefined,
classes: Array.isArray(element.classes) ? element.classes.join(',') : undefined,
tag: element.tagName ? String(element.tagName).toLowerCase() : undefined,
text: element.textContent ? String(element.textContent).trim() : undefined,
};
}
function formatRun(run) {
return `[live-bench] run ${run.iteration}: first=${run.goToFirstVariantMs}ms all=${run.goToAllVariantsMs}ms generation=${run.generationMs}ms overhead=${run.impeccableOverheadMs}ms`;
}
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { parseArgs } from './lib/cli-args.mjs';
import { compareModelBackedReports } from './lib/live-benchmark.mjs';
const args = parseArgs(process.argv.slice(2));
if (!args.atomic || !args.progressive) {
throw new Error('usage: node scripts/compare-live-benchmarks.mjs --atomic=<report.json> --progressive=<report.json>');
}
const [atomic, progressive] = await Promise.all([
readReport(args.atomic, 'atomic'),
readReport(args.progressive, 'progressive'),
]);
const comparison = compareModelBackedReports(atomic, progressive, {
medianTarget: ratioArg(args.medianTarget, 0.35),
p95Target: ratioArg(args.p95Target, 0.25),
});
process.stdout.write(JSON.stringify(comparison, null, 2) + '\n');
if (!comparison.passed) process.exitCode = 1;
async function readReport(file, delivery) {
const value = JSON.parse(await readFile(resolve(String(file)), 'utf-8'));
const reports = Array.isArray(value?.reports) ? value.reports : [value];
const report = reports.find((item) => item?.benchmark?.delivery === delivery);
if (!report) throw new Error(`${file} does not contain a ${delivery} benchmark report`);
return report;
}
function ratioArg(value, fallback) {
if (value == null || value === true) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed >= 1) throw new Error(`invalid threshold ratio: ${value}`);
return parsed;
}
-395
View File
@@ -1,395 +0,0 @@
import { performance } from 'node:perf_hooks';
const METRIC_KEYS = [
'browserPreparationMs',
'browserDispatchMs',
'automationClickMs',
'serverPickupMs',
'goToAgentMs',
'serverPreflightMs',
'scaffoldMs',
'generationToFirstMs',
'generationMs',
'firstVariantWriteMs',
'writeMs',
'writeToFirstVariantMs',
'replyMs',
'goToFirstVariantMs',
'goToAllVariantsMs',
'deliveryGapMs',
'impeccableOverheadMs',
];
export function createTraceRecorder(now = () => performance.now()) {
const events = [];
return {
events,
trace(name, data = {}) {
events.push({ name, at: now(), ...data });
},
mark(name, data = {}) {
const event = { name, at: now(), ...data };
events.push(event);
return event;
},
};
}
export function durationBetween(events, startName, endName, predicate = () => true) {
const start = events.find((event) => event.name === startName && predicate(event));
const end = events.find((event) => event.name === endName && predicate(event) && (!start || event.at >= start.at));
if (!start || !end) return null;
return roundMs(Math.max(0, end.at - start.at));
}
/**
* Assemble the two model calls used by the Live benchmark's progressive path.
* The first checkpoint is already visible in the browser, so both its markup
* and CSS are immutable. The tail call may supply deferred params for variant
* 1, but its CSS must contain only independently-scoped rules for variants 2+.
*/
export function assembleSplitProgressiveOutput(first, remaining) {
const firstVariant = first?.variants?.[0];
if (!firstVariant) throw new Error('progressive assembly requires a first variant');
if (!Array.isArray(remaining?.variants) || remaining.variants.length < 1) {
throw new Error('progressive assembly requires a complete remaining variant set');
}
const firstCss = String(first.scopedCss || '');
const laterCss = String(remaining.scopedCss || '');
assertLaterVariantCss(laterCss);
return {
scopedCss: firstCss && laterCss ? `${firstCss}\n${laterCss}` : firstCss || laterCss,
variants: [
{
...firstVariant,
params: Array.isArray(remaining.variants[0]?.params)
? remaining.variants[0].params
: [],
},
...remaining.variants.slice(1),
],
};
}
export function buildInteractionRun(events, { iteration, scenario, goStartedAt, browserTiming = null }) {
const received = events.find((event) =>
event.name === 'agent.event.received'
&& event.type === 'generate'
&& event.at >= goStartedAt
);
if (!received?.id) throw new Error(`iteration ${iteration}: no generate event was traced`);
const id = received.id;
const forId = (event) => event.id === id;
const eventPost = events.find((event) => event.name === 'browser.generate_post' && forId(event));
const mark = (name) => events.find((event) => event.name === name && event.iteration === iteration);
const first = mark('browser.first_variant');
const all = mark('browser.all_variants');
const writeEnd = events.find((event) => event.name === 'agent.write.end' && forId(event));
const firstWriteEnd = events.find((event) => event.name === 'agent.first_variant.write.end' && forId(event));
const reusedScaffold = events.find((event) => event.name === 'agent.scaffold.reused' && forId(event));
const generationMs = durationBetween(events, 'agent.generate.start', 'agent.generate.end', forId);
const generationToFirstMs = durationBetween(events, 'agent.generate.start', 'agent.generate.first_ready', forId);
const browserPreparationMs = eventPost ? roundMs(eventPost.at - goStartedAt) : null;
const browserDispatchMs = Number.isFinite(browserTiming?.goAt) && Number.isFinite(browserTiming?.generateAt)
? roundMs(Math.max(0, browserTiming.generateAt - browserTiming.goAt))
: null;
const interactionStartedAt = eventPost && browserDispatchMs != null
? eventPost.at - browserDispatchMs
: goStartedAt;
const measuredGoToFirstVariantMs = first ? roundMs(first.at - interactionStartedAt) : null;
const measuredGoToAllVariantsMs = all ? roundMs(all.at - interactionStartedAt) : null;
return {
iteration,
scenario,
eventId: id,
annotationEvidence: {
screenshotPath: eventPost?.hasScreenshotPath === true,
comments: Number(eventPost?.commentCount || 0),
strokes: Number(eventPost?.strokeCount || 0),
},
browserPreparationMs,
browserDispatchMs,
automationClickMs: browserPreparationMs == null || browserDispatchMs == null
? null
: roundMs(Math.max(0, browserPreparationMs - browserDispatchMs)),
serverPickupMs: eventPost ? roundMs(Math.max(0, received.at - eventPost.at)) : null,
goToAgentMs: roundMs(received.at - interactionStartedAt),
serverPreflightMs: Number.isFinite(reusedScaffold?.durationMs) ? roundMs(reusedScaffold.durationMs) : null,
scaffoldMs: durationBetween(events, 'agent.scaffold.start', 'agent.scaffold.end', forId),
generationToFirstMs,
generationMs,
firstVariantWriteMs: durationBetween(events, 'agent.first_variant.write.start', 'agent.first_variant.write.end', forId),
writeMs: durationBetween(events, 'agent.write.start', 'agent.write.end', forId),
writeToFirstVariantMs: first && (firstWriteEnd || writeEnd)
? roundMs(Math.max(0, first.at - (firstWriteEnd || writeEnd).at))
: null,
replyMs: durationBetween(events, 'agent.reply.start', 'agent.reply.end', forId),
goToFirstVariantMs: measuredGoToFirstVariantMs,
goToAllVariantsMs: measuredGoToAllVariantsMs,
deliveryGapMs: first && all ? roundMs(Math.max(0, all.at - first.at)) : null,
impeccableOverheadMs: measuredGoToFirstVariantMs == null || generationToFirstMs == null
? null
: roundMs(Math.max(0, measuredGoToFirstVariantMs - generationToFirstMs)),
};
}
export function summarizeRuns(runs) {
const metrics = {};
for (const key of METRIC_KEYS) {
const values = runs.map((run) => run[key]).filter(Number.isFinite).sort((a, b) => a - b);
if (values.length === 0) continue;
metrics[key] = {
median: roundMs(percentile(values, 0.5)),
p95: roundMs(percentile(values, 0.95)),
min: roundMs(values[0]),
max: roundMs(values[values.length - 1]),
};
}
return { count: runs.length, metrics };
}
export function summarizeSetup(events) {
const stages = [
['dependencies', 'setup.install.start', 'setup.install.end'],
['liveServer', 'setup.live_server.start', 'setup.live_server.end'],
['injection', 'setup.inject.start', 'setup.inject.end'],
['devServer', 'setup.dev_server.start', 'setup.dev_server.end'],
['pageLoad', 'setup.page_load.start', 'setup.page_load.end'],
['handshake', 'setup.handshake.start', 'setup.handshake.end'],
];
return Object.fromEntries(stages.map(([key, start, end]) => [key, durationBetween(events, start, end)]));
}
export function createBenchmarkReport({
fixture,
agent,
provider,
model,
scenario,
runs,
events,
harnessProbe = null,
delivery = 'atomic',
promptMode = null,
simulation = null,
generatedAt = new Date().toISOString(),
}) {
return {
schemaVersion: 1,
generatedAt,
benchmark: {
fixture,
agent,
provider: provider || null,
model: model || null,
scenario,
variants: 3,
delivery,
promptMode,
simulation,
},
setup: summarizeSetup(events),
summary: summarizeRuns(runs),
runs,
harnessProbe,
};
}
export function mergeBenchmarkReports(reports, generatedAt = new Date().toISOString()) {
return {
schemaVersion: 1,
generatedAt,
reports,
};
}
export function compareModelBackedReports(atomic, progressive, {
medianTarget = 0.35,
p95Target = 0.25,
minimumRuns = 3,
} = {}) {
assertComparableModelReport(atomic, 'atomic', minimumRuns);
assertComparableModelReport(progressive, 'progressive', minimumRuns);
for (const key of ['fixture', 'provider', 'model', 'scenario', 'variants', 'promptMode']) {
if (atomic.benchmark[key] !== progressive.benchmark[key]) {
throw new Error(`benchmark mismatch for ${key}: atomic=${atomic.benchmark[key]} progressive=${progressive.benchmark[key]}`);
}
}
const atomicFirst = requiredMetric(atomic, 'goToFirstVariantMs');
const progressiveFirst = requiredMetric(progressive, 'goToFirstVariantMs');
const medianImprovement = improvement(atomicFirst.median, progressiveFirst.median);
const p95Improvement = improvement(atomicFirst.p95, progressiveFirst.p95);
const allReady = {
atomic: requiredMetric(atomic, 'goToAllVariantsMs'),
progressive: requiredMetric(progressive, 'goToAllVariantsMs'),
};
const passed = medianImprovement >= medianTarget && p95Improvement >= p95Target;
return {
passed,
target: { medianImprovement, p95Improvement, medianTarget, p95Target },
firstReviewable: { atomic: atomicFirst, progressive: progressiveFirst },
allVariantsReady: allReady,
benchmark: {
fixture: atomic.benchmark.fixture,
provider: atomic.benchmark.provider,
model: atomic.benchmark.model,
scenario: atomic.benchmark.scenario,
runs: { atomic: atomic.summary.count, progressive: progressive.summary.count },
},
};
}
function assertComparableModelReport(report, delivery, minimumRuns) {
if (!report?.benchmark || !report?.summary) throw new Error(`${delivery} benchmark report is missing metadata or summary`);
if (report.benchmark.agent !== 'llm') throw new Error(`${delivery} benchmark must be model-backed (agent=llm)`);
if (report.benchmark.delivery !== delivery) {
throw new Error(`expected ${delivery} delivery report, got ${report.benchmark.delivery || 'unknown'}`);
}
if (report.benchmark.simulation) throw new Error(`${delivery} model benchmark must not contain simulated latency`);
if (!report.benchmark.provider || !report.benchmark.model) throw new Error(`${delivery} benchmark is missing provider/model identity`);
if (!Number.isInteger(report.summary.count) || report.summary.count < minimumRuns) {
throw new Error(`${delivery} benchmark requires at least ${minimumRuns} runs`);
}
}
function requiredMetric(report, key) {
const metric = report.summary.metrics?.[key];
if (!Number.isFinite(metric?.median) || !Number.isFinite(metric?.p95)) {
throw new Error(`${report.benchmark.delivery} benchmark is missing ${key} median/p95`);
}
return { median: metric.median, p95: metric.p95 };
}
function improvement(baseline, candidate) {
if (!(baseline > 0) || !Number.isFinite(candidate)) throw new Error('benchmark latency must be finite and baseline must be positive');
return Number((1 - (candidate / baseline)).toFixed(4));
}
function assertLaterVariantCss(css) {
if (!css.trim()) return;
for (const prelude of topLevelCssPreludes(css)) {
const variants = [...prelude.matchAll(/\[data-impeccable-variant\s*=\s*(["'])(\d+)\1[^\]]*\]/g)]
.map((match) => Number(match[2]));
if (variants.includes(1)) {
throw new Error('progressive tail CSS must not repeat or conflict with published variant 1 CSS');
}
if (variants.length === 0 || variants.some((variant) => variant < 2)) {
throw new Error('progressive tail CSS must be attributable only to variants 2+');
}
if (new Set(variants).size !== 1) {
throw new Error('each progressive tail CSS block must target exactly one later variant');
}
}
}
function topLevelCssPreludes(css) {
const preludes = [];
let cursor = 0;
while (cursor < css.length) {
while (cursor < css.length && /\s/.test(css[cursor])) cursor += 1;
if (cursor >= css.length) break;
const start = cursor;
const open = findCssToken(css, cursor, '{');
if (open === -1) throw new Error('progressive tail CSS contains a rule without a block');
const prelude = css.slice(start, open).trim();
if (!prelude || prelude.includes(';')) {
throw new Error('progressive tail CSS must contain scoped rule blocks only');
}
preludes.push(prelude);
const close = findMatchingCssBrace(css, open);
if (close === -1) throw new Error('progressive tail CSS has unbalanced braces');
cursor = close + 1;
}
return preludes;
}
function findCssToken(css, start, token) {
let quote = null;
let comment = false;
for (let index = start; index < css.length; index += 1) {
const char = css[index];
const next = css[index + 1];
if (comment) {
if (char === '*' && next === '/') {
comment = false;
index += 1;
}
continue;
}
if (!quote && char === '/' && next === '*') {
comment = true;
index += 1;
continue;
}
if (quote) {
if (char === '\\') index += 1;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === token) return index;
}
return -1;
}
function findMatchingCssBrace(css, open) {
let depth = 0;
let quote = null;
let comment = false;
for (let index = open; index < css.length; index += 1) {
const char = css[index];
const next = css[index + 1];
if (comment) {
if (char === '*' && next === '/') {
comment = false;
index += 1;
}
continue;
}
if (!quote && char === '/' && next === '*') {
comment = true;
index += 1;
continue;
}
if (quote) {
if (char === '\\') index += 1;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '{') depth += 1;
if (char === '}') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function percentile(sortedValues, ratio) {
if (sortedValues.length === 1) return sortedValues[0];
const index = (sortedValues.length - 1) * ratio;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) return sortedValues[lower];
const weight = index - lower;
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
}
function roundMs(value) {
if (!Number.isFinite(value)) return null;
return Number(value.toFixed(2));
}
+3 -6
View File
@@ -22,10 +22,7 @@ export const PROVIDERS = {
},
'claude-code': {
provider: 'claude-code',
// live-progressive: Live delivers variant 1 as soon as it validates instead of
// one atomic edit. Claude Code polls in a background task, so the extra
// publish calls do not stall its control lane.
providerTags: ['claude-code', 'claude', 'live-progressive'],
providerTags: ['claude-code', 'claude'],
configDir: '.claude',
displayName: 'Claude Code',
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'],
@@ -43,7 +40,7 @@ export const PROVIDERS = {
},
codex: {
provider: 'codex',
providerTags: ['codex', 'live-progressive'],
providerTags: ['codex'],
configDir: '.codex',
displayName: 'Codex',
frontmatterFields: [],
@@ -57,7 +54,7 @@ export const PROVIDERS = {
},
agents: {
provider: 'agents',
providerTags: ['agents', 'codex', 'live-progressive'],
providerTags: ['agents', 'codex'],
configDir: '.agents',
displayName: 'Codex Repo Skills',
placeholderProvider: 'codex',
-5
View File
@@ -645,11 +645,6 @@ export const PROVIDER_BLOCK_TAGS = new Set([
'rovo-dev',
'trae',
'trae-cn',
// Capability tags. Not harness names: they mark instructions that belong to a
// shared capability several harnesses opt into. Listing the harnesses instead
// would mean duplicating the block body per provider tag, since a block takes
// one tag. Opt a provider in by adding the tag to its providerTags.
'live-progressive',
]);
/**
-2
View File
@@ -125,7 +125,6 @@ export const SUITES = {
'tests/live-browser-regression.test.mjs',
'tests/live-browser-session.test.mjs',
'tests/live-browser-source.test.mjs',
'tests/live-benchmark.test.mjs',
'tests/live-commit-manual-edits.test.mjs',
'tests/live-completion.test.mjs',
'tests/live-copy-edit-agent.test.mjs',
@@ -137,7 +136,6 @@ export const SUITES = {
'tests/live-e2e/agent-insert.test.mjs',
'tests/live-event-validation.test.mjs',
'tests/live-generation-preflight.test.mjs',
'tests/live-generation-publisher.test.mjs',
'tests/live-inject.test.mjs',
'tests/live-insert.test.mjs',
'tests/live-insert-ui.test.mjs',
+4 -13
View File
@@ -23,11 +23,10 @@ The first argument is the action. Defaults to `status`.
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. Suppresses the rule across the whole project. |
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. Suppresses **every** rule for matching files. |
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -52,8 +51,7 @@ Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
@@ -75,14 +73,7 @@ Example whole-rule font exception:
node {{scripts_path}}/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
```
Example one-rule-in-one-file exception, for a file that is still worth reviewing
for everything else:
```bash
node {{scripts_path}}/hook-admin.mjs ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site"
```
Example whole-file exception, for a file that is out of scope entirely:
Example file-scoped exception:
```bash
node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
-31
View File
@@ -32,10 +32,6 @@ Harness policy:
Generation delivery policy:
- **Default (Cursor and other harnesses):** keep the established atomic single-edit delivery. Do not switch a harness to progressive until its poll loop is known not to block on the extra publish calls. This avoids trading model latency for extra tool-call latency on harnesses with different streaming behavior.
<live-progressive>
- **Progressive delivery (Codex, Claude Code):** deliver progressively through `live-publish.mjs`, never by editing project source directly. Publish variant 1 as soon as it is complete, then publish each additional validated variant (or the largest ready prefix) without waiting for later siblings. Attach parameter CSS/manifests only with the complete set. The browser makes every arrived variant immediately reviewable and acceptable; Accept/Discard durably cancel unfinished revisions. The user reviews the first direction while the rest are still being written, so time-to-first-variant is what matters, not total time.
</live-progressive>
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
## Start
@@ -314,33 +310,6 @@ Colocate preview CSS as a `<style>` tag inside the variant wrapper; `<style>` wo
**Atomic default:** write CSS + all variants + parameter manifests in one edit at `insertLine`, preserving the established behavior.
<live-progressive>
**Transactional progressive delivery (Codex, Claude Code):**
1. Plan all directions and name their parameter axes first so the trio remains coherent.
2. Prepare revision 1 from the scaffolded source:
```bash
node {{scripts_path}}/live-publish.mjs --prepare --id EVENT_ID --file SOURCE_FILE
```
The JSON result contains `artifactFile`, `epoch`, and `expectedSourceHash`. For the normal source-wrapper path, `artifactFile` is a staging copy of the already-wrapped source; edit **only `artifactFile`** at `insertLine`: write variant 1 and only the CSS it needs. Do not attach `data-impeccable-params` yet. Publishing replaces the wrapped source atomically, and `expectedSourceHash` is the fence that makes it safe: if the file moved under you the publish is rejected rather than clobbering it. The wrapper itself is already in your source from the scaffold, so preview markers are visible there until Accept or Discard removes them; do not hand-edit the file while a publish may be in flight.
For `previewMode: "svelte-component"` or `"vue-component"`, `artifactFile` is an isolated manifest and `componentDir` is its isolated component directory. Write `v1.svelte` or `v1.vue` under the returned `componentDir`, set the artifact manifest's `arrivedVariants` to `1`, and leave `params.json` absent. Keep `--file` pointed at the original live manifest on publish; the publisher fences against `targetSourceFile`, promotes the component, then commits the live manifest last. Never edit the live `componentDir` directly.
3. Publish revision 1 with the exact fence values returned by `--prepare`:
```bash
node {{scripts_path}}/live-publish.mjs --id EVENT_ID --epoch EPOCH \
--file SOURCE_FILE --artifact ARTIFACT_FILE --expected-source-hash SOURCE_HASH \
--arrived 1 --expected EVENT_COUNT
```
`{ok:false,error:"stale_generation_epoch"}` means the user already accepted or discarded. Stop immediately, do not touch source, and post the generation reply as canceled/error.
4. Continue variants 2 through `EVENT_COUNT` from the stored plan. Whenever another direction validates, run `--prepare` again so the revision starts from the immutable published prefix, add the largest ready prefix without changing any published variant or default appearance, and publish it immediately. Attach parameter CSS/manifests only when the complete set is ready, using `--kind params`. On component-preview paths, preserve every already-published `vN.svelte` / `vN.vue` byte-for-byte; publication rejects a revision that silently changes a variant the user may already be reviewing.
5. A params-only pass is recovery-only: use it when durable state says every variant arrived but `paramsPublished` is still false after an interrupted publication.
6. Verify the published preview parses, then `--reply done`. A late reply is diagnostic only after Accept/Discard and cannot move the durable session backward.
</live-progressive>
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
```html
+7 -34
View File
@@ -13,8 +13,6 @@
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
* node hook-admin.mjs ignore-value <rule> "*" # refused: scope it or use ignore-rule
* node hook-admin.mjs reset # remove all config + cache
*
* Designed to be invoked by the LLM from the reference/hooks.md flow.
@@ -536,13 +534,12 @@ function addIgnoreFile(cwd, glob) {
function parseIgnoreValueArgs(args) {
const positionals = [];
const files = [];
let shared = false;
let local = false;
let reason = '';
for (let i = 0; i < args.length; i++) {
const arg = String(args[i] || '');
const arg = args[i];
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
@@ -553,20 +550,8 @@ function parseIgnoreValueArgs(args) {
chunks.push(args[++i]);
}
reason = chunks.join(' ').trim();
} else if (arg.startsWith('--reason=')) {
reason = arg.slice('--reason='.length).trim();
} else if (arg === '--file' || arg === '--files') {
if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
files.push(String(args[++i]).trim());
} else if (arg.startsWith('--file=')) {
files.push(arg.slice('--file='.length).trim());
} else if (arg.startsWith('--files=')) {
files.push(arg.slice('--files='.length).trim());
} else if (arg.startsWith('--')) {
// Otherwise a typo folds into the value: `ignore-value overused-font Inter
// --shard` stored the value "inter --shard", which matches no finding, and
// reported success. Matches `impeccable ignores add-value`.
throw new Error(`Unknown ignore-value flag: ${arg}`);
} else if (String(arg).startsWith('--reason=')) {
reason = String(arg).slice('--reason='.length).trim();
} else {
positionals.push(arg);
}
@@ -576,7 +561,6 @@ function parseIgnoreValueArgs(args) {
return {
rule: String(rule || '').trim().toLowerCase(),
value: normalizeIgnoreValue(valueParts.join(' ')),
files: Array.from(new Set(files.filter(Boolean))),
shared,
local,
reason,
@@ -593,19 +577,10 @@ function addIgnoreValue(cwd, args) {
throw new Error('Pass only one scope flag: --shared or --local');
}
// A bare `*` would suppress the rule everywhere, which is ignore-rule's job and
// not what a finding in one file justifies. detector.ignoreValues honours a
// `files` scope, so require one — matching `impeccable ignores add-value`.
if (parsed.value === '*' && parsed.files.length === 0) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
// once with different scopes, and a rule+value-only key overwrote them.
const key = ignoreValueEntryKey({ rule: parsed.rule, value: parsed.value, files: parsed.files });
const existing = config.ignoreValues.find((entry) => ignoreValueEntryKey(entry) === key);
const key = `${parsed.rule}\0${parsed.value}`;
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
if (existing) {
if (parsed.reason) existing.reason = parsed.reason;
@@ -613,17 +588,15 @@ function addIgnoreValue(cwd, args) {
const entry = {
rule: parsed.rule,
value: parsed.value,
createdAt: new Date().toISOString(),
};
if (parsed.files.length) entry.files = parsed.files;
entry.createdAt = new Date().toISOString();
if (parsed.reason) entry.reason = parsed.reason;
config.ignoreValues.push(entry);
}
const target = writeDetectorConfig(cwd, config, { local });
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
const scopeSuffix = parsed.files.length ? ` scoped to ${parsed.files.join(', ')}` : '';
return `Added ${parsed.rule}=${parsed.value}${scopeSuffix} to ${scope} (${path.relative(cwd, target) || target}).`;
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
}
function reset(cwd) {
+8 -12
View File
@@ -502,15 +502,12 @@ export function normalizeIgnoreValueEntries(entries) {
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
// Key order is rule, value, files, createdAt, reason and must stay that way:
// normalizing runs on every write, so emitting a different order than the one
// already on disk rewrites every untouched entry and churns the diff.
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
@@ -1468,17 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
// for the path forever, which is far more than one noisy rule on a real UI
// surface justifies, and it was previously the only option named here.
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
: `run \`${ignoreFileCommand}\``;
return [
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
].join('\n');
}
-5
View File
@@ -30,7 +30,6 @@ import {
inlineVueComponentAccept,
retireVueComponentSession,
} from './live/vue-component.mjs';
import { removeGenerationArtifacts } from './live/generation-publisher.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
const ACCEPT_LOCK_WAIT_MS = 1_000;
@@ -154,10 +153,6 @@ Output (JSON):
variantId: isDiscard ? null : String(variantNum),
result,
});
// The session is over: drop its staged revision artifacts. Leaving them
// behind is what let a later marker search find a decoy instead of real
// source. Only on success, so a failed accept can still be retried.
removeGenerationArtifacts(id, process.cwd());
}
console.log(JSON.stringify(result));
};
+7 -1
View File
@@ -5687,6 +5687,13 @@
arrivedVariants = variants.length;
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
if (arrivedVariants <= 0) {
// Scaffold wrapper exists but variants haven't been written yet.
// This happens when server-side preflight triggers a full page reload
// (e.g. Astro's HMR) before the agent finishes writing variants. In
// GENERATING state the agent will write the variants shortly — don't
// destroy the session. The next HMR or done SSE will call this again
// with the full variant set.
if (state === 'GENERATING') return;
recoverEmptyCycling('source-fallback-empty');
return;
}
@@ -8038,7 +8045,6 @@ void main() {
paramsCurrentValues = { ...saved.paramValues };
}
if (saved.parameterState) parameterGenerationState = saved.parameterState;
else if (saved.paramsPublished === true && parameterGenerationState !== 'ready') parameterGenerationState = 'loading';
if (saved.generationPhase) generationPhase = saved.generationPhase;
}
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env node
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from './live/generation-publisher.mjs';
const args = process.argv.slice(2);
const result = args.includes('--prepare')
? prepareGenerationArtifact({
id: arg(args, '--id'),
sourceFile: arg(args, '--file'),
})
: publishGenerationArtifact({
id: arg(args, '--id'),
epoch: Number(arg(args, '--epoch')),
sourceFile: arg(args, '--file'),
artifactFile: arg(args, '--artifact'),
expectedSourceHash: arg(args, '--expected-source-hash'),
arrivedVariants: optionalNumber(arg(args, '--arrived')),
expectedVariants: optionalNumber(arg(args, '--expected')),
publicationKind: arg(args, '--kind'),
});
console.log(JSON.stringify(result));
if (!result.ok) process.exitCode = 2;
function arg(values, name) {
const index = values.indexOf(name);
return index >= 0 ? values[index + 1] : undefined;
}
function optionalNumber(value) {
if (value === undefined) return undefined;
const number = Number(value);
return Number.isInteger(number) ? number : undefined;
}
-1
View File
@@ -383,7 +383,6 @@ function summarizeActiveSessionForClient(snapshot = {}) {
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
paramValues: snapshot.paramValues || {},
paramsPublished: snapshot.paramsPublished === true,
generationPhase: snapshot.generationPhase ?? null,
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
-1
View File
@@ -230,7 +230,6 @@ The agent should insert variant HTML at insertLine.`);
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
let originalLines = lines.slice(startLine, endLine + 1);
const sourceOriginalLines = [...originalLines];
// Buffer-aware "original" content: if the user has pending manual edits for
// this page whose originalText appears in the picked source range, apply
-604
View File
@@ -1,604 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { createLiveSessionStore } from './session-store.mjs';
import { withSourceLockSync } from './source-lock.mjs';
import { getLiveDir, safeSessionId } from '../lib/impeccable-paths.mjs';
export function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
/**
* Delete a session's staged revision artifacts.
*
* Nothing used to remove these, and they are the reason a Live accept could
* resolve to the wrong file: `<id>-r<n>.<source-ext>` carries the session marker,
* so it is a decoy for any marker search that walks the project. live-accept no
* longer searches `.impeccable`, but the artifacts should not outlive the session
* they belong to either. Called on accept and discard.
*/
export function removeGenerationArtifacts(id, cwd = process.cwd()) {
let removed = 0;
try { safeSessionId(id); } catch { return removed; }
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
let entries;
try { entries = fs.readdirSync(artifactDir); } catch { return removed; }
for (const name of entries) {
if (!name.startsWith(id + '-r')) continue;
try { fs.rmSync(path.join(artifactDir, name), { force: true }); removed += 1; } catch { /* best effort */ }
}
return removed;
}
export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) {
if (!id) return failure('missing_session_id');
if (!sourceFile) return failure('missing_file');
const requestedPath = resolveInside(cwd, sourceFile);
if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
if (snapshot.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
const source = fs.readFileSync(sourcePath, 'utf-8');
const artifactBase = source;
const revision = Number(snapshot.publishedRevision || 0) + 1;
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
if (componentTarget) {
return prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target: componentTarget,
artifactDir,
cwd,
});
}
const extension = path.extname(sourcePath) || '.html';
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
fs.mkdirSync(artifactDir, { recursive: true });
fs.writeFileSync(artifactPath, artifactBase, 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
expectedSourceHash: sha256(source),
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('prepare_failed', { message: error?.message || String(error) });
}
}
export function publishGenerationArtifact({
id,
epoch,
sourceFile,
artifactFile,
expectedSourceHash,
arrivedVariants,
expectedVariants,
publicationKind,
cwd = process.cwd(),
} = {}) {
if (!id) return failure('missing_session_id');
if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch');
if (!sourceFile || !artifactFile) return failure('missing_file');
if (publicationKind && !['variants', 'params'].includes(publicationKind)) {
return failure('invalid_publication_kind');
}
const requestedPath = resolveInside(cwd, sourceFile);
const artifactPath = resolveInside(cwd, artifactFile);
if (!requestedPath || !artifactPath) return failure('path_outside_project');
if (!fs.existsSync(requestedPath)) return failure('source_missing');
if (!fs.existsSync(artifactPath)) return failure('artifact_missing');
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
if (componentTarget?.error) return componentTarget;
const artifactManifest = readJson(artifactPath);
const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode);
if (Boolean(componentTarget) !== isComponentArtifact) {
return failure('artifact_preview_mode_mismatch');
}
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
return failure('artifact_preview_mode_mismatch');
}
const sourcePath = componentTarget?.sourcePath || requestedPath;
try {
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
const store = createLiveSessionStore({ cwd, sessionId: id });
const snapshot = store.getSnapshot(id, { includeCompleted: true });
if (!snapshot?.updatedAt) return failure('session_missing');
const stale = staleGenerationFailure(snapshot, epoch);
if (stale) return stale;
const current = fs.readFileSync(sourcePath, 'utf-8');
const currentHash = sha256(current);
if (!expectedSourceHash || currentHash !== expectedSourceHash) {
return failure('source_hash_mismatch', { actualSourceHash: currentHash });
}
if (componentTarget) {
return publishComponentArtifact({
id,
epoch,
snapshot,
target: componentTarget,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
publicationKind,
store,
cwd,
});
}
const stablePreview = current;
const artifact = fs.readFileSync(artifactPath, 'utf-8');
if (!artifact.includes('data-impeccable-variants="' + id + '"')) {
return failure('artifact_missing_session_wrapper');
}
const delivered = countDeliveredVariants(artifact);
if (delivered < 1) return failure('artifact_has_no_variants');
if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) {
return failure('artifact_variant_count_mismatch', { delivered });
}
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
for (let variant = 1; variant <= priorArrived; variant++) {
const currentVariant = extractVariantBlock(stablePreview, variant);
const artifactVariant = extractVariantBlock(artifact, variant);
if (!currentVariant || !artifactVariant) {
return failure('published_variant_missing', { variant });
}
if (sha256(withoutVariantParams(currentVariant)) !== sha256(withoutVariantParams(artifactVariant))) {
return failure('published_variant_changed', { variant });
}
}
const currentPreviewCss = extractPreviewCss(stablePreview, id);
const artifactPreviewCss = extractPreviewCss(artifact, id);
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
return failure('published_variant_css_changed');
}
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
if (commitStale) return commitStale;
const artifactHash = sha256(artifact);
const publishPath = sourcePath;
atomicReplace(publishPath, artifact);
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
publicationKind: publicationKind || 'variants',
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest: artifactHash,
sourceFile: relative(cwd, sourcePath),
arrivedVariants: delivered,
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
publicationKind: publicationKind || 'variants',
};
}, { cwd });
} catch (error) {
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
return failure('publish_failed', { message: error?.message || String(error) });
}
}
function prepareComponentArtifact({
id,
revision,
snapshot,
source,
sourcePath,
requestedPath,
target,
artifactDir,
cwd,
}) {
const artifactComponentDir = path.join(
artifactDir,
id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(),
);
fs.mkdirSync(artifactComponentDir, { recursive: true });
copyDirectoryFiles(target.componentPath, artifactComponentDir);
const artifactPath = path.join(artifactComponentDir, 'manifest.json');
const artifactManifest = {
...target.manifest,
componentDir: relative(cwd, artifactComponentDir),
};
fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8');
return {
ok: true,
id,
epoch: Number(snapshot.generationEpoch || 1),
revision,
sourceFile: relative(cwd, requestedPath),
targetSourceFile: relative(cwd, sourcePath),
artifactFile: relative(cwd, artifactPath),
componentDir: relative(cwd, artifactComponentDir),
previewMode: target.manifest.previewMode,
expectedSourceHash: sha256(source),
};
}
function publishComponentArtifact({
id,
epoch,
snapshot,
target,
artifactManifest,
artifactPath,
sourcePath,
arrivedVariants,
expectedVariants,
publicationKind,
store,
cwd,
}) {
if (!artifactManifest || typeof artifactManifest !== 'object') {
return failure('artifact_manifest_invalid');
}
if (artifactManifest.id !== id || target.manifest.id !== id) {
return failure('artifact_session_mismatch');
}
const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir);
if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) {
return failure('artifact_component_dir_mismatch');
}
if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) {
return failure('artifact_not_staged');
}
const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest);
if (immutableMismatch) {
return failure('artifact_manifest_changed', { field: immutableMismatch });
}
const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0);
const declared = optionalPositiveInteger(artifactManifest.arrivedVariants);
const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared;
if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants');
if (expected > 0 && delivered > expected) {
return failure('artifact_variant_count_mismatch', { delivered, expected });
}
if (declared !== null && declared !== delivered) {
return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered });
}
const priorArrived = Math.max(
optionalPositiveInteger(target.manifest.arrivedVariants) || 0,
Number(snapshot.arrivedVariants || 0),
);
if (delivered < priorArrived) {
return failure('artifact_variant_count_regressed', { delivered, priorArrived });
}
const componentExtension = target.manifest.componentExtension
|| (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
const variantContents = [];
for (let variant = 1; variant <= delivered; variant++) {
const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension);
if (!regularFileInside(artifactComponentPath, artifactVariantPath)) {
return failure('artifact_variant_missing', { variant });
}
const content = fs.readFileSync(artifactVariantPath, 'utf-8');
if (!content.trim()) return failure('artifact_variant_empty', { variant });
const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension);
if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) {
return failure('published_variant_missing', { variant });
}
if (variant <= priorArrived) {
const prior = fs.readFileSync(targetVariantPath, 'utf-8');
if (sha256(prior) !== sha256(content)) {
return failure('published_variant_changed', { variant });
}
}
variantContents.push({ variant, content, targetPath: targetVariantPath });
}
const artifactParamsPath = path.join(artifactComponentPath, 'params.json');
let paramsContent = null;
if (fs.existsSync(artifactParamsPath)) {
if (!regularFileInside(artifactComponentPath, artifactParamsPath)) {
return failure('artifact_params_invalid');
}
paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8');
const params = parseJson(paramsContent);
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return failure('artifact_params_invalid');
}
}
// Check the fence before writing anything. The prepare→publish gap is exactly
// where an Accept lands, and the non-component path above rechecks before
// its only write. Without the same check here, a canceled generation still
// scattered variant files across the generated component dir and left them
// there — the `stale_generation_epoch` returns below have no rollback.
const preWriteStale = staleGenerationFailure(store.getSnapshot(id, { includeCompleted: true }), epoch);
if (preWriteStale) return preWriteStale;
// Components and optional params become reachable before the manifest
// advertises them. Committing the manifest last makes publication atomic
// from the browser's point of view while the source lock excludes Accept.
fs.mkdirSync(target.componentPath, { recursive: true });
for (const variant of variantContents) {
if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content);
}
if (paramsContent !== null) {
atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent);
}
// Re-check immediately before the manifest: the manifest is what makes the
// variants visible to the browser, so this is the gate that actually matters.
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
if (commitStale) return commitStale;
const publishedManifest = {
...target.manifest,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
};
delete publishedManifest.manifestPath;
const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n';
atomicReplace(target.manifestPath, manifestContent);
const digest = digestComponentPublication(manifestContent, variantContents, paramsContent);
const revision = Number(snapshot.publishedRevision || 0) + 1;
const sourceFile = relative(cwd, sourcePath);
const previewFile = relative(cwd, target.manifestPath);
store.appendEvent({
type: 'variant_published',
id,
generationEpoch: epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
arrivedVariants: delivered,
expectedVariants: expected || delivered,
publicationKind: publicationKind || 'variants',
at: Date.now(),
});
return {
ok: true,
id,
epoch,
revision,
digest,
sourceFile,
previewFile,
previewMode: target.manifest.previewMode,
componentDir: relative(cwd, target.componentPath),
arrivedVariants: delivered,
expectedVariants: expected || delivered,
publicationKind: publicationKind || 'variants',
};
}
const COMPONENT_MANIFEST_FIELDS = [
'id',
'mode',
'previewMode',
'sourceFile',
'sourceStartLine',
'sourceEndLine',
'insertLine',
'position',
'anchorStartLine',
'anchorEndLine',
'count',
'propContract',
'originalMarkup',
'anchorMarkup',
'runtimeModule',
'componentModuleBase',
'framework',
'componentExtension',
];
function readComponentPublicationTarget(manifestPath, cwd, id) {
if (path.basename(manifestPath) !== 'manifest.json') return null;
const manifest = readJson(manifestPath);
if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null;
if (manifest.id !== id) return failure('artifact_session_mismatch');
const sourcePath = resolveInside(cwd, manifest.sourceFile);
const componentPath = resolveInside(cwd, manifest.componentDir);
if (!sourcePath || !componentPath) return failure('path_outside_project');
if (!fs.existsSync(sourcePath)) return failure('source_missing');
if (path.resolve(componentPath) !== path.dirname(manifestPath)) {
return failure('manifest_component_dir_mismatch');
}
return { manifest, manifestPath, sourcePath, componentPath };
}
function componentManifestMismatch(target, artifact) {
for (const field of COMPONENT_MANIFEST_FIELDS) {
if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field;
}
return null;
}
function isComponentPreviewMode(value) {
return value === 'svelte-component' || value === 'vue-component';
}
function copyDirectoryFiles(sourceDir, targetDir) {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile() || entry.isSymbolicLink()) continue;
fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
}
}
function regularFileInside(root, file) {
const rel = path.relative(root, file);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
try {
return fs.lstatSync(file).isFile();
} catch {
return false;
}
}
function isDescendant(root, candidate) {
const rel = path.relative(root, candidate);
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function digestComponentPublication(manifestContent, variants, paramsContent) {
const hash = createHash('sha256');
hash.update(manifestContent);
for (const variant of variants) {
hash.update('\0v' + variant.variant + '\0');
hash.update(variant.content);
}
if (paramsContent !== null) hash.update('\0params\0' + paramsContent);
return hash.digest('hex');
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function optionalPositiveInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
}
function countDeliveredVariants(source) {
const matches = source.match(/<div\b[^>]*\bdata-impeccable-variant=(?:"|')(?!original(?:"|'))[^"']+(?:"|')[^>]*>/g);
return matches?.length || 0;
}
function extractVariantBlock(source, variant) {
const open = /<div\b[^>]*>/gi;
let match;
let start = -1;
const attr = new RegExp("\\bdata-impeccable-variant=(?:\"" + variant + "\"|'" + variant + "')");
while ((match = open.exec(source))) {
if (attr.test(match[0])) {
start = match.index;
break;
}
}
if (start < 0) return null;
const token = /<div\b[^>]*\/\s*>|<div\b[^>]*>|<\/div\s*>/gi;
token.lastIndex = start;
let depth = 0;
while ((match = token.exec(source))) {
if (/^<\/div/i.test(match[0])) {
depth -= 1;
if (depth === 0) return source.slice(start, token.lastIndex);
} else if (!/\/\s*>$/.test(match[0])) {
depth += 1;
}
}
return null;
}
function withoutVariantParams(block) {
return String(block || '').replace(
/\sdata-impeccable-params=(?:"[^"]*"|'[^']*')/i,
'',
);
}
function extractPreviewCss(source, id) {
const escapedId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const open = new RegExp("<style\\b[^>]*\\bdata-impeccable-css=(?:\"" + escapedId + "\"|'" + escapedId + "')[^>]*>", 'i');
const match = open.exec(source);
if (!match) return '';
const start = match.index + match[0].length;
const end = source.indexOf('</style>', start);
if (end < 0) return '';
return source.slice(start, end)
.replace(/^\s*\{\s*`\s*/, '')
.replace(/\s*`\s*\}\s*$/, '')
.trim();
}
function atomicReplace(target, content) {
let mode = 0o666;
try { mode = fs.statSync(target).mode; } catch {}
const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
try {
fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
fs.renameSync(temp, target);
} finally {
try { fs.unlinkSync(temp); } catch {}
}
}
function resolveInside(cwd, value) {
const resolved = path.resolve(cwd, value);
const rel = path.relative(cwd, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
return resolved;
}
function relative(cwd, value) {
return path.relative(cwd, value).split(path.sep).join('/');
}
function failure(error, details = {}) {
return { ok: false, error, ...details };
}
/**
* The generation fence: has this session been canceled (Accept/Discard landed),
* or has a newer generation superseded this epoch? Returns a failure result to
* propagate, or null when the caller may proceed.
*/
function staleGenerationFailure(snapshot, epoch) {
if (snapshot?.generationCanceled === true) {
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
}
if (Number(snapshot?.generationEpoch || 1) !== epoch) {
return failure('stale_generation_epoch', { expectedEpoch: snapshot?.generationEpoch || 1 });
}
return null;
}
-40
View File
@@ -121,11 +121,7 @@ function baseSnapshot(id) {
fallbackMode: null,
generationPhase: null,
generationTimings: {},
generationEpoch: 1,
publishedRevision: 0,
deliveredVariants: {},
variantPlan: null,
paramsPublished: false,
generationCanceled: false,
generationCanceledAt: null,
cancelReason: null,
@@ -169,7 +165,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
generationTimings: { ...(snapshot.generationTimings || {}) },
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
variantPlan: snapshot.variantPlan || null,
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
@@ -183,7 +178,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -204,40 +198,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
];
}
break;
case 'variant_published':
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
revision: event.revision ?? null,
});
break;
}
if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
next.diagnostics.push({
error: 'stale_generation_epoch_ignored',
epoch: event.generationEpoch ?? null,
expectedEpoch: next.generationEpoch || 1,
});
break;
}
next.phase = 'variants_progress';
next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
if (event.publicationKind === 'params') next.paramsPublished = true;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.revision) {
next.deliveredVariants[String(event.revision)] = {
digest: event.digest || null,
arrivedVariants: Number(event.arrivedVariants || 0),
publishedAt: event.at || null,
};
}
break;
case 'agent_phase':
next.generationPhase = event.phase ?? next.generationPhase;
if (event.phase) {
@@ -6,7 +6,6 @@
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'path';
import { fileURLToPath } from 'url';
import {
@@ -18,60 +17,6 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
describe('detectText - Astro structural CSS fixtures', () => {
const SHOULD_FLAG = [
'Kinpaku Edge',
'Patina Edge',
'Accent Edge',
'Signal Blue Edge',
'Chromatic Hex Edge',
'Named Red Edge',
'Chromatic Rgb Edge',
'Chromatic Oklch Edge',
// `inset` may follow the offsets/color. Requiring it first missed the same
// stripe written the other legal way.
'Trailing Inset Edge',
'Trailing Inset Token Edge',
'Inset Named Token Edge',
];
const SHOULD_PASS = [
'Neutral Shadow Token',
'Current Color Edge',
'Selected State Edge',
'Hairline Edge',
'Thick Fill Edge',
'Blurred Edge',
'Narrow Artwork',
// Authored CSS spells neutrals as hex and keywords. isNeutralColor only
// parses the computed function forms and reports everything else as
// chromatic, so routing these through it flagged plain black and gray
// hairlines as the "colored stripe" AI tell.
'Black Hex Edge',
'Black Named Edge',
'Gray Hex Edge',
'Dimgray Named Edge',
'Black Rgb Edge',
'Shorthand Neutral Hex Edge',
// Commented-out CSS is not a live rule.
'Commented Out Edge',
// Trailing `inset` still respects the neutral-color exemption.
'Trailing Inset Neutral Edge',
];
it('Astro style blocks flag unresolved chromatic inset stripes only', () => {
const filePath = path.join(FIXTURES, 'astro-inset-shadow-stripe.astro');
const source = fs.readFileSync(filePath, 'utf8');
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
const snippets = findings.map(r => r.snippet || '').join(' | ');
for (const heading of SHOULD_FLAG) {
assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`);
}
for (const heading of SHOULD_PASS) {
assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`);
}
});
});
describe('detectHtml — static HTML/CSS fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
@@ -1,77 +0,0 @@
---
const title = 'Astro inset shadow stripe regression';
---
<main>
<h1>{title}</h1>
<section aria-labelledby="should-flag">
<h2 id="should-flag">Should flag</h2>
<article data-case="Kinpaku Edge"><h3>Kinpaku Edge</h3></article>
<article data-case="Patina Edge"><h3>Patina Edge</h3></article>
<article data-case="Accent Edge"><h3>Accent Edge</h3></article>
<article data-case="Signal Blue Edge"><h3>Signal Blue Edge</h3></article>
<article data-case="Chromatic Hex Edge"><h3>Chromatic Hex Edge</h3></article>
<article data-case="Named Red Edge"><h3>Named Red Edge</h3></article>
<article data-case="Chromatic Rgb Edge"><h3>Chromatic Rgb Edge</h3></article>
<article data-case="Chromatic Oklch Edge"><h3>Chromatic Oklch Edge</h3></article>
<article data-case="Trailing Inset Edge"><h3>Trailing Inset Edge</h3></article>
<article data-case="Trailing Inset Token Edge"><h3>Trailing Inset Token Edge</h3></article>
<article data-case="Inset Named Token Edge"><h3>Inset Named Token Edge</h3></article>
</section>
<section aria-labelledby="should-pass">
<h2 id="should-pass">Should pass</h2>
<article data-case="Neutral Shadow Token"><h3>Neutral Shadow Token</h3></article>
<article data-case="Current Color Edge"><h3>Current Color Edge</h3></article>
<article data-case="Selected State Edge" aria-current="page"><h3>Selected State Edge</h3></article>
<article data-case="Hairline Edge"><h3>Hairline Edge</h3></article>
<article data-case="Thick Fill Edge"><h3>Thick Fill Edge</h3></article>
<article data-case="Blurred Edge"><h3>Blurred Edge</h3></article>
<article data-case="Narrow Artwork"><h3>Narrow Artwork</h3></article>
<article data-case="Black Hex Edge"><h3>Black Hex Edge</h3></article>
<article data-case="Black Named Edge"><h3>Black Named Edge</h3></article>
<article data-case="Gray Hex Edge"><h3>Gray Hex Edge</h3></article>
<article data-case="Dimgray Named Edge"><h3>Dimgray Named Edge</h3></article>
<article data-case="Black Rgb Edge"><h3>Black Rgb Edge</h3></article>
<article data-case="Shorthand Neutral Hex Edge"><h3>Shorthand Neutral Hex Edge</h3></article>
<article data-case="Commented Out Edge"><h3>Commented Out Edge</h3></article>
<article data-case="Trailing Inset Neutral Edge"><h3>Trailing Inset Neutral Edge</h3></article>
</section>
</main>
<style is:inline>
[data-case="Kinpaku Edge"] { box-shadow: inset 3px 0 0 var(--ks-kinpaku-deep); }
[data-case="Patina Edge"] { box-shadow: inset 3px 0 0 var(--ks-patina-deep); }
[data-case="Accent Edge"] { box-shadow: inset -4px 0 0 var(--brand-accent); }
[data-case="Signal Blue Edge"] { box-shadow: inset 0 5px 0 var(--signal-blue); }
[data-case="Neutral Shadow Token"] { box-shadow: inset 3px 0 0 var(--shadow-color); }
[data-case="Current Color Edge"] { box-shadow: inset 3px 0 0 currentColor; }
[data-case="Selected State Edge"][aria-current="page"] { box-shadow: inset 3px 0 0 var(--brand-accent); }
[data-case="Hairline Edge"] { box-shadow: inset 2px 0 0 var(--brand-accent); }
[data-case="Thick Fill Edge"] { box-shadow: inset 14px 0 0 var(--brand-accent); }
[data-case="Blurred Edge"] { box-shadow: inset 3px 0 5px var(--brand-accent); }
[data-case="Narrow Artwork"] { width: 24px; box-shadow: inset 3px 0 0 var(--brand-accent); }
/* Literal colors: authored CSS spells neutrals as hex and keywords, not as
the computed rgb()/oklch() forms a browser emits. */
[data-case="Chromatic Hex Edge"] { box-shadow: inset 4px 0 0 #6366f1; }
[data-case="Named Red Edge"] { box-shadow: inset 4px 0 0 red; }
[data-case="Chromatic Rgb Edge"] { box-shadow: inset 4px 0 0 rgb(99, 102, 241); }
[data-case="Chromatic Oklch Edge"] { box-shadow: inset 4px 0 0 oklch(65% 0.18 250); }
[data-case="Black Hex Edge"] { box-shadow: inset 4px 0 0 #000; }
[data-case="Black Named Edge"] { box-shadow: inset 4px 0 0 black; }
[data-case="Gray Hex Edge"] { box-shadow: inset 4px 0 0 #e5e7eb; }
[data-case="Dimgray Named Edge"] { box-shadow: inset 4px 0 0 dimgray; }
[data-case="Black Rgb Edge"] { box-shadow: inset 4px 0 0 rgb(0, 0, 0); }
[data-case="Shorthand Neutral Hex Edge"] { box-shadow: inset 4px 0 0 #1118; }
/* `inset` is order-independent per spec; these paint the same stripe as above. */
[data-case="Trailing Inset Edge"] { box-shadow: 4px 0 0 #6366f1 inset; }
[data-case="Trailing Inset Token Edge"] { box-shadow: 4px 0 0 var(--brand-accent) inset; }
/* The keyword must only be stripped standalone: this token merely contains it. */
[data-case="Inset Named Token Edge"] { box-shadow: inset 4px 0 0 var(--inset-accent); }
[data-case="Trailing Inset Neutral Edge"] { box-shadow: 4px 0 0 #000 inset; }
/* Commented-out rules are not live CSS.
[data-case="Commented Out Edge"] { box-shadow: inset 4px 0 0 var(--brand-accent); }
*/
</style>
-128
View File
@@ -53,9 +53,7 @@ import {
extractFindingIgnoreValue,
resolveProjectPlatform,
isNativePlatform,
normalizeIgnoreValueEntries,
} from '../skill/scripts/hook-lib.mjs';
import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs';
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
function mkTmp() {
@@ -566,132 +564,6 @@ describe('hook-admin.mjs', () => {
assert.match(status, /ignoreValues:\s+overused-font=inter/);
});
// detector.ignoreValues honours a `files` scope, which is the narrowest way to
// silence one noisy rule on one file. hook-admin could not write it, so the
// only reachable option was ignore-file, which silences every rule for that
// file forever.
it('ignore-value scopes a wildcard to files via --file', () => {
const out = runAdmin([
'ignore-value', 'design-system-font-size', '*',
'--file', 'src/overlay/widget.js',
'--reason', 'Widget builds its own type scale',
]);
assert.match(out, /scoped to src\/overlay\/widget\.js/);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
assert.deepEqual(shared.ignoreValues, [{
rule: 'design-system-font-size',
value: '*',
files: ['src/overlay/widget.js'],
createdAt: shared.ignoreValues[0].createdAt,
reason: 'Widget builds its own type scale',
}]);
});
it('ignore-value accepts --file=, --files= and repeated --file', () => {
runAdmin(['ignore-value', 'side-tab', '*', '--file=a.css']);
runAdmin(['ignore-value', 'side-tab', '*', '--files=b.css']);
runAdmin(['ignore-value', 'low-contrast', '*', '--file', 'c.css', '--file', 'd.css']);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
assert.deepEqual(
shared.ignoreValues.map(({ rule, files }) => ({ rule, files })),
[
{ rule: 'side-tab', files: ['a.css'] },
{ rule: 'side-tab', files: ['b.css'] },
{ rule: 'low-contrast', files: ['c.css', 'd.css'] },
],
'each distinct file scope is its own entry; a rule+value-only key overwrote them',
);
});
it('ignore-value refuses a wildcard with no file scope', () => {
assert.throws(
() => runAdmin(['ignore-value', 'design-system-font-size', '*']),
/Wildcard value ignores must be scoped with --file/,
'a bare wildcard is ignore-rule\'s job, not a per-file waiver',
);
assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config');
});
it('ignore-value --file requires a glob', () => {
assert.throws(
() => runAdmin(['ignore-value', 'side-tab', '*', '--file']),
/--file requires a glob/,
);
});
it('ignore-value rejects an unknown flag instead of folding it into the value', () => {
// `--shard` (a typo for --shared) used to store the value "inter --shard",
// which matches nothing, while reporting a successful suppression.
assert.throws(
() => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shard']),
/Unknown ignore-value flag: --shard/,
);
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
});
// Every write runs the entries through normalizeIgnoreValueEntries. Emitting a
// different key order than the one on disk rewrote all untouched entries.
it('an unrelated edit leaves existing ignoreValues byte-identical', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
const seeded = {
detector: {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [
{
rule: 'bounce-easing',
value: 'bounce-ball',
createdAt: '2026-06-15T04:15:03.164Z',
reason: 'Intentional',
},
{
rule: 'design-system-color',
value: '*',
files: ['site/styles/demo.css'],
createdAt: '2026-06-15T23:37:38.170Z',
reason: 'Deliberate off-system demo',
},
],
},
};
fs.writeFileSync(getConfigPath(cwd), JSON.stringify(seeded, null, 2) + '\n');
const before = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector.ignoreValues;
runAdmin(['ignore-file', 'some/other/**']);
const after = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
assert.deepEqual(after.ignoreFiles, ['some/other/**'], 'the intended change still lands');
assert.equal(
JSON.stringify(after.ignoreValues),
JSON.stringify(before),
'untouched ignoreValues must keep their exact key order, or every config diff churns',
);
});
// hook-lib.mjs (skill, ships into harness dirs) and cli/lib/impeccable-config.mjs
// (CLI + Pages functions) carry independent copies of this normalizer by
// necessity. They write the same file, so a key-order drift between them makes
// the config churn depending on which tool touched it last.
it('both config normalizers emit identical entries', () => {
const input = [
{ rule: 'BOUNCE-EASING', value: 'Bounce-Ball', reason: ' r ', createdAt: '2026-01-01T00:00:00.000Z' },
{ rule: 'design-system-color', value: '*', files: [' a.css ', 'b.css', 'a.css'], createdAt: '2026-02-02T00:00:00.000Z' },
{ rule: 'side-tab', value: '*', file: 'legacy.css' },
{ rule: '', value: 'dropped' },
];
assert.equal(
JSON.stringify(normalizeIgnoreValueEntries(input)),
JSON.stringify(normalizeIgnoreValueEntriesCli(input)),
'skill/scripts/hook-lib.mjs and cli/lib/impeccable-config.mjs must agree, key order included',
);
// And pin the canonical order itself, which is what the config on disk uses.
const full = { rule: 'side-tab', value: '*', files: ['a.css'], createdAt: '2026-01-01T00:00:00.000Z', reason: 'r' };
assert.deepEqual(
Object.keys(normalizeIgnoreValueEntries([full])[0]),
['rule', 'value', 'files', 'createdAt', 'reason'],
);
});
it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
// A recorded per-developer consent in the local file...
-10
View File
@@ -74,16 +74,6 @@ describe('live-accept — marker search must ignore Impeccable state', () => {
assert.doesNotMatch(source, /impeccable-variants-start/, 'the wrapper must be gone from real source');
});
it('retires the sessions staged artifacts and leaves other sessions alone', () => {
seed();
const dir = join(tmp, '.impeccable', 'live', 'artifacts');
writeFileSync(join(dir, 'ffff0000-r1.astro'), SOURCE);
runAccept(tmp, ['--id', 'ab12cd34', '--variant', '1']);
assert.equal(existsSync(join(dir, 'ab12cd34-r1.astro')), false, 'own artifacts must not outlive the session');
assert.equal(existsSync(join(dir, 'ab12cd34-r3.astro')), false);
assert.equal(existsSync(join(dir, 'ffff0000-r1.astro')), true, 'another sessions artifacts must survive');
});
it('discards into real source with an artifact decoy present', () => {
seed({ revisions: 1 });
const result = runAccept(tmp, ['--id', 'ab12cd34', '--discard']);
-165
View File
@@ -1,165 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
assembleSplitProgressiveOutput,
buildInteractionRun,
compareModelBackedReports,
createTraceRecorder,
durationBetween,
summarizeRuns,
} from '../scripts/lib/live-benchmark.mjs';
describe('live benchmark metrics', () => {
it('keeps published progressive CSS byte-stable and carries deferred params', () => {
const firstCss = '@scope ([data-impeccable-variant="1"]) { .offer { color: red; } }';
const laterCss = [
'@scope ([data-impeccable-variant="2"]) { .offer { color: green; } }',
'@scope ([data-impeccable-variant="3"]) { .offer { color: blue; } }',
].join('\n');
const firstVariant = { innerHtml: '<article class="offer">One</article>', params: [] };
const deferredParams = [{ name: 'density', type: 'range', min: 0, max: 1, default: 0.5 }];
const assembled = assembleSplitProgressiveOutput(
{ scopedCss: firstCss, variants: [firstVariant] },
{
scopedCss: laterCss,
variants: [
{ innerHtml: firstVariant.innerHtml, params: deferredParams },
{ innerHtml: '<article class="offer">Two</article>', params: [] },
{ innerHtml: '<article class="offer">Three</article>', params: [] },
],
},
);
assert.equal(assembled.scopedCss, `${firstCss}\n${laterCss}`);
assert.equal(assembled.scopedCss.slice(0, firstCss.length), firstCss);
assert.equal(assembled.variants[0].innerHtml, firstVariant.innerHtml);
assert.equal(assembled.variants[0].params, deferredParams);
});
it('rejects tail CSS that would reproduce published_variant_css_changed', () => {
const first = {
scopedCss: '@scope ([data-impeccable-variant="1"]) { .offer { color: red; } }',
variants: [{ innerHtml: '<article class="offer">One</article>', params: [] }],
};
const conflictingTail = {
scopedCss: [
'@scope ([data-impeccable-variant="1"]) { .offer { color: purple; } }',
'@scope ([data-impeccable-variant="2"]) { .offer { color: green; } }',
].join('\n'),
variants: [
{ innerHtml: first.variants[0].innerHtml, params: [] },
{ innerHtml: '<article class="offer">Two</article>', params: [] },
],
};
assert.throws(
() => assembleSplitProgressiveOutput(first, conflictingTail),
/must not repeat or conflict with published variant 1 CSS/,
);
});
it('separates model generation from Impeccable overhead', () => {
const events = [
{ name: 'ui.go.start', at: 100, iteration: 1 },
{ name: 'browser.generate_post', at: 108, id: 'abc', hasScreenshotPath: false, commentCount: 0, strokeCount: 0 },
{ name: 'agent.event.received', at: 110, id: 'abc', type: 'generate' },
{ name: 'agent.scaffold.start', at: 112, id: 'abc' },
{ name: 'agent.scaffold.end', at: 132, id: 'abc' },
{ name: 'agent.generate.start', at: 132, id: 'abc' },
{ name: 'agent.generate.first_ready', at: 1132, id: 'abc' },
{ name: 'agent.generate.end', at: 1132, id: 'abc' },
{ name: 'agent.write.start', at: 1132, id: 'abc' },
{ name: 'agent.write.end', at: 1142, id: 'abc' },
{ name: 'agent.reply.start', at: 1142, id: 'abc' },
{ name: 'agent.reply.end', at: 1147, id: 'abc' },
{ name: 'browser.first_variant', at: 1200, iteration: 1 },
{ name: 'browser.all_variants', at: 1200, iteration: 1 },
];
const run = buildInteractionRun(events, {
iteration: 1,
scenario: 'plain',
goStartedAt: 100,
browserTiming: { goAt: 50, generateAt: 52.5 },
});
assert.equal(run.goToFirstVariantMs, 1094.5);
assert.equal(run.browserPreparationMs, 8);
assert.equal(run.browserDispatchMs, 2.5);
assert.equal(run.automationClickMs, 5.5);
assert.deepEqual(run.annotationEvidence, { screenshotPath: false, comments: 0, strokes: 0 });
assert.equal(run.serverPickupMs, 2);
assert.equal(run.generationMs, 1000);
assert.equal(run.impeccableOverheadMs, 94.5);
assert.equal(run.deliveryGapMs, 0);
assert.equal(run.scaffoldMs, 20);
});
it('reports interpolated medians and p95 values', () => {
const summary = summarizeRuns([
{ goToFirstVariantMs: 100, generationMs: 70 },
{ goToFirstVariantMs: 200, generationMs: 140 },
{ goToFirstVariantMs: 300, generationMs: 210 },
]);
assert.equal(summary.metrics.goToFirstVariantMs.median, 200);
assert.equal(summary.metrics.goToFirstVariantMs.p95, 290);
});
it('records monotonic trace events and returns null for missing boundaries', () => {
let now = 0;
const recorder = createTraceRecorder(() => ++now);
recorder.trace('start');
recorder.trace('end');
assert.equal(durationBetween(recorder.events, 'start', 'end'), 1);
assert.equal(durationBetween(recorder.events, 'missing', 'end'), null);
});
it('proves model-backed first-reviewable thresholds with comparable reports', () => {
const atomic = modelReport('atomic', 1000, 1200, 1400, 1500);
const progressive = modelReport('progressive', 500, 700, 1450, 1550);
const comparison = compareModelBackedReports(atomic, progressive);
assert.equal(comparison.passed, true);
assert.equal(comparison.target.medianImprovement, 0.5);
assert.equal(comparison.target.p95Improvement, 0.4167);
});
it('rejects fake, simulated, and mismatched model reports', () => {
const atomic = modelReport('atomic', 1000, 1200, 1400, 1500);
const progressive = modelReport('progressive', 500, 700, 1450, 1550);
assert.throws(
() => compareModelBackedReports({ ...atomic, benchmark: { ...atomic.benchmark, agent: 'fake' } }, progressive),
/model-backed/,
);
assert.throws(
() => compareModelBackedReports(atomic, { ...progressive, benchmark: { ...progressive.benchmark, simulation: { remainingGenerationMs: 1 } } }),
/simulated latency/,
);
assert.throws(
() => compareModelBackedReports(atomic, { ...progressive, benchmark: { ...progressive.benchmark, model: 'other-model' } }),
/benchmark mismatch for model/,
);
});
});
function modelReport(delivery, firstMedian, firstP95, allMedian, allP95) {
return {
benchmark: {
fixture: 'vite8-react-plain',
agent: 'llm',
provider: 'anthropic',
model: 'claude-haiku-4-5',
scenario: 'plain',
variants: 3,
delivery,
promptMode: 'synthetic-element-contract',
simulation: null,
},
summary: {
count: 5,
metrics: {
goToFirstVariantMs: { median: firstMedian, p95: firstP95 },
goToAllVariantsMs: { median: allMedian, p95: allP95 },
},
},
};
}
-318
View File
@@ -660,270 +660,7 @@ for (const { name, fixture } of fixtures) {
}
});
if (['vite8-react-plain', 'astro-vite7', 'nextjs-app-router', 'vite8-sveltekit', 'nuxt-vite7'].includes(name) && shouldRunScenario('progressive')) {
it('reveals variant 1 safely while the remaining variants and params are pending', liveE2eTestOptions, async (t) => {
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
t.skip('manual scenario filter is active');
return;
}
const traceEvents = [];
const session = await bootFixtureSession({
name,
fixture,
browser,
agent: createFakeAgent(),
wrapTarget: wrapTargetFromPickedElement,
progressive: true,
progressiveDelayMs: 2500,
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
log: (m) => t.diagnostic(m),
});
const { page, tmp, consoleErrors, teardown } = session;
let sourceFile = null;
try {
await waitForHandshake(page);
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
const originalCopy = await page.locator(pickSelector).innerText();
await pickElement(page, pickSelector);
await clickGo(page);
const partial = await waitForProgressiveReviewState(page, 3);
assert.equal(partial.arrived, 1, 'exactly variant 1 is present during the progressive interval');
assert.equal(partial.visible, 1, 'variant 1 is the visible review target');
assert.equal(partial.copy, originalCopy, 'variant 1 preserves the picked copy');
assert.notEqual(partial.acceptPointerEvents, 'none', 'Accept is available for the first reviewable variant');
assert.notEqual(partial.discardPointerEvents, 'none', 'Discard can cancel unfinished generation');
assert.equal(partial.hasParams, false, 'variant 1 has no eager parameter manifest');
assert.equal(partial.tuneVisible, true, 'Tune stays visible while parameter generation is outstanding');
assert.equal(partial.tuneDisabled, true, 'pending Tune is non-interactive until controls arrive');
assert.match(partial.tuneTitle || '', /still being prepared/, 'pending Tune explains its loading state');
assert.equal(partial.paramsPanelVisible, false, 'the Tune popover stays closed until parameter delivery');
sourceFile = await locateSessionFile(tmp);
const isComponentPreview = sourceFile.endsWith('manifest.json');
if (isComponentPreview) {
const manifest = JSON.parse(readFileSync(sourceFile, 'utf-8'));
sourceFile = join(tmp, manifest.sourceFile);
const extension = manifest.componentExtension || 'svelte';
assert.equal(existsSync(join(tmp, manifest.componentDir, `v1.${extension}`)), true, 'partial component preview contains variant 1');
assert.equal(existsSync(join(tmp, manifest.componentDir, 'params.json')), false, 'partial component preview defers parameter manifests');
} else {
const partialSource = readFileSync(sourceFile, 'utf-8');
assert.equal(countSourceVariants(partialSource), 1, 'partial source contains one reviewable variant');
assert.doesNotMatch(partialSource, /data-impeccable-params=/, 'partial source defers parameter manifests');
}
// Keyboard Accept must durably fence the worker before its delayed
// second publication, then return the browser to picking without
// waiting for variants the user no longer wants.
const acceptClickedAt = Date.now();
await clickAccept(page, { expectedVariant: 1 });
await waitForBarHidden(page);
await page.waitForFunction(
() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING',
{ timeout: 2_000 },
);
const automationAcceptToPickingMs = Date.now() - acceptClickedAt;
const browserAcceptToPickingMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
const acceptToPickingMs = Number.isFinite(browserAcceptToPickingMs) && browserAcceptToPickingMs > 0
? browserAcceptToPickingMs
: automationAcceptToPickingMs;
t.diagnostic(`Accept dispatch → picker ready: ${acceptToPickingMs}ms (${automationAcceptToPickingMs}ms including Playwright actionability)`);
assert.ok(acceptToPickingMs < 500, `Accept should release the picker within 500ms of dispatch; got ${acceptToPickingMs}ms`);
const finalSource = await waitForSourceClean(sourceFile, 20_000);
assert.match(finalSource, new RegExp(escapeRegExp(originalCopy)), 'early accepted source preserves the original copy');
assert.doesNotMatch(finalSource, /data-impeccable-variant=/, 'early accepted source is free of preview scaffolding');
assert.equal(countSourceVariants(finalSource), 0, 'the delayed worker cannot reinsert later variants');
const firstGenerateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
// Give framework HMR one paint to settle the newly committed tree;
// this stays inside the 1.5s next-pick budget and avoids selecting a
// node instance React is replacing in the same frame.
if (name === 'nextjs-app-router' || name === 'vite8-sveltekit' || name === 'nuxt-vite7') await waitForHandshake(page);
await page.waitForTimeout(250);
await page.mouse.move(1, 1);
const nextPickSelector = name === 'nextjs-app-router'
? 'main.page'
: name === 'vite8-sveltekit'
? 'article.feature-card'
: name === 'nuxt-vite7'
? 'main.page'
: '.hero-hook';
await pickElement(page, nextPickSelector, {
resetPickMode: name === 'nextjs-app-router' || name === 'nuxt-vite7',
position: name === 'nuxt-vite7' ? { x: 12, y: 12 } : undefined,
});
const nextGoAt = Date.now();
await clickGo(page);
let nextGenerateTrace = null;
const pickupDeadline = Date.now() + 1_500;
while (Date.now() < pickupDeadline) {
nextGenerateTrace = traceEvents.find((event) => (
event.name === 'agent.event.received'
&& event.type === 'generate'
&& event.id !== firstGenerateId
));
if (nextGenerateTrace) break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.ok(nextGenerateTrace, 'the poll supervisor picks up the next generation while the canceled worker unwinds');
const nextDispatchToPickupMs = nextGenerateTrace.at - nextGenerateTrace.clientSentAt;
assert.ok(
nextDispatchToPickupMs < 1_500,
`next generation pickup should stay below 1.5s from dispatch; got ${nextDispatchToPickupMs}ms`,
);
t.diagnostic(`Next Go dispatch → generation pickup: ${nextDispatchToPickupMs}ms (${nextGenerateTrace.at - nextGoAt}ms including Playwright actionability)`);
if (process.env.IMPECCABLE_E2E_METRICS_FILE) {
appendFileSync(process.env.IMPECCABLE_E2E_METRICS_FILE, JSON.stringify({
acceptToPickingMs,
nextGoToPickupMs: nextDispatchToPickupMs,
automationAcceptToPickingMs,
automationNextGoToPickupMs: nextGenerateTrace.at - nextGoAt,
fixture: name,
at: new Date().toISOString(),
}) + '\n');
}
assert.ok(
traceEvents.some((event) => event.name === 'agent.scaffold.reused'),
'agent reuses the server preflight scaffold',
);
assert.equal(
traceEvents.some((event) => event.name === 'agent.scaffold.start'),
false,
'agent does not repeat deterministic source discovery after preflight',
);
const generateTrace = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate');
assert.ok(generateTrace?.id, 'generate trace exposes the durable session id');
const generationTimings = await waitForGenerationTimings(tmp, generateTrace.id, { requireAllVariants: false });
assert.ok(generationTimings.generation_ready?.at, 'durable timing records when generation work can start');
assert.ok(generationTimings.first_reviewable?.at, 'durable timing records the first reviewable variant');
assert.equal(generationTimings.all_variants_ready, undefined, 'canceled work never records all variants ready');
const realErrors = consoleErrors.filter((error) =>
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
);
if (fixture.runtime.probe?.expectConsoleClean) {
assert.deepEqual(realErrors, [], 'progressive HMR and early-action guards produce no browser errors');
} else if (realErrors.length > 0) {
t.diagnostic(`Known framework HMR console noise during progressive source rewrites: ${realErrors.length} error(s)`);
for (const error of realErrors) t.diagnostic(error.split('\n')[0]);
}
} finally {
await teardownAndResetBrowser(teardown);
}
});
}
if (name === 'vite8-react-plain' && shouldRunScenario('progressive')) {
it('accepts variant 2 while variant 3 is still pending', liveE2eTestOptions, async (t) => {
const traceEvents = [];
const session = await bootFixtureSession({
name,
fixture,
browser,
agent: createFakeAgent(),
wrapTarget: wrapTargetFromPickedElement,
progressive: true,
progressiveInitialCount: 2,
progressiveDelayMs: 2500,
trace: (eventName, data = {}) => traceEvents.push({ name: eventName, at: Date.now(), ...data }),
log: (m) => t.diagnostic(m),
});
const { page, tmp, consoleErrors, teardown } = session;
try {
await waitForHandshake(page);
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
const originalCopy = await page.locator(pickSelector).innerText();
await pickElement(page, pickSelector);
await clickGo(page);
const partial = await waitForProgressiveReviewState(page, 3, { arrived: 2, visible: 1 });
assert.equal(partial.arrived, 2, 'variants 1 and 2 arrive before variant 3');
assert.equal(partial.visible, 1, 'variant 1 remains visible until the user advances');
assert.notEqual(partial.acceptPointerEvents, 'none', 'arrived variants remain actionable while the tail is pending');
assert.equal(partial.hasParams, false, 'the partial two-variant revision still defers parameter manifests');
await clickNext(page);
const second = await readProgressiveReviewState(page);
assert.equal(second.visible, 2, 'variant 2 is reviewable before variant 3 exists');
assert.equal(second.copy, originalCopy, 'variant 2 preserves the picked copy');
const wrappedSource = await locateSessionFile(tmp);
const acceptStartedAt = Date.now();
await clickAccept(page, { expectedVariant: 2 });
await waitForBarHidden(page);
await page.waitForFunction(
() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING',
{ timeout: 2_000 },
);
const browserAcceptMs = Number(await page.evaluate(() => document.documentElement.dataset.impeccableAcceptToPickingMs));
const acceptToPickingMs = Number.isFinite(browserAcceptMs) && browserAcceptMs > 0
? browserAcceptMs
: Date.now() - acceptStartedAt;
assert.ok(acceptToPickingMs < 500, `variant 2 Accept should release the picker within 500ms; got ${acceptToPickingMs}ms`);
const cleanSource = await waitForSourceClean(wrappedSource, 20_000);
assert.match(cleanSource, new RegExp(escapeRegExp(originalCopy)), 'accepted variant 2 preserves source copy');
assert.doesNotMatch(cleanSource, /data-impeccable-variant=/, 'accepted variant 2 leaves no preview scaffolding');
await page.waitForTimeout(2750);
assert.doesNotMatch(readFileSync(wrappedSource, 'utf-8'), /data-impeccable-variant=/, 'the delayed variant 3 write stays fenced');
const generateId = traceEvents.find((event) => event.name === 'agent.event.received' && event.type === 'generate')?.id;
const timings = await waitForGenerationTimings(tmp, generateId, { requireAllVariants: false });
assert.equal(timings.all_variants_ready, undefined, 'accepting variant 2 cancels the unfinished third variant');
const realErrors = consoleErrors.filter((error) =>
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(error),
);
assert.deepEqual(realErrors, [], 'variant 2 early Accept stays console-clean');
} finally {
await teardownAndResetBrowser(teardown);
}
});
it('promotes pending Tune controls when the params-only revision arrives', liveE2eTestOptions, async (t) => {
const session = await bootFixtureSession({
name,
fixture,
browser,
agent: createFakeAgent(),
wrapTarget: wrapTargetFromPickedElement,
progressive: true,
progressiveDelayMs: 1500,
log: (message) => t.diagnostic(message),
});
const { page, teardown } = session;
try {
await waitForHandshake(page);
await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title');
await clickGo(page);
const pending = await waitForProgressiveReviewState(page, 3);
assert.equal(pending.tuneVisible, true);
assert.equal(pending.tuneDisabled, true);
await page.waitForFunction(() => {
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const tune = root.querySelector('[data-iceq-tune="1"]');
const wrapper = document.querySelector('[data-impeccable-variants]');
return tune?.disabled === false
&& !!wrapper?.querySelector('[data-impeccable-params]');
}, { timeout: 10_000 });
const ready = await readProgressiveReviewState(page);
assert.equal(ready.arrived, 3, 'all variants remain mounted after params publication');
assert.equal(ready.tuneVisible, true);
assert.equal(ready.tuneDisabled, false, 'Tune becomes actionable without another variant arrival');
await clickDiscard(page);
await page.waitForFunction(() => window.__IMPECCABLE_LIVE_STATE__ === 'PICKING', { timeout: 2_000 });
} 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 || '';
@@ -1074,61 +811,6 @@ function recordGenerateEvents(agent, events) {
};
}
async function waitForProgressiveReviewState(page, expected, { arrived: targetArrived = 1, visible: targetVisible = 1 } = {}) {
await installLiveQueryHelpers(page);
await page.waitForFunction(({ variantCount, targetArrived, targetVisible }) => {
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
const wrapper = query('[data-impeccable-variants]');
const variants = wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const arrived = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '')
? Number(debugState?.arrivedVariants || 0)
: variants?.length;
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const bar = root.querySelector('#impeccable-live-bar');
return arrived === targetArrived
&& new RegExp(`${targetVisible}\\s*\\/\\s*${variantCount}`).test(bar?.textContent || '')
&& /more arriving/.test(bar?.textContent || '');
}, { variantCount: expected, targetArrived, targetVisible }, { timeout: 15_000 });
return readProgressiveReviewState(page);
}
async function readProgressiveReviewState(page) {
await installLiveQueryHelpers(page);
return page.evaluate(() => {
const query = window.__impeccableLiveQuery || ((selector) => document.querySelector(selector));
const wrapper = query('[data-impeccable-variants]');
const variants = [...(wrapper?.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])') || [])];
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
const isSveltePreview = /^(?:svelte|vue)-component$/.test(wrapper?.dataset.impeccablePreview || '');
const visibleVariant = variants.find((variant) => getComputedStyle(variant).display !== 'none');
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| document;
const buttons = [...root.querySelectorAll('#impeccable-live-bar button')];
const accept = buttons.find((button) => /Accept/.test(button.textContent || ''));
const discard = buttons.find((button) => (button.textContent || '').includes('✕'));
const paramsPanel = root.querySelector('#impeccable-live-params-panel');
const tune = root.querySelector('[data-iceq-tune="1"]');
return {
arrived: isSveltePreview ? Number(debugState?.arrivedVariants || 0) : variants.length,
visible: isSveltePreview ? Number(debugState?.visibleVariant || 0) : Number(visibleVariant?.dataset.impeccableVariant || 0),
copy: isSveltePreview ? (wrapper?.innerText || '') : (visibleVariant?.innerText || ''),
acceptPointerEvents: accept ? getComputedStyle(accept).pointerEvents : null,
discardPointerEvents: discard ? getComputedStyle(discard).pointerEvents : null,
hasParams: variants.some((variant) => variant.hasAttribute('data-impeccable-params')),
tuneVisible: !!tune,
tuneDisabled: tune?.disabled ?? null,
tuneTitle: tune?.title || '',
paramsPanelVisible: !!paramsPanel
&& getComputedStyle(paramsPanel).pointerEvents !== 'none'
&& getComputedStyle(paramsPanel).clipPath === 'inset(0px)',
};
});
}
function countSourceVariants(source) {
return (String(source).match(/<div\s+data-impeccable-variant="(?!original")/g) || []).length;
}
+11 -223
View File
@@ -27,10 +27,6 @@ import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { completionTypeForAcceptResult } from '../../skill/scripts/live/completion.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from '../../skill/scripts/live/generation-publisher.mjs';
const execFileP = promisify(execFile);
@@ -1394,36 +1390,6 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
async function publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const prepared = prepareGenerationArtifact({
id: event.id,
sourceFile: wrapInfo.file,
cwd: tmp,
});
if (!prepared.ok) throw new Error(`Svelte publication prepare failed: ${prepared.error}`);
await writeSvelteComponentVariants({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
event,
output,
writeParams,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Svelte publication failed: ${published.error}`);
return published;
}
async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
@@ -1465,92 +1431,6 @@ async function writeVueComponentVariants({ tmp, wrapInfo, event, output, writePa
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
async function publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams = true }) {
const prepared = prepareGenerationArtifact({ id: event.id, sourceFile: wrapInfo.file, cwd: tmp });
if (!prepared.ok) throw new Error(`Vue publication prepare failed: ${prepared.error}`);
await writeVueComponentVariants({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
event,
output,
writeParams,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Vue publication failed: ${published.error}`);
return published;
}
async function publishSourceVariants({ tmp, wrapInfo, event, output }) {
const prepared = prepareGenerationArtifact({
id: event.id,
sourceFile: wrapInfo.file,
cwd: tmp,
});
if (!prepared.ok) throw new Error(`Source publication prepare failed: ${prepared.error}`);
await spliceVariantsIntoWrapper({
tmp,
wrapInfo: { ...wrapInfo, file: prepared.artifactFile },
sessionId: event.id,
output,
});
const published = publishGenerationArtifact({
id: event.id,
epoch: prepared.epoch,
sourceFile: wrapInfo.file,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: output.variants.length,
expectedVariants: event.count,
cwd: tmp,
});
if (!published.ok) throw new Error(`Source publication failed: ${published.error}`);
return published;
}
async function publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants,
signal,
revision = 1,
publicationKind = 'variants',
}) {
const previewMode = wrapInfo.previewMode || 'source';
await fetch(`${base}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
type: 'checkpoint',
id: event.id,
revision,
revisionDomain: 'publication',
phase: 'cycling',
reason: 'variants_progress',
arrivedVariants,
expectedVariants: event.count,
sourceFile: wrapInfo.sourceFile || wrapInfo.file,
previewFile: wrapInfo.file,
previewMode,
publicationKind,
}),
signal,
});
}
function variantMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
@@ -1683,9 +1563,6 @@ export async function runAgentLoop({
signal,
log = () => {},
trace = () => {},
progressive = false,
progressiveDelayMs = 0,
progressiveInitialCount = 1,
atomicDelayMs = 0,
wrapTarget = { classes: 'hero-title', tag: 'h1' },
steerSourceFile,
@@ -1807,91 +1684,16 @@ export async function runAgentLoop({
// Providers may expose a true split path so variant 1 is written before
// the request for the remaining variants completes.
trace('agent.generate.start', { id: event.id, count: event.count });
const splitProgressive = progressive
&& typeof agent.generateFirstVariant === 'function'
&& typeof agent.generateRemainingVariants === 'function'
&& event.count > 1;
let output;
let firstOutput;
if (splitProgressive) {
firstOutput = normalizeVariantOutput(
await agent.generateFirstVariant(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
firstOutput = {
...firstOutput,
variants: firstOutput.variants.slice(0, 1).map((variant) => ({ ...variant, params: [] })),
};
trace('agent.generate.first_ready', { id: event.id, count: firstOutput.variants.length });
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else {
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
}
await publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants: firstOutput.variants.length,
signal,
});
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
output = normalizeVariantOutput(
await agent.generateRemainingVariants(event, { wrapTarget, wrapInfo, firstOutput }),
wrapInfo,
);
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
} else {
output = normalizeVariantOutput(
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
if (!progressive && atomicDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
}
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
if (!progressive || output.variants.length <= 1) {
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
}
if (progressive && output.variants.length > 1) {
const initialCount = Math.max(1, Math.min(
Number(progressiveInitialCount) || 1,
output.variants.length - 1,
));
firstOutput = {
...output,
variants: output.variants
.slice(0, initialCount)
.map((variant) => ({ ...variant, params: [] })),
};
trace('agent.first_variant.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output: firstOutput, writeParams: false });
} else {
await publishSourceVariants({ tmp, wrapInfo, event, output: firstOutput });
}
await publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants: firstOutput.variants.length,
signal,
});
trace('agent.first_variant.write.end', { id: event.id, file: wrapInfo.file });
if (progressiveDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, progressiveDelayMs));
}
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
}
let output = normalizeVariantOutput(
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
wrapInfo,
);
if (atomicDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, atomicDelayMs));
}
trace('agent.generate.first_ready', { id: event.id, count: output?.variants?.length || 0 });
trace('agent.generate.end', { id: event.id, count: output?.variants?.length || 0 });
if (output.variants.length !== event.count) {
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
}
@@ -1899,27 +1701,13 @@ export async function runAgentLoop({
// 3. Write the complete set into the deterministic preview target.
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await publishSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (wrapInfo.previewMode === 'vue-component') {
await publishVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (progressive) {
await publishSourceVariants({ tmp, wrapInfo, event, output });
await writeVueComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else {
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
}
trace('agent.write.end', { id: event.id, file: wrapInfo.file });
if (progressive) {
await publishVariantProgress({
base,
token,
event,
wrapInfo,
arrivedVariants: output.variants.length,
signal,
revision: 2,
publicationKind: 'params',
});
}
if (process.env.IMPECCABLE_E2E_DEBUG) {
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
log(`--- post-splice (variants written) ---\n${post}`);
+1 -11
View File
@@ -236,9 +236,6 @@ export async function bootFixtureSession({
prepareTmp,
log = () => {},
trace = () => {},
progressive = false,
progressiveDelayMs = 0,
progressiveInitialCount = 1,
atomicDelayMs = 0,
keepTmp = false,
}) {
@@ -324,18 +321,11 @@ export async function bootFixtureSession({
wrapTarget,
signal: agentAbort.signal,
trace,
progressive,
progressiveDelayMs,
progressiveInitialCount,
atomicDelayMs,
steerSourceFile: runtime.steer?.sourceFile,
steerTarget: runtime.steer?.target,
};
const loops = [runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })];
if (progressive) {
loops.push(runAgentLoop({ ...loopOptions, log: (m) => log('[supervisor] ' + m) }));
}
agentDone = Promise.all(loops);
agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]);
}
const scheme = runtime.scheme || 'http';
-387
View File
@@ -1,387 +0,0 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
sha256,
} from '../skill/scripts/live/generation-publisher.mjs';
describe('transactional generation publisher', () => {
let tmp;
let source;
let artifact;
let store;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-publisher-'));
source = join(tmp, 'page.html');
artifact = join(tmp, 'variant.html');
writeFileSync(source, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div></div></main>');
store = createLiveSessionStore({ cwd: tmp, sessionId: 'abc12345' });
store.appendEvent({
type: 'generate',
id: 'abc12345',
generationEpoch: 1,
action: 'polish',
count: 3,
element: { outerHTML: '<main>Original</main>' },
});
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('atomically publishes an artifact that matches the fenced source revision', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1">Variant</div></div></main>');
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.arrivedVariants, 1);
assert.equal(readFileSync(source, 'utf-8'), readFileSync(artifact, 'utf-8'));
const snapshot = store.getSnapshot('abc12345');
assert.equal(snapshot.phase, 'variants_progress');
assert.equal(snapshot.publishedRevision, 1);
assert.equal(snapshot.deliveredVariants['1'].digest, result.digest);
});
it('prepares a revision artifact with the current epoch and source fence', () => {
const result = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
assert.equal(result.ok, true);
assert.equal(result.epoch, 1);
assert.equal(result.revision, 1);
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
assert.equal(readFileSync(join(tmp, result.artifactFile), 'utf-8'), readFileSync(source, 'utf-8'));
});
it('rejects a late publication after early accept without touching source', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Late</div></div></main>');
store.appendEvent({ type: 'accept', id: 'abc12345', variantId: '1' });
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
cwd: tmp,
});
assert.deepEqual(result, {
ok: false,
error: 'stale_generation_epoch',
canceled: true,
phase: 'accept_requested',
});
assert.equal(readFileSync(source, 'utf-8'), before);
});
it('rejects a stale artifact when source changed after the worker snapshot', () => {
const before = readFileSync(source, 'utf-8');
writeFileSync(artifact, '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="1">Variant</div></div></main>');
writeFileSync(source, before.replace('Original', 'Changed'));
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(before),
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'source_hash_mismatch');
assert.match(readFileSync(source, 'utf-8'), /Changed/);
});
it('keeps an already reviewable source variant immutable across revisions', () => {
const firstSource = '<main><div data-impeccable-variants="abc12345"><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><section><div>First</div></section></div></div></main>';
writeFileSync(artifact, firstSource);
const first = publishGenerationArtifact({
id: 'abc12345',
epoch: 1,
sourceFile: source,
artifactFile: artifact,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(first.ok, true);
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
const changed = firstSource.replace('First', 'Silently changed')
.replace('</div></div></main>', '</div><div data-impeccable-variant="2">Second</div></div></main>');
writeFileSync(join(tmp, prepared.artifactFile), changed);
const result = publishGenerationArtifact({
id: 'abc12345',
epoch: prepared.epoch,
sourceFile: source,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 2,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_changed');
assert.equal(result.variant, 1);
assert.equal(readFileSync(source, 'utf-8'), firstSource);
});
it('allows the deferred parameter manifest without weakening prior markup immutability', () => {
const firstSource = '<main><div data-impeccable-variants="abc12345"><style data-impeccable-css="abc12345">@scope ([data-impeccable-variant="1"]) { h1 { color: red; } }</style><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><h1>First</h1></div></div></main>';
writeFileSync(artifact, firstSource);
const first = publishGenerationArtifact({
id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
});
assert.equal(first.ok, true);
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
const withParams = firstSource
.replace('<div data-impeccable-variant="1"', '<div data-impeccable-variant="1" data-impeccable-params=\'[{"id":"scale"}]\'')
.replace('</div></main>', '<div data-impeccable-variant="2">Second</div></div></main>');
writeFileSync(join(tmp, prepared.artifactFile), withParams);
const result = publishGenerationArtifact({
id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 2, expectedVariants: 3, cwd: tmp,
});
assert.equal(result.ok, true, JSON.stringify(result));
assert.match(readFileSync(source, 'utf-8'), /data-impeccable-params/);
});
it('rejects later source revisions that restyle an already reviewable variant', () => {
const firstSource = '<main><div data-impeccable-variants="abc12345"><style data-impeccable-css="abc12345">@scope ([data-impeccable-variant="1"]) { :scope > h1 { color: red; } }</style><div data-impeccable-variant="original">Original</div><div data-impeccable-variant="1"><h1>First</h1></div></div></main>';
writeFileSync(artifact, firstSource);
const first = publishGenerationArtifact({
id: 'abc12345', epoch: 1, sourceFile: source, artifactFile: artifact,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')), arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
});
assert.equal(first.ok, true);
const prepared = prepareGenerationArtifact({ id: 'abc12345', sourceFile: source, cwd: tmp });
const changed = firstSource.replace('color: red', 'color: blue');
writeFileSync(join(tmp, prepared.artifactFile), changed);
const result = publishGenerationArtifact({
id: 'abc12345', epoch: prepared.epoch, sourceFile: source, artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash, arrivedVariants: 1, expectedVariants: 3, cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_css_changed', JSON.stringify(result));
assert.equal(readFileSync(source, 'utf-8'), firstSource);
});
});
describe('transactional Svelte component publisher', () => {
let tmp;
let source;
let manifestPath;
let componentDir;
let store;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-svelte-publisher-'));
source = join(tmp, 'src', 'routes', '+page.svelte');
componentDir = join(tmp, 'node_modules', '.impeccable-live', 'svelte123');
manifestPath = join(componentDir, 'manifest.json');
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
mkdirSync(componentDir, { recursive: true });
writeFileSync(source, '<main><h1>{title}</h1></main>\n');
writeFileSync(manifestPath, JSON.stringify({
id: 'svelte123',
previewMode: 'svelte-component',
sourceFile: 'src/routes/+page.svelte',
sourceStartLine: 1,
sourceEndLine: 1,
count: 3,
propContract: [{ prop: 'title', expr: 'title', placeholder: '{title}' }],
originalMarkup: '<main><h1>{title}</h1></main>',
componentDir: 'node_modules/.impeccable-live/svelte123',
runtimeModule: '/node_modules/.impeccable-live/__runtime.js',
}, null, 2) + '\n');
for (let variant = 1; variant <= 3; variant++) {
writeFileSync(join(componentDir, `v${variant}.svelte`), `<main>Stub ${variant}</main>\n`);
}
store = createLiveSessionStore({ cwd: tmp, sessionId: 'svelte123' });
store.appendEvent({
type: 'generate',
id: 'svelte123',
generationEpoch: 1,
action: 'polish',
count: 3,
element: { outerHTML: '<main><h1>Original</h1></main>' },
});
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
it('prepares an isolated component directory fenced against the real route', () => {
const result = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
assert.equal(result.ok, true);
assert.equal(result.previewMode, 'svelte-component');
assert.equal(result.sourceFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
assert.equal(result.targetSourceFile, 'src/routes/+page.svelte');
assert.equal(result.expectedSourceHash, sha256(readFileSync(source, 'utf-8')));
const artifactManifest = JSON.parse(readFileSync(join(tmp, result.artifactFile), 'utf-8'));
assert.equal(artifactManifest.componentDir, result.componentDir);
assert.equal(readFileSync(join(tmp, result.componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
writeFileSync(join(tmp, result.componentDir, 'v1.svelte'), '<main>Prepared only</main>\n');
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>Stub 1</main>\n');
});
it('publishes components before committing the arrived manifest and journals preview metadata', () => {
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const artifactManifestPath = join(tmp, prepared.artifactFile);
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
artifactManifest.arrivedVariants = 1;
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
writeFileSync(join(tmp, prepared.componentDir, 'v1.svelte'), '<main>First live variant</main>\n');
const result = publishGenerationArtifact({
id: 'svelte123',
epoch: prepared.epoch,
sourceFile: manifestPath,
artifactFile: artifactManifestPath,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, true);
assert.equal(result.previewMode, 'svelte-component');
assert.equal(result.sourceFile, 'src/routes/+page.svelte');
assert.equal(result.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
assert.equal(readFileSync(source, 'utf-8'), '<main><h1>{title}</h1></main>\n');
const liveManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
assert.equal(liveManifest.arrivedVariants, 1);
assert.equal(liveManifest.componentDir, 'node_modules/.impeccable-live/svelte123');
const snapshot = store.getSnapshot('svelte123');
assert.equal(snapshot.arrivedVariants, 1);
assert.equal(snapshot.previewMode, 'svelte-component');
assert.equal(snapshot.previewFile, 'node_modules/.impeccable-live/svelte123/manifest.json');
});
it('keeps published variants immutable across later revisions', () => {
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const before = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
const result = publishSveltePrepared(second, {
arrived: 2,
edits: {
1: '<main>Silently changed first variant</main>\n',
2: '<main>Second live variant</main>\n',
},
});
assert.equal(result.ok, false);
assert.equal(result.error, 'published_variant_changed');
assert.equal(result.variant, 1);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), before);
assert.equal(JSON.parse(readFileSync(manifestPath, 'utf-8')).arrivedVariants, 1);
});
it('publishes later variants and params without rewriting an already reviewable variant', () => {
const first = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
publishSveltePrepared(first, { arrived: 1, edits: { 1: '<main>First live variant</main>\n' } });
const second = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
writeFileSync(join(tmp, second.componentDir, 'params.json'), '{"2":[{"id":"density"}]}\n');
const result = publishSveltePrepared(second, {
arrived: 3,
edits: {
2: '<main>Second live variant</main>\n',
3: '<main>Third live variant</main>\n',
},
});
assert.equal(result.ok, true);
assert.equal(result.arrivedVariants, 3);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), '<main>First live variant</main>\n');
assert.equal(readFileSync(join(componentDir, 'v2.svelte'), 'utf-8'), '<main>Second live variant</main>\n');
assert.equal(existsSync(join(componentDir, 'params.json')), true);
assert.deepEqual(JSON.parse(readFileSync(join(componentDir, 'params.json'), 'utf-8')), {
2: [{ id: 'density' }],
});
});
it('rejects a prepared Svelte publication after Accept without touching live artifacts', () => {
const prepared = prepareGenerationArtifact({ id: 'svelte123', sourceFile: manifestPath, cwd: tmp });
const beforeManifest = readFileSync(manifestPath, 'utf-8');
const beforeVariant = readFileSync(join(componentDir, 'v1.svelte'), 'utf-8');
store.appendEvent({ type: 'accept', id: 'svelte123', variantId: '1' });
const result = publishSveltePrepared(prepared, {
arrived: 1,
edits: { 1: '<main>Too late</main>\n' },
});
assert.equal(result.ok, false);
assert.equal(result.error, 'stale_generation_epoch');
assert.equal(readFileSync(manifestPath, 'utf-8'), beforeManifest);
assert.equal(readFileSync(join(componentDir, 'v1.svelte'), 'utf-8'), beforeVariant);
});
it('rejects a live component directory masquerading as a staged artifact', () => {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
manifest.arrivedVariants = 1;
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
const result = publishGenerationArtifact({
id: 'svelte123',
epoch: 1,
sourceFile: manifestPath,
artifactFile: manifestPath,
expectedSourceHash: sha256(readFileSync(source, 'utf-8')),
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'artifact_not_staged');
});
function publishSveltePrepared(prepared, { arrived, edits }) {
const artifactManifestPath = join(tmp, prepared.artifactFile);
const artifactManifest = JSON.parse(readFileSync(artifactManifestPath, 'utf-8'));
artifactManifest.arrivedVariants = arrived;
writeFileSync(artifactManifestPath, JSON.stringify(artifactManifest, null, 2) + '\n');
for (const [variant, content] of Object.entries(edits)) {
writeFileSync(join(tmp, prepared.componentDir, `v${variant}.svelte`), content);
}
return publishGenerationArtifact({
id: 'svelte123',
epoch: prepared.epoch,
sourceFile: manifestPath,
artifactFile: artifactManifestPath,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: arrived,
expectedVariants: 3,
cwd: tmp,
});
}
});
+6 -44
View File
@@ -147,7 +147,7 @@ describe('live reference authoring contract', () => {
);
assert.doesNotMatch(
codexLiveMd,
/<\/?(codex|live-progressive)>/,
/<\/?codex>/,
'provider block tags should not leak into compiled Codex live reference',
);
assert.doesNotMatch(
@@ -157,55 +157,17 @@ describe('live reference authoring contract', () => {
);
});
it('gives progressive delivery to the harnesses that opt in, and only those', () => {
it('routes every helper command through the per-provider scripts path', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
const compileFor = (provider) => compileProviderBlocks(liveMd, PROVIDERS[provider].providerTags);
// Codex delegates to unblock a foreground poll; Claude Code polls in a
// background task. Both can publish variant 1 before the trio is finished.
for (const provider of ['codex', 'agents', 'claude-code']) {
const compiled = compileFor(provider);
assert.match(
compiled,
/Transactional progressive delivery/,
`${provider} should get the progressive publish recipe`,
);
assert.match(
compiled,
/Progressive delivery \(Codex, Claude Code\)/,
`${provider} should get the progressive delivery policy`,
);
}
// Everyone else keeps the atomic single-edit path until their poll loop is
// known not to stall on the extra publish calls.
for (const provider of ['cursor', 'gemini']) {
const compiled = compileFor(provider);
assert.doesNotMatch(
compiled,
/Transactional progressive delivery|Progressive delivery \(Codex, Claude Code\)/,
`${provider} has not opted into progressive delivery`,
);
assert.match(compiled, /\*\*Atomic default:\*\*/, `${provider} should keep the atomic path`);
assert.doesNotMatch(
compiled,
/<\/?live-progressive>/,
`capability block tags should not leak into the compiled ${provider} reference`,
);
}
});
it('routes every live-publish command through the per-provider scripts path', () => {
const liveMd = readFileSync(join(ROOT, 'skill/reference/live.md'), 'utf-8');
// The progressive recipe used to hardcode `.agents/skills/...`, which is only
// correct for the Codex repo-skills bundle. Every other harness would have
// been told to run the publisher from a directory its install never creates.
// A recipe that hardcodes `.agents/skills/...` is only correct for the Codex
// repo-skills bundle. Every other harness would be told to run the helper
// from a directory its install never creates.
assert.doesNotMatch(
liveMd,
/node\s+\.[a-z-]+\/skills\/impeccable\/scripts\//,
'live.md must not hardcode a harness config dir; use {{scripts_path}}',
);
assert.match(liveMd, /node \{\{scripts_path\}\}\/live-publish\.mjs --prepare/);
});
it('keeps live preview CSS guidance capability-mode driven', () => {
-15
View File
@@ -80,21 +80,6 @@ describe('live-session-store', () => {
assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan);
});
it('tracks parameter publication separately from variant arrival', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'parameter-phase' });
store.appendEvent({ type: 'generate', id: 'parameter-phase', count: 3, generationEpoch: 1 });
store.appendEvent({
type: 'variant_published', id: 'parameter-phase', revision: 1,
generationEpoch: 1, arrivedVariants: 3, publicationKind: 'variants',
});
assert.equal(store.getSnapshot('parameter-phase').paramsPublished, false);
store.appendEvent({
type: 'variant_published', id: 'parameter-phase', revision: 2,
generationEpoch: 1, arrivedVariants: 3, publicationKind: 'params',
});
assert.equal(store.getSnapshot('parameter-phase').paramsPublished, true);
});
it('tombstones generation on early accept and ignores late generation writes', () => {
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
store.appendEvent({
-75
View File
@@ -5,10 +5,6 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLiveSessionStore } from '../skill/scripts/live/session-store.mjs';
import {
prepareGenerationArtifact,
publishGenerationArtifact,
} from '../skill/scripts/live/generation-publisher.mjs';
import {
inlineVueComponentAccept,
nuxtViteFsModulePath,
@@ -216,75 +212,4 @@ describe('Nuxt Vue component preview', () => {
assert.equal(existsSync(root), false);
});
it('publishes manifest-last, preserves the route, and rejects late work after Accept', () => {
const result = scaffoldVueComponentSession({
id: 'vue12345',
count: 3,
sourceFile: 'app/pages/index.vue',
sourceStartLine: 3,
sourceEndLine: 3,
originalLines: [' <h1 class="hero-title">Hello {{ user.name }}</h1>'],
cwd: tmp,
});
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'vue12345' });
store.appendEvent({
type: 'generate',
id: 'vue12345',
generationEpoch: 1,
count: 3,
action: 'polish',
element: { outerHTML: '<h1>Hello Paul</h1>' },
});
const routeBefore = readFileSync(source, 'utf-8');
const prepared = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
assert.equal(prepared.ok, true);
assert.equal(prepared.previewMode, 'vue-component');
const artifactManifest = JSON.parse(readFileSync(join(tmp, prepared.artifactFile), 'utf-8'));
artifactManifest.arrivedVariants = 1;
writeFileSync(join(tmp, prepared.artifactFile), JSON.stringify(artifactManifest, null, 2) + '\n');
writeFileSync(join(tmp, prepared.componentDir, 'v1.vue'), '<template><h1>First</h1></template>\n');
const published = publishGenerationArtifact({
id: 'vue12345',
epoch: prepared.epoch,
sourceFile: result.manifestFile,
artifactFile: prepared.artifactFile,
expectedSourceHash: prepared.expectedSourceHash,
arrivedVariants: 1,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(published.ok, true);
assert.equal(published.previewMode, 'vue-component');
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1);
const late = prepareGenerationArtifact({ id: 'vue12345', sourceFile: result.manifestFile, cwd: tmp });
const lateManifest = JSON.parse(readFileSync(join(tmp, late.artifactFile), 'utf-8'));
lateManifest.arrivedVariants = 2;
writeFileSync(join(tmp, late.artifactFile), JSON.stringify(lateManifest, null, 2) + '\n');
writeFileSync(join(tmp, late.componentDir, 'v2.vue'), '<template><h1>Second</h1></template>\n');
store.appendEvent({ type: 'accept', id: 'vue12345', variantId: '1' });
const rejected = publishGenerationArtifact({
id: 'vue12345',
epoch: late.epoch,
sourceFile: result.manifestFile,
artifactFile: late.artifactFile,
expectedSourceHash: late.expectedSourceHash,
arrivedVariants: 2,
expectedVariants: 3,
cwd: tmp,
});
assert.equal(rejected.ok, false);
assert.equal(rejected.error, 'stale_generation_epoch');
assert.equal(readFileSync(source, 'utf-8'), routeBefore);
assert.equal(JSON.parse(readFileSync(join(tmp, result.manifestFile), 'utf-8')).arrivedVariants, 1,
'the manifest must still advertise only the variant published before Accept');
// A rejected publish must not have touched the session dir at all: v2 still
// holds its untouched scaffold stub rather than the late variant's markup.
const v2AfterReject = readFileSync(join(tmp, result.componentDir, 'v2.vue'), 'utf-8');
assert.doesNotMatch(v2AfterReject, /Second/,
'a rejected late publish must not write variant files into the session dir');
assert.match(v2AfterReject, /Variant 2: add scoped CSS here/, 'v2 must still be the scaffold stub');
});
});