Merge remote-tracking branch 'upstream/main' into fix/detect-system-chrome-gpu-window

# Conflicts:
#	scripts/test-suites.mjs
This commit is contained in:
Vinaywho
2026-07-27 15:13:14 +05:30
769 changed files with 41365 additions and 3458 deletions
+80
View File
@@ -386,3 +386,83 @@ describe('bundled skill scripts are self-contained', () => {
expect(broken).toEqual([]);
});
});
describe('degraded-mode fallback reference generation', () => {
const ROOT = process.cwd();
const DEGRADED_TEST_DIR = path.join(ROOT, 'test-tmp-degraded');
const DIST = path.join(DEGRADED_TEST_DIR, 'dist');
const readDegraded = (provider, configDir, role) =>
fs.readFileSync(
path.join(DIST, provider, configDir, 'skills', 'impeccable', 'reference', 'degraded', `${role}.md`),
'utf-8'
);
beforeEach(() => {
if (fs.existsSync(DEGRADED_TEST_DIR)) fs.rmSync(DEGRADED_TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(DEGRADED_TEST_DIR, { recursive: true });
const { skills } = utils.readSourceFiles(ROOT);
transformers.transformClaudeCode(skills, DIST);
transformers.transformCodex(skills, DIST);
});
afterEach(() => {
if (fs.existsSync(DEGRADED_TEST_DIR)) fs.rmSync(DEGRADED_TEST_DIR, { recursive: true, force: true });
});
test('a build emits reference/degraded/<role>.md for every agent, prefix-stripped', () => {
const dir = path.join(DIST, 'codex', '.codex', 'skills', 'impeccable', 'reference', 'degraded');
const files = fs.readdirSync(dir).sort();
expect(files).toEqual([
'asset-producer.md',
'documenter.md',
'finish-reviewer.md',
'manual-edit-applier.md',
]);
});
test('finish-reviewer fallback opens with the preamble and carries a distinctive body phrase', () => {
const content = readDegraded('codex', '.codex', 'finish-reviewer');
expect(content.startsWith('<!-- Generated from skill/agents/ at build time. Do not edit; edit the agent definition. -->'))
.toBe(true);
expect(content).toContain('This harness has no subagent capability, so you are running this role inline.');
// Distinctive phrase from the agent body proves the source body was inlined.
expect(content).toContain('material_fixes');
});
test('generated fallbacks pass through provider-block compilation (codex keeps its block, others strip it)', () => {
// Standalone provider blocks are the shape compileProviderBlocks compiles.
// A synthetic agent proves the degraded path runs the same compilation as
// ordinary reference files, with the right provider tags per target.
const synthetic = {
name: 'impeccable',
description: 'synthetic',
body: 'Synthetic skill body.',
agents: [
{
name: 'impeccable-synthetic',
body: 'Shared body line.\n\n<codex>\nCODEX_ONLY_MARKER for the codex target.\n</codex>\n\nMore shared body.',
},
],
};
const synthDist = path.join(DEGRADED_TEST_DIR, 'synth');
transformers.transformCodex([synthetic], synthDist);
transformers.transformClaudeCode([synthetic], synthDist);
const read = (provider, configDir) =>
fs.readFileSync(
path.join(synthDist, provider, configDir, 'skills', 'impeccable', 'reference', 'degraded', 'synthetic.md'),
'utf-8'
);
const codex = read('codex', '.codex');
const claude = read('claude-code', '.claude');
expect(codex).toContain('CODEX_ONLY_MARKER');
expect(claude).not.toContain('CODEX_ONLY_MARKER');
// Both still carry the preamble and the shared body.
expect(codex.startsWith('<!-- Generated from skill/agents/')).toBe(true);
expect(claude).toContain('More shared body.');
});
test('the source repo contains no hand-authored degraded/ reference files (generation-only)', () => {
expect(fs.existsSync(path.join(ROOT, 'skill', 'reference', 'degraded'))).toBe(false);
});
});
+50
View File
@@ -135,6 +135,56 @@ describe('gatherSignals', () => {
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // README.md filtered out
});
it('filters harness-dir files out of git-changes scan targets (#303)', async () => {
const { execFileSync } = await import('node:child_process');
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
git('init', '-q');
git('config', 'user.email', 't@example.com');
git('config', 'user.name', 'Test');
write('src/Hero.tsx', 'export const Hero = () => null;\n');
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
git('add', '.');
git('commit', '-qm', 'init');
write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty app code
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // dirty vendored skill
const s = await gatherSignals(scratch);
assert.equal(s.scan.via, 'git-changes');
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out
});
it('keeps hidden-source-dir files (VitePress/Storybook) in scan targets', async () => {
const { execFileSync } = await import('node:child_process');
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
git('init', '-q');
git('config', 'user.email', 't@example.com');
git('config', 'user.name', 'Test');
write('.vitepress/theme/Layout.vue', '<template><div/></template>\n');
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
git('add', '.');
git('commit', '-qm', 'init');
write('.vitepress/theme/Layout.vue', '<template><span/></template>\n'); // real UI source
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // vendored
const s = await gatherSignals(scratch);
assert.equal(s.scan.via, 'git-changes');
assert.deepEqual(s.scan.targets, ['.vitepress/theme/Layout.vue']);
});
it('falls through to source dirs when only harness files changed (#303)', async () => {
const { execFileSync } = await import('node:child_process');
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
git('init', '-q');
git('config', 'user.email', 't@example.com');
git('config', 'user.name', 'Test');
write('src/Hero.tsx', 'export const Hero = () => null;\n');
write('.cursor/skills/impeccable/example.css', 'a{}\n');
git('add', '.');
git('commit', '-qm', 'init');
write('.cursor/skills/impeccable/example.css', 'a{color:red}\n'); // only harness dirty
const s = await gatherSignals(scratch);
assert.equal(s.scan.via, 'source-dir');
assert.deepEqual(s.scan.targets, ['src']);
});
it('has empty scan.targets only when there is no code at all', async () => {
const s = await gatherSignals(scratch);
assert.deepEqual(s.scan.targets, []);
@@ -112,6 +112,15 @@ describe('detectUrl — browser-only fixtures', () => {
}
});
it('shadowed form.id: a <form> with <input name="id"> does not crash the scan (issue #407)', async () => {
// HTMLFormElement named-property shadowing makes form.id / form.className
// return the child input element, whose .startsWith throws. Every Shopify
// product form ships <input name="id">, so this crashed the URL scan. The
// scan must complete and return an array of findings instead of throwing.
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/shadowed-form-id.html`);
assert.ok(Array.isArray(f), 'detectUrl must return findings without throwing on a shadowed form.id');
});
it('line-length: flag column triggers, pass column adds none', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
+349 -25
View File
@@ -12,8 +12,10 @@ import { fileURLToPath } from 'url';
import {
detectHtml,
detectText,
formatFindings,
normalizeDesignSystem,
} from '../cli/engine/detect-antipatterns.mjs';
import { checkEmDashOveruse } from '../cli/engine/rules/checks.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
@@ -84,6 +86,74 @@ describe('detectText - Astro structural CSS fixtures', () => {
});
});
describe('detectText — pseudo-element stripe fixtures (issue #394)', () => {
// The side-tab silhouette drawn as an absolutely-positioned ::before/::after
// bar instead of a border. The scanner already ran on full HTML pages via
// checkHtmlPatterns; these pin the standalone-stylesheet and component
// style-block paths, which used to pass this construction clean.
const SHOULD_FLAG = [
'Inset Shorthand Left Edge',
'Longhand Left Edge',
'Bottom Edge',
'Full Height Right Edge',
];
const SHOULD_PASS = [
'Neutral Divider',
'Wide Panel',
'Static Underline',
'Hairline Divider',
'Hover Underline',
'Floating Badge',
// Commented-out CSS is not a live rule.
'Commented Out Stripe',
];
// The 1-based line a case's selector sits on in a fixture file, so the
// reported finding line can be checked against the actual source.
const selectorLine = (source, caseName) => {
const idx = source.split('\n').findIndex(l => l.includes(`data-case="${caseName}"`));
assert.notEqual(idx, -1, `fixture is missing case "${caseName}"`);
return idx + 1;
};
it('standalone .css files flag chromatic pseudo-element stripes only', () => {
const filePath = path.join(FIXTURES, 'pseudo-stripe.css');
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`);
}
// Every finding must carry the selector's real source line, so
// line-scoped inline ignores (impeccable-disable-line and
// impeccable-disable-next-line) can match it.
for (const f of findings) {
const caseName = (f.snippet.match(/data-case="([^"]+)"/) || [])[1];
assert.equal(
f.line, selectorLine(source, caseName),
`finding for "${caseName}" reports line ${f.line}, selector sits on line ${selectorLine(source, caseName)}`,
);
}
});
it('component style blocks flag pseudo-element stripes at their source line', () => {
const filePath = path.join(FIXTURES, 'pseudo-stripe.vue');
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(' | ');
assert.match(snippets, /data-case="Component Left Edge"/, 'expected the component stripe to flag');
assert.doesNotMatch(snippets, /data-case="Component Neutral Divider"/, 'neutral divider should pass');
const stripe = findings.find(r => /data-case="Component Left Edge"/.test(r.snippet || ''));
assert.equal(
stripe.line, selectorLine(source, 'Component Left Edge'),
'style-block finding must map back to the whole-file line, not the block-local one',
);
});
});
describe('detectHtml — static HTML/CSS fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
@@ -279,6 +349,43 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
it('color: gradient-clipped text is not contrast-checked against its own fill (issue #409 Case A)', async () => {
// background-clip: text with a transparent fill paints the glyphs with the
// gradient; the inherited `color` is never painted, so measuring it against
// the element's own gradient stops (#6d8cff / #a78bfa) is a false positive.
// The gradient-text pattern flag still fires; the backdrop-contrast rules
// (low-contrast / gray-on-color) must stay silent for the clipped element.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const clippedContrastFP = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#6d8cff|#a78bfa/i.test(r.snippet || '')
);
assert.equal(
clippedContrastFP.length, 0,
`gradient-clipped text must not be contrast-checked against its own fill, got: ${clippedContrastFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
// The pattern itself must still be surfaced.
assert.ok(
f.some(r => r.antipattern === 'gradient-text'),
'gradient-text pattern flag must still fire'
);
});
it('color: alpha gradient-glow stops composite against the surface beneath (issue #409 Case B)', async () => {
// A 9%-alpha teal glow stop (rgba(52,192,168,0.09)) over a dark section
// composites to ~near-black, not the full-opacity #34c0a8. Text on it is
// high-contrast; treating the stop as opaque flagged every text child.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const glowFP = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#34c0a8/i.test(r.snippet || '')
);
assert.equal(
glowFP.length, 0,
`alpha glow stops must composite against the underlying surface, got: ${glowFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('legitimate-borders: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
@@ -327,6 +434,42 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
it('named-color-borders: named-color side-tabs are flagged, neutral names pass', async () => {
// Regression for issue #359: the static cascade's shorthand color
// extraction recognized only 9 named colors, so `border-left: 4px solid
// purple` (or any of the other named colors parseAnyColor understands)
// lost its color during expansion, defaulted to neutral black, and never
// fired side-tab — while the same declaration in a .css file was flagged
// by the regex engine. The extraction list is now derived from the same
// CSS_NAMED_COLORS table the parser uses, so the two can't drift apart.
const f = await detectHtml(path.join(FIXTURES, 'named-color-borders.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab').map(r => r.snippet).sort();
// Six FLAG cases, each with a unique width/radius signature so every
// finding attributes to exactly one case (an offsetting miss + false
// positive can't cancel out in an aggregate count):
// purple 4px + radius 8 (the issue reproducer), rebeccapurple 5px +
// radius 4 (contains "purple" as a substring — whole-token matching),
// crimson 4px top stripe, bare 3px teal, var() resolving to a named
// color at 6px + radius 4, and a 7px inline style attribute.
// The PASS column (neutral named colors at 3-4px, 1px thin, uniform)
// must contribute nothing — dimgray/gainsboro/black have to parse AND
// read as neutral rather than being dropped as unknown colors, and none
// of its shapes can produce any of the signatures below.
assert.deepEqual(sideTabs, [
'border-left: 3px',
'border-left: 4px + border-radius: 8px',
'border-left: 5px + border-radius: 4px',
'border-left: 6px + border-radius: 4px',
'border-left: 7px',
'border-top: 4px',
]);
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
borderAccent.length, 0,
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
);
});
it('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
const css = `
.flag-side-tab {
@@ -553,6 +696,115 @@ describe('detectHtml — icon-tile-stack', () => {
});
});
describe('detectHtml — radial-spotlight-glow', () => {
// Two-column fixture convention: left col = should-flag, right col = should-pass.
// The rule's snippet embeds the element's data-name in quotes, e.g.
// radial-gradient spotlight glow "Hero Spotlight Blue" (#506fff a0.26 → transparent).
const SHOULD_FLAG = [
'Hero Spotlight Blue',
'Section Glow Violet',
'Overlay Glow Cyan',
'Two Stop Soft Glow',
'Hex Alpha Glow',
];
const SHOULD_PASS = [
'Opaque Radial Background',
'Small Accent Badge',
'Avatar Glow Light',
'Neutral Vignette',
'White Vignette',
'Rich Radial Composition',
'Rich Transparent Composition',
'Opaque Center Glow',
'Linear Gradient Wash',
];
it('radial-spotlight-glow: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'radial-spotlight-glow.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'radial-spotlight-glow') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as radial-spotlight-glow`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as radial-spotlight-glow`);
}
});
});
describe('detectHtml — undersized-ui-text', () => {
// Two-column fixture: left col = should-flag, right col = should-pass.
// The rule's snippet embeds the element's direct text in quotes, e.g.
// `8px functional text "Flag Nav Link" (below 11px floor)`.
// The test extracts those quoted texts and matches them against the lists.
const SHOULD_FLAG = [
'Flag Nav Link', // interactive nav link at 8px
'Flag Category', // non-interactive furniture label at 8px
'Flag Meta Row', // meta row at 9px
'Flag Button', // interactive button at 10px
'Flag Table Cell', // structural table cell at 9px
'Flag Caps Label', // uppercase letterspaced micro-label — NOT exempt
'Flag Footer Link', // interactive text in footer stays on the 11px floor
];
const SHOULD_PASS = [
'Pass Legal Fine Print', // non-interactive footer smallprint at 10px (floor 10)
'Pass Sr Only', // visually-hidden text
'Pass Sup Marker', // sup tag exempt
'Pass Sub Marker', // sub tag exempt
'Pass Em Sized', // 0.6em of a 20px parent = 12px, above the floor
'Pass Terminal Line', // code/terminal mock, legitimately small
'Pass Normal Link', // functional text at the 12px floor
];
it('undersized-ui-text: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'undersized-ui-text.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'undersized-ui-text') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as undersized-ui-text`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as undersized-ui-text`);
}
});
});
describe('detectHtml — non-rendered text (issue #408)', () => {
// On sites that set `html { font-size: 62.5% }` the root computes to 10px, so
// <script>/<style>/<title>/<noscript> and display:none / visibility:hidden
// blocks — whose JS/CSS/JSON-LD text clears the hasDirectText gate — report a
// 10px size and used to produce dozens of phantom "10px body text" findings.
// Both text-size floors (tiny-text and undersized-ui-text) must skip them and
// measure only genuinely rendered text.
it('tiny-text / undersized-ui-text: non-rendered elements produce no findings, rendered text still flags', async () => {
const f = await detectHtml(path.join(FIXTURES, 'nonrendered-text.html'));
const tiny = f.filter(r => r.antipattern === 'tiny-text');
const undersized = f.filter(r => r.antipattern === 'undersized-ui-text');
// Exactly the two genuinely rendered elements flag: the 10px body <p>
// (tiny-text) and the 9px interactive nav link (undersized-ui-text).
assert.equal(
tiny.length, 1,
`expected exactly 1 tiny-text finding (rendered body copy), got ${tiny.length}: ${tiny.map(r => r.snippet).join('; ')}`
);
assert.equal(
undersized.length, 1,
`expected exactly 1 undersized-ui-text finding (rendered nav link), got ${undersized.length}: ${undersized.map(r => r.snippet).join('; ')}`
);
assert.match(undersized[0].snippet || '', /Rendered Nav Link/, 'the one undersized finding must be the rendered nav link');
});
});
describe('detectHtml — quality (static-compatible rules)', () => {
// Six of the eight quality rules can run in static HTML/CSS because they only need
// computed CSS values (tight-leading, tiny-text, justified-text,
@@ -927,52 +1179,68 @@ describe('em-dash overuse — HTML entity escapes', () => {
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>t</title></head>` +
`<body><main><h1>A real page heading of ordinary length</h1><p>${body}</p></main></body></html>`;
// Six dashes clears the 5+ threshold. Sentence fragments keep the surrounding
// prose realistic so nothing else in the pipeline objects.
const sixNamed = 'fast &mdash; cheap &mdash; honest &mdash; simple &mdash; quiet &mdash; kind &mdash; done';
const sixNumeric = 'fast &#8212; cheap &#8212; honest &#8212; simple &#8212; quiet &#8212; kind &#8212; done';
const sixHex = 'fast &#x2014; cheap &#x2014; honest &#x2014; simple &#x2014; quiet &#x2014; kind &#x2014; done';
const sixHexUpper = 'fast &#X2014; cheap &#X2014; honest &#X2014; simple &#X2014; quiet &#X2014; kind &#X2014; done';
const sixNumericPadded = 'fast &#08212; cheap &#08212; honest &#08212; simple &#08212; quiet &#08212; kind &#08212; done';
// Three literal glyphs + three named entities render identically; the count
// must see all six.
const mixed = 'fast — cheap — honest — simple &mdash; quiet &mdash; kind &mdash; done';
// Eight dashes clears the raised advisory floor (EM_DASH_FLOOR = 8, up from
// the old flat 5). Packed into one short paragraph they also clear the density
// gate. Sentence fragments keep the surrounding prose realistic so nothing
// else in the pipeline objects.
const eightNamed = 'fast &mdash; cheap &mdash; honest &mdash; simple &mdash; quiet &mdash; kind &mdash; bright &mdash; calm &mdash; done';
const eightNumeric = 'fast &#8212; cheap &#8212; honest &#8212; simple &#8212; quiet &#8212; kind &#8212; bright &#8212; calm &#8212; done';
const eightHex = 'fast &#x2014; cheap &#x2014; honest &#x2014; simple &#x2014; quiet &#x2014; kind &#x2014; bright &#x2014; calm &#x2014; done';
const eightHexUpper = 'fast &#X2014; cheap &#X2014; honest &#X2014; simple &#X2014; quiet &#X2014; kind &#X2014; bright &#X2014; calm &#X2014; done';
const eightNumericPadded = 'fast &#08212; cheap &#08212; honest &#08212; simple &#08212; quiet &#08212; kind &#08212; bright &#08212; calm &#08212; done';
// Four literal glyphs + four named entities render identically; the count
// must see all eight.
const mixed = 'fast — cheap — honest — simple — quiet &mdash; kind &mdash; bright &mdash; calm &mdash; done';
const SHOULD_FLAG = {
'named &mdash;': sixNamed,
'numeric &#8212;': sixNumeric,
'hex &#x2014;': sixHex,
'uppercase-hex &#X2014;': sixHexUpper,
'zero-padded decimal &#08212;': sixNumericPadded,
'named &mdash;': eightNamed,
'numeric &#8212;': eightNumeric,
'hex &#x2014;': eightHex,
'uppercase-hex &#X2014;': eightHexUpper,
'zero-padded decimal &#08212;': eightNumericPadded,
'mixed literal + entity': mixed,
};
// A long paragraph carrying exactly eight dashes across several thousand
// characters of prose. Above the absolute floor, but the density gate
// (one per ~500 chars) keeps ordinary long-form writing from flagging.
const longLowDensityFiller = 'This paragraph is written in ordinary human prose that runs on for quite a while. '.repeat(60);
const longLowDensity = `a — b — c — d — e — f — g — h — end. ${longLowDensityFiller}`;
// False-positive shapes: none of these should trip the em-dash counter.
const SHOULD_PASS = {
// Below the 5+ threshold: occasional em-dash entity use is legitimate prose.
// Below the floor: seven dashes on a short page is under the raised floor of 8.
'seven dashes below floor': 'a — b — c — d — e — f — g — done, otherwise plain sentences fill the paragraph body',
// Below the floor: occasional em-dash entity use is legitimate prose.
'two entities below threshold': 'fast &mdash; cheap &mdash; done, otherwise plain sentences fill the paragraph body',
// Above the floor but below the density gate: a long human article.
'eight dashes across a long article': longLowDensity,
// En-dashes are a different character and a different job (ranges); the em-dash
// rule must not decode or count them.
'en-dash entities': 'pages 10&ndash;20 and 30&ndash;40 and 50&ndash;60 and 70&ndash;80 and 90&ndash;100 and 1&ndash;2',
'numeric en-dash entities': 'pages 10&#8211;20 and 30&#8211;40 and 50&#8211;60 and 70&#8211;80 and 90&#8211;100 and 1&#8211;2',
'en-dash entities': 'pages 10&ndash;20 and 30&ndash;40 and 50&ndash;60 and 70&ndash;80 and 90&ndash;100 and 1&ndash;2 and 3&ndash;4 and 5&ndash;6 and 7&ndash;8',
'numeric en-dash entities': 'pages 10&#8211;20 and 30&#8211;40 and 50&#8211;60 and 70&#8211;80 and 90&#8211;100 and 1&#8211;2 and 3&#8211;4 and 5&#8211;6',
// Double-escaped: the visible text is the literal string "&mdash;", not a dash.
'double-escaped ampersand': 'write &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; literally',
'double-escaped ampersand': 'write &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; and &amp;mdash; literally',
// Unrelated entities must never be miscounted as dashes.
'non-dash entities': 'a&nbsp;b &copy; c &hellip; d &amp; e &trade; f &reg; g &deg; h &sect; i &para;',
// Ordinary hyphenated compounds are single hyphens, not the double-hyphen tell.
'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance copy',
'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance, day-to-day, off-the-shelf copy',
};
const emDashCount = (findings) =>
findings.filter((r) => r.antipattern === 'em-dash-overuse').length;
const emDashFindings = (findings) =>
findings.filter((r) => r.antipattern === 'em-dash-overuse');
for (const [label, body] of Object.entries(SHOULD_FLAG)) {
it(`flags em-dash overuse spelled as ${label}`, () => {
const findings = detectText(page(body), 'em-dash.html');
const hits = emDashFindings(findings);
assert.equal(
emDashCount(findings), 1,
hits.length, 1,
`expected em-dash-overuse for "${label}", got: ${findings.map((r) => r.antipattern).join(', ') || 'none'}`,
);
// The rule is advisory: the finding must carry the flag so the CLI, JSON,
// and hook can partition it out of the failure set.
assert.equal(hits[0].advisory, true, `"${label}" finding should be marked advisory`);
});
}
@@ -980,7 +1248,7 @@ describe('em-dash overuse — HTML entity escapes', () => {
it(`does not flag ${label}`, () => {
const findings = detectText(page(body), 'em-dash.html');
assert.equal(
emDashCount(findings), 0,
emDashFindings(findings).length, 0,
`"${label}" should not flag em-dash overuse`,
);
});
@@ -988,9 +1256,65 @@ describe('em-dash overuse — HTML entity escapes', () => {
it('static-HTML path decodes entity em-dashes too (fixture file)', async () => {
const findings = await detectHtml(path.join(FIXTURES, 'em-dash-entities.html'));
const hits = findings.filter((r) => r.antipattern === 'em-dash-overuse');
assert.equal(
findings.filter((r) => r.antipattern === 'em-dash-overuse').length, 1,
hits.length, 1,
'em-dash-entities.html should flag em-dash overuse via the static-HTML path',
);
assert.equal(hits[0].advisory, true, 'static-HTML em-dash finding should be advisory');
});
});
describe('formatFindings — advisory partitioning', () => {
const primary = { antipattern: 'side-tab', name: 'Side-tab', description: 'A primary finding.', file: 'a.css', line: 1, snippet: 'x' };
const advisory = { antipattern: 'em-dash-overuse', name: 'Em-dash', description: 'An advisory finding.', file: 'a.html', line: 0, snippet: '8 em-dashes', advisory: true };
it('lists advisory findings in a separate section and excludes them from the failure count', () => {
const text = formatFindings([primary, advisory], false);
assert.match(text, /1 anti-pattern found\./); // primary count only
assert.match(text, /Advisory \(not counted as failures\)/);
assert.match(text, /em-dash-overuse/);
assert.match(text, /1 advisory note/);
});
it('reports zero failures for an advisory-only set but still shows the advisory section', () => {
const text = formatFindings([advisory], false);
assert.match(text, /0 anti-patterns found\./);
assert.match(text, /em-dash-overuse/);
});
it('keeps every finding (advisory flagged) in JSON output', () => {
const json = JSON.parse(formatFindings([primary, advisory], true));
assert.equal(json.length, 2);
assert.equal(json.find((f) => f.antipattern === 'em-dash-overuse').advisory, true);
assert.equal(json.find((f) => f.antipattern === 'side-tab').advisory, undefined);
});
});
describe('em-dash overuse — browser adapter parity (checkEmDashOveruse)', () => {
// The browser DOM check operates on already-rendered text, so it exercises
// the same two-gate logic without entity decoding. checkEmDashOveruse is the
// pure core the DOM wrapper calls.
const id = (findings) => findings.map((f) => f.id).join(',');
it('flags eight dense em-dashes', () => {
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — h — done');
assert.equal(id(findings), 'em-dash-overuse');
});
it('does not flag seven em-dashes (below the floor)', () => {
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — done');
assert.equal(findings.length, 0);
});
it('does not flag eight em-dashes spread across long prose (density gate)', () => {
const filler = 'This is ordinary human prose that continues at length. '.repeat(80);
const findings = checkEmDashOveruse(`a — b — c — d — e — f — g — h — end. ${filler}`);
assert.equal(findings.length, 0);
});
it('counts the double-hyphen em-dash substitute', () => {
const findings = checkEmDashOveruse('a--b c--d e--f g--h i--j k--l m--n o--p done');
assert.equal(id(findings), 'em-dash-overuse');
});
});
+49
View File
@@ -1828,6 +1828,55 @@ describe('walkDir', () => {
test('returns empty for nonexistent dir', () => {
expect(walkDir('/nonexistent/path/12345')).toHaveLength(0);
});
// Issue #303: when impeccable (or any agent tool) is installed into a
// project's .claude/.cursor/etc. tree, a root scan descended into the
// vendored skill code and reported the detector's own example strings as
// findings. Hidden directories are never app source — skip them all.
test('skips hidden dirs (AI-harness installs) during recursion', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-'));
try {
const write = (rel) => {
const abs = path.join(tmp, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, '/* fixture */');
};
write('src/app.css');
write('.claude/skills/impeccable/scripts/detector.js');
write('.cursor/skills/impeccable/example.css');
write('.impeccable/live/preview.html');
write('node_modules/pkg/index.js');
// Hidden dirs that conventionally hold real UI source are the
// exception: VitePress themes and Storybook preview files must keep
// being scanned (they were before the hidden-dir rule existed).
write('.vitepress/theme/Layout.vue');
write('.vuepress/theme/Layout.vue');
write('.storybook/preview.css');
const files = walkDir(tmp).sort();
expect(files).toEqual([
path.join(tmp, '.storybook', 'preview.css'),
path.join(tmp, '.vitepress', 'theme', 'Layout.vue'),
path.join(tmp, '.vuepress', 'theme', 'Layout.vue'),
path.join(tmp, 'src', 'app.css'),
]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('still scans a hidden dir passed as the explicit target', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-'));
try {
const abs = path.join(tmp, '.claude', 'page.html');
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, '<html></html>');
// Only child entries are name-checked; naming the hidden dir directly
// is an explicit user intent and must keep working.
expect(walkDir(path.join(tmp, '.claude'))).toEqual([abs]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
@@ -0,0 +1,132 @@
/**
* Regression: `impeccable detect <file-or-dir>` must resolve DESIGN.md from
* EACH scan target's own project root, not from process.cwd().
*
* The bug (found during eval work): running detect from repo A against a file
* that lives in repo B applied A's DESIGN.md to B — cross-project contamination.
* These tests spawn the real CLI so the fix is exercised end to end.
*
* Run with: node --test tests/detect-cli-design-contamination.test.mjs
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI = path.resolve(__dirname, '../cli/bin/cli.js');
// Verdana is a plain web-safe font: it is not in OVERUSED_FONTS and trips no
// standalone rule, so the only way it becomes a `design-system-font` finding is
// if a DESIGN.md that forbids it gets applied.
const PAGE_HTML =
'<!doctype html><html><head><style>.card { font-family: Verdana, sans-serif; }</style></head>' +
'<body><div class="card">Hi</div></body></html>';
// A DESIGN.md whose typography allows only Palatino — Verdana violates it.
const DESIGN_MD = `---
typography:
body:
fontFamily: "Palatino, Georgia, serif"
---
# Project A Design System
`;
const tempRoots = [];
function mkProject({ withDesign, withMarker = true }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-contam-'));
tempRoots.push(dir);
if (withMarker) fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"fixture"}');
if (withDesign) fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
const page = path.join(dir, 'page.html');
fs.writeFileSync(page, PAGE_HTML);
return { dir, page };
}
// Run the CLI from `cwd`; force the node binary so the HTML/jsdom path never
// runs under bun (which is unusably slow).
function runDetect(cwd, targets) {
const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], {
cwd,
encoding: 'utf-8',
});
let findings = [];
try {
findings = JSON.parse(result.stdout || '[]');
} catch {
throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
}
return findings;
}
function fontFindingsFor(findings, file) {
return findings.filter(
(f) => f.antipattern === 'design-system-font' && (!file || f.file === file),
);
}
let projA;
let projB;
before(() => {
projA = mkProject({ withDesign: true }); // DESIGN.md forbids Verdana
projB = mkProject({ withDesign: false }); // its own project, no DESIGN.md
});
after(() => {
for (const dir of tempRoots) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
describe('detect CLI DESIGN.md resolution', () => {
it('does NOT apply cwd project A\'s DESIGN.md to project B\'s file (the contamination bug)', () => {
const findings = runDetect(projA.dir, [projB.page]);
assert.deepEqual(
fontFindingsFor(findings, projB.page).map((f) => f.ignoreValue),
[],
'project B\'s Verdana must not be flagged by project A\'s DESIGN.md',
);
});
it('still applies a project\'s own DESIGN.md to its own file (positive control)', () => {
const findings = runDetect(projA.dir, [projA.page]);
assert.ok(
fontFindingsFor(findings, projA.page).some((f) => f.ignoreValue === 'verdana'),
'project A\'s own DESIGN.md must flag Verdana in project A\'s file',
);
});
it('resolves per target when one scan spans two projects', () => {
const findings = runDetect(projA.dir, [projA.page, projB.page]);
assert.ok(
fontFindingsFor(findings, projA.page).length > 0,
'A\'s file should be judged against A\'s DESIGN.md',
);
assert.equal(
fontFindingsFor(findings, projB.page).length,
0,
'B\'s file should NOT be judged against A\'s DESIGN.md',
);
});
it('falls back to no design system for a bare file with no project markers above it', () => {
// A lone file whose directory has neither .git, package.json, nor .impeccable.
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-bare-'));
tempRoots.push(bareDir);
const barePage = path.join(bareDir, 'page.html');
fs.writeFileSync(barePage, PAGE_HTML);
const findings = runDetect(projA.dir, [barePage]);
assert.equal(
fontFindingsFor(findings, barePage).length,
0,
'a project-less file must fall back to no design system, not cwd\'s',
);
});
});
+103 -1
View File
@@ -39,7 +39,11 @@ beforeEach(() => {
});
afterEach(() => {
fs.rmSync(scratch, { recursive: true, force: true });
// These tests run real git subprocesses in the scratch dir; on Node 22 a
// recursive delete can race git's object writes and fail the whole test
// with ENOTEMPTY (seen in CI). maxRetries/retryDelay make rmSync retry
// exactly those transient errors.
fs.rmSync(scratch, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
function write(rel, body) {
@@ -272,6 +276,104 @@ describe('checkHookInstallation', () => {
[],
);
});
// The manifest `impeccable hooks on` actually writes, verbatim: a
// `${CLAUDE_PROJECT_DIR}`-relative command. Claude Code expands the variable
// to the project dir at hook time; the doctor must expand it the same way
// (issue #402) instead of existsSync-ing the literal `${CLAUDE_PROJECT_DIR}/...`.
const claudeManifest = () => ({
hooks: {
PostToolUse: [{
matcher: 'Edit|Write|MultiEdit',
hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }],
}],
Stop: [{ hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }] }],
},
});
it('stays quiet for a ${CLAUDE_PROJECT_DIR} manifest when the script exists at root', () => {
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
write('.claude/settings.json', JSON.stringify(claudeManifest()));
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('still flags a genuinely missing script behind ${CLAUDE_PROJECT_DIR}', () => {
// Placeholder expands to a real path that does not exist: the check must
// stay real, not neutered into always-quiet.
write('.claude/settings.json', JSON.stringify(claudeManifest()));
const findings = checkHookInstallation({
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
});
assert.deepEqual(ids(findings), ['hook-script-missing']);
});
it('handles the #399 guarded project-relative form', () => {
const p = '"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"';
const guarded = `[ ! -f ${p} ] || node ${p}`;
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
}));
// missing → flagged
assert.deepEqual(
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
['hook-script-missing'],
);
// present → quiet
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('handles the #399 guarded absolute form (user-level installs)', () => {
const abs = path.join(scratch, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
const p = JSON.stringify(abs);
const guarded = `[ ! -f ${p} ] || node ${p}`;
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
}));
// absolute path missing → flagged
assert.deepEqual(
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
['hook-script-missing'],
);
// present → quiet
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('never reports missing for the GitHub $(git rev-parse) form', () => {
// Command substitution is not statically resolvable; a doctor must not
// assert a negative it cannot verify.
write('.github/hooks/impeccable.json', JSON.stringify({
hooks: { postToolUse: [{ bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"' }] },
}));
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'github' }),
[],
);
});
it('never reports missing for plugin-root placeholders the doctor cannot map', () => {
for (const token of ['${CLAUDE_PLUGIN_ROOT}', '${PLUGIN_ROOT}', '${GROK_PLUGIN_ROOT}']) {
fs.rmSync(path.join(scratch, '.claude'), { recursive: true, force: true });
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: `node "${token}/skills/impeccable/scripts/hook.mjs"` }] }] },
}));
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
`expected no finding for ${token}`,
);
}
});
});
// ─── retired live-mode state ───────────────────────────────────────────────
+31
View File
@@ -24,6 +24,18 @@
.panel-reset { background: rgb(28, 30, 38); color: rgb(230, 232, 237); padding: 12px; }
.panel-reset code { background: rgb(246, 242, 244); border-radius: 3px; padding: 1px 4px; }
.panel-reset pre code { background: none; }
/* issue #409 Case A: gradient-clipped text. The gradient IS the glyph fill
(text-fill-color: transparent), not a backdrop. The inherited `color`
(#e8e6e3) is never painted, so measuring it against the element's own
gradient stops (#6d8cff / #a78bfa) is a false positive. */
.ox-grad-text { background: linear-gradient(135deg, #6d8cff 0%, #a78bfa 50%, #6d8cff 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: #e8e6e3; font-size: 40px; font-weight: 800; }
/* issue #409 Case B: a 9%-alpha accent glow stop over a dark surface. The
stop composites to ~#121f1f, not the full-opacity #34c0a8, so text stays
high-contrast. The dark wrapper supplies the surface beneath the glow. */
.ox-dark-wrap { background: #0f0f11; padding: 16px; }
.ox-glow { background: linear-gradient(160deg, rgba(52,192,168,0.09) 0%, #141419 65%); padding: 20px; }
.ox-glow p { color: #e8e6e3; font-size: 18px; }
.ox-glow .muted { color: #8e8c89; font-size: 16px; }
</style>
</head>
<body>
@@ -181,6 +193,25 @@
<pre><code data-test="code-reset">light text over the dark panel, not the light code surface</code></pre>
</div>
<h3>Gradient-clipped text (issue #409 Case A — must not flag contrast)</h3>
<!-- background-clip: text with a transparent fill: the gradient paints the
glyphs, not a surface behind them. The inherited light `color` is
never painted. The gradient-text pattern still flags; the backdrop
contrast rules (low-contrast / gray-on-color) must not. -->
<h1 class="ox-grad-text" data-test="ox-grad-text">Gradient Clipped Heading Text</h1>
<h3>Faint accent-glow gradient (issue #409 Case B — must not flag contrast)</h3>
<!-- A 9%-alpha teal glow over a dark section. Composited against the dark
surface the stop is near-black, so the light and gray text on it are
high-contrast. Treating the stop as opaque #34c0a8 was the false
positive that flagged every text child. -->
<div class="ox-dark-wrap">
<div class="ox-glow" data-test="ox-glow">
<p>Light body copy on a faint accent glow that composites to near-black</p>
<p class="muted">Muted secondary line on the same faint glow area here</p>
</div>
</div>
<h3>Emoji on light backgrounds</h3>
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
CSS color is irrelevant for contrast. These should NOT be flagged. -->
+1 -1
View File
@@ -10,7 +10,7 @@
<p>
The product is fast &mdash; it is also cheap &mdash; and it is honest
&mdash; which matters &mdash; more than speed &mdash; or price
&mdash; in the long run.
&mdash; in the short term &mdash; and the long run &mdash; always.
</p>
</main>
</body>
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Side-Tab with CSS Named Colors (purple/rebeccapurple/crimson/teal)</title>
<style>
/* Two-column fixture: left col = should-flag, right col = should-pass.
Regression for issue #359: `border-left: 4px solid purple` in a .html
file never fired side-tab because the static cascade's shorthand color
extraction only recognized 9 named colors — every other spec name
(purple, rebeccapurple, crimson, teal, ...) was dropped from the
shorthand, leaving the side at the default black, which reads as
neutral and silently skips the check. The same declaration in a .css
file was flagged by the regex engine, so the two engines disagreed. */
:root {
--accent: orange; /* named color behind a var() — must resolve and flag */
}
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #fafafa; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1120px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.case { padding: 12px 16px; margin-bottom: 16px; }
.case h3 { font-size: 14px; margin: 0 0 4px; }
.case p { font-size: 13px; margin: 0; color: #64748b; }
/* ── FLAG cases: colored side-tab borders using CSS named colors ── */
/* 1: the exact reproducer from issue #359 — named purple in a <style>
rule with background + padding + radius */
#flag-named-purple {
width: 400px;
background: #f6f6f6;
padding: 16px;
border-left: 4px solid purple;
border-radius: 8px;
}
/* 2: rebeccapurple — a longer name that contains another color name
("purple") as a substring; must match whole-token. Width 5px so the
finding snippet is unique to this case. */
#flag-named-rebecca {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 5px solid rebeccapurple;
}
/* 3: horizontal stripe variant — named crimson riding the top edge */
#flag-named-crimson-top {
width: 400px;
background: #ffffff;
border-top: 4px solid crimson;
}
/* 4: named teal, 3px, no radius — the bare w >= 3 arm */
#flag-named-teal {
width: 400px;
background: #ffffff;
border-left: 3px solid teal;
}
/* 5: named color behind a var() in the shorthand. Width 6px so the
finding snippet is unique to this case. */
#flag-named-var {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 6px solid var(--accent);
}
/* ── PASS cases: neutral named colors and non-side-tab shapes ── */
/* 1: dimgray — chromatic-looking name, neutral value; must NOT fire */
#pass-named-dimgray {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid dimgray;
}
/* 2: gainsboro — light neutral named color */
#pass-named-gainsboro {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid gainsboro;
}
/* 3: named black side border — neutral, not a colored stripe */
#pass-named-black {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid black;
}
/* 4: 1px named purple — too thin to qualify */
#pass-named-thin {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 1px solid purple;
}
/* 5: uniform named purple border on all four sides — not a side-tab */
#pass-named-allsides {
width: 400px;
background: #ffffff;
border: 3px solid purple;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<div class="case" id="flag-named-purple"><h3>named purple</h3><p>border-left 4px solid purple + radius</p></div>
<div class="case" id="flag-named-rebecca"><h3>rebeccapurple</h3><p>border-left 4px + radius</p></div>
<div class="case" id="flag-named-crimson-top"><h3>crimson top stripe</h3><p>border-top 4px, horizontal variant</p></div>
<div class="case" id="flag-named-teal"><h3>named teal</h3><p>border-left 3px, no radius</p></div>
<div class="case" id="flag-named-var"><h3>var() to named</h3><p>border-left 4px solid var(--accent)</p></div>
<div class="case" style="width: 400px; background: #ffffff; border-left: 7px solid purple">
<h3>inline named purple</h3><p>style attribute, issue #359 case (a); width 7px keeps the snippet unique</p>
</div>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<div class="case" id="pass-named-dimgray"><h3>dimgray</h3><p>neutral named color</p></div>
<div class="case" id="pass-named-gainsboro"><h3>gainsboro</h3><p>light neutral named color</p></div>
<div class="case" id="pass-named-black"><h3>named black</h3><p>neutral side border</p></div>
<div class="case" id="pass-named-thin"><h3>1px purple</h3><p>too thin to qualify</p></div>
<div class="case" id="pass-named-allsides"><h3>uniform purple</h3><p>all four sides, not a stripe</p></div>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<!--
Regression fixture for issue #408: tiny-text / undersized-ui-text must only
measure rendered text. On sites that set `html { font-size: 62.5% }` the root
computes to 10px, so every element that inherits the root and carries text
content — including <script>, <style>, <title>, <noscript>, and display:none /
visibility:hidden blocks — reports a 10px computed size. Their JS / CSS /
JSON-LD text satisfies hasDirectText, so before the fix they produced dozens
of phantom "10px body text" findings on every Shopify page.
Explicit pixel sizes throughout because jsdom does no layout.
-->
<html style="font-size: 10px">
<head>
<meta charset="utf-8">
<!-- SHOULD PASS: a long <title> inherits the 10px root, but nothing renders it. -->
<title>This is a fairly long document title that easily exceeds twenty characters</title>
<!-- SHOULD PASS: stylesheet text is never painted. -->
<style>
/* This CSS comment is deliberately longer than twenty characters so the style
element's text node clears the hasDirectText / textLen > 20 gate. */
body { font-size: 10px; font-family: system-ui, sans-serif; }
.rendered-body { font-size: 10px; }
.rendered-nav-link { font-size: 9px; }
.none-block { display: none; font-size: 10px; }
.hidden-block { visibility: hidden; font-size: 10px; }
</style>
<!-- SHOULD PASS: script payload text is never painted. -->
<script>window.__ANALYTICS__ = { id: 1, ts: 0 }; console.log("an analytics payload string that is clearly longer than twenty characters");</script>
<!-- SHOULD PASS: JSON-LD schema block, a Shopify staple. -->
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"A product name long enough to exceed twenty characters"}</script>
<!-- SHOULD PASS: noscript fallback prose is only shown without JS; still non-rendered here. -->
<noscript>Please enable JavaScript in your browser to view this page content correctly.</noscript>
</head>
<body>
<!-- SHOULD PASS: display:none block, its text is never painted. -->
<div class="none-block">Hidden display-none block of body text long enough to exceed twenty characters.</div>
<!-- SHOULD PASS: visibility:hidden paragraph, its text is never painted. -->
<p class="hidden-block">Invisible visibility-hidden paragraph copy that is longer than the twenty char gate.</p>
<!-- SHOULD FLAG (tiny-text): genuinely rendered body copy at 10px. -->
<p class="rendered-body">This is real rendered body copy at 10px that is definitely long enough to flag.</p>
<!-- SHOULD FLAG (undersized-ui-text): genuinely rendered interactive text at 9px. -->
<nav aria-label="primary"><a href="/docs" class="rendered-nav-link">Rendered Nav Link</a></nav>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
/*
* Pseudo-element stripe fixture for the regex engine (issue #394).
*
* The side-tab silhouette built as an absolutely-positioned ::before/::after
* bar instead of a border. scanCssTextForPseudoStripe already caught these on
* full HTML pages; this fixture pins the standalone-stylesheet path. The
* data-case attribute in each selector lands in the finding snippet, so the
* test can attribute every flag/pass case individually.
*/
:root {
--stripe-fixture-neutral: #e5e7eb;
}
/* ── FLAG: the issue reproducer — inset shorthand pin + 4px chromatic bar ── */
.card[data-case="Inset Shorthand Left Edge"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: #7c3aed;
}
/* ── FLAG: longhand edge pins; unresolvable var() errs toward detection ── */
.card[data-case="Longhand Left Edge"]::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--accent);
}
/* ── FLAG: horizontal variant riding the bottom edge ── */
.card[data-case="Bottom Edge"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: #f43f5e;
}
/* ── FLAG: height:100% full-height stripe on the right edge ── */
.card[data-case="Full Height Right Edge"]::before {
content: "";
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 3px;
background: oklch(60% 0.2 300);
}
/* ── PASS: neutral gray divider is not an accent stripe ── */
.card[data-case="Neutral Divider"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--stripe-fixture-neutral);
}
/* ── PASS: 24px is a panel, not a stripe ── */
.card[data-case="Wide Panel"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 24px;
background: #7c3aed;
}
/* ── PASS: no position: absolute — not an overlay stripe ── */
.card[data-case="Static Underline"]::before {
content: "";
width: 4px;
background: #7c3aed;
}
/* ── PASS: 1px hairline is a divider ── */
.card[data-case="Hairline Divider"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 1px;
background: #7c3aed;
}
/* ── PASS: hover-conditional underline is an affordance, not decoration ── */
.link-row[data-case="Hover Underline"]:hover::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: #7c3aed;
}
/* ── PASS: pinned to one edge but not full-height — a badge, not a stripe ── */
.card[data-case="Floating Badge"]::before {
content: "";
position: absolute;
left: 0;
width: 4px;
height: 12px;
background: #7c3aed;
}
/* ── PASS: commented-out CSS is not a live rule ──
.card[data-case="Commented Out Stripe"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: #7c3aed;
}
*/
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="card">
<div class="body">Component with a pseudo-element stripe in its style block</div>
</div>
</template>
<script setup>
const label = 'pseudo-stripe fixture';
</script>
<style scoped>
/* FLAG: same silhouette as a border side-tab, drawn as a ::before bar */
.card[data-case="Component Left Edge"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: #7c3aed;
}
/* PASS: neutral hairline divider */
.card[data-case="Component Neutral Divider"]::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 1px;
background: #e5e7eb;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>radial-spotlight-glow fixture</title>
<style>
/* jsdom / static-html engine does no layout: every case declares explicit
pixel width + height so the size gate can read them. */
body { margin: 0; background: #0b0d13; }
.grid { display: flex; flex-wrap: wrap; gap: 24px; padding: 24px; }
.case { color: #e6e6e6; }
/* ───────── SHOULD FLAG: decorative low-opacity chromatic spotlights ───────── */
/* The exact declaration that evaded the detector: soft blue spotlight fading
to transparent, painted on a large mobile hero. */
.flag-hero-blue {
width: 360px; height: 560px;
background: radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26), transparent 44%);
}
/* Violet accent wash on a full section. */
.flag-section-violet {
width: 900px; height: 600px;
background-image: radial-gradient(circle, rgba(139,92,246,0.30), transparent 60%);
}
/* Cyan overlay-style glow (the pseudo-element overlay div reflex). */
.flag-overlay-cyan {
width: 760px; height: 480px;
background: radial-gradient(ellipse at top, rgba(34,211,238,0.22), transparent 70%);
}
/* Single glow drawn with two low-alpha stops of one hue fading out. */
.flag-two-stop-rose {
width: 640px; height: 400px;
background: radial-gradient(circle, rgba(255,90,120,0.28) 0%, rgba(255,90,120,0.12) 45%, transparent 75%);
}
/* Same tell written as an 8-digit hex (#506fff at alpha 0x3d ≈ 0.24). */
.flag-hex-alpha {
width: 700px; height: 460px;
background: radial-gradient(circle, #506fff3d, transparent 50%);
}
/* ───────── SHOULD PASS: real backgrounds, small elements, neutrals, rich art ───────── */
/* Opaque radial background — a real surface, not a glow (no transparent end). */
.pass-opaque-bg {
width: 800px; height: 520px;
background: radial-gradient(circle, #1e3a8a, #0f172a);
}
/* Small badge: chromatic low-alpha radial glow, but tiny. */
.pass-badge {
width: 40px; height: 40px;
background: radial-gradient(circle, rgba(80,111,255,0.30), transparent);
}
/* Avatar-scale light: the subject IS a small light, exempt by size. */
.pass-avatar-light {
width: 72px; height: 72px;
background: radial-gradient(circle, rgba(255,196,64,0.35), transparent 70%);
}
/* Neutral near-black vignette — grayscale, not the accent-glow tell. */
.pass-neutral-vignette {
width: 800px; height: 520px;
background: radial-gradient(circle, rgba(0,0,0,0.40), transparent 80%);
}
/* Neutral near-white vignette — grayscale, exempt. */
.pass-white-vignette {
width: 820px; height: 540px;
background: radial-gradient(ellipse, rgba(255,255,255,0.35), transparent 70%);
}
/* Rich three-color radial composition — real color structure, opaque, no fade. */
.pass-rich-composition {
width: 800px; height: 520px;
background: radial-gradient(circle, #ff0080 0%, #7928ca 45%, #0070f3 100%);
}
/* Rich composition that does fade out, but its color stops are opaque. */
.pass-rich-transparent {
width: 780px; height: 500px;
background: radial-gradient(circle, rgba(255,0,128,0.90) 0%, rgba(121,40,202,0.85) 50%, transparent);
}
/* Opaque center glow (the radial-halo case, alpha ≥ 0.45) — not this rule. */
.pass-opaque-center {
width: 800px; height: 520px;
background: radial-gradient(circle, rgba(80,111,255,0.85), transparent);
}
/* Low-alpha chromatic wash, but LINEAR, not radial. */
.pass-linear-wash {
width: 800px; height: 520px;
background: linear-gradient(120deg, rgba(80,111,255,0.28), transparent 60%);
}
</style>
</head>
<body>
<main class="grid">
<!-- should-flag column -->
<div class="case flag-hero-blue" data-name="Hero Spotlight Blue"></div>
<div class="case flag-section-violet" data-name="Section Glow Violet"></div>
<div class="case flag-overlay-cyan" data-name="Overlay Glow Cyan"></div>
<div class="case flag-two-stop-rose" data-name="Two Stop Soft Glow"></div>
<div class="case flag-hex-alpha" data-name="Hex Alpha Glow"></div>
<!-- should-pass column -->
<div class="case pass-opaque-bg" data-name="Opaque Radial Background"></div>
<div class="case pass-badge" data-name="Small Accent Badge"></div>
<div class="case pass-avatar-light" data-name="Avatar Glow Light"></div>
<div class="case pass-neutral-vignette" data-name="Neutral Vignette"></div>
<div class="case pass-white-vignette" data-name="White Vignette"></div>
<div class="case pass-rich-composition" data-name="Rich Radial Composition"></div>
<div class="case pass-rich-transparent" data-name="Rich Transparent Composition"></div>
<div class="case pass-opaque-center" data-name="Opaque Center Glow"></div>
<div class="case pass-linear-wash" data-name="Linear Gradient Wash"></div>
</main>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<!--
Regression fixture for issue #407: DOM named-property shadowing.
HTMLFormElement is [LegacyOverrideBuiltIns], so a named control shadows even
builtin getters: a <form> containing <input name="id"> makes `form.id` return
the INPUT ELEMENT, not the id string. Reading `.startsWith` on it throws
"elId.startsWith is not a function". Every Shopify product form ships an
<input name="id"> (the variant id), so this crashed the URL scan of essentially
every Shopify page. `<input name="className">` shadows `form.className` the same
way. The detector must read these via getAttribute (immune to shadowing).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shadowed form.id regression fixture</title>
<style>
body { font-family: system-ui, sans-serif; background: #ffffff; color: #1a1a1a; margin: 0; padding: 24px; }
.product { max-width: 640px; margin: 0 auto; }
h1 { font-size: 28px; margin: 0 0 12px; }
.price { font-size: 20px; font-weight: 600; }
form { margin-top: 16px; }
.add-to-cart { background: #1a1a1a; color: #ffffff; border: 0; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer; }
label { display: block; margin: 8px 0 4px; font-size: 14px; }
select, input[type="number"] { padding: 8px; font-size: 14px; }
</style>
</head>
<body>
<main class="product">
<h1>Impeccable Test Product</h1>
<p class="price">$49.00</p>
<!-- Shopify-style product form: the <input name="id"> shadows form.id. -->
<form method="post" action="/cart/add" id="product-form">
<input type="hidden" name="id" value="4001">
<input type="hidden" name="className" value="variant-default">
<label for="qty">Quantity</label>
<input type="number" id="qty" name="quantity" value="1" min="1">
<label for="variant">Variant</label>
<select id="variant" name="options[Size]">
<option value="s">Small</option>
<option value="m">Medium</option>
<option value="l">Large</option>
</select>
<button type="submit" class="add-to-cart">Add to cart</button>
</form>
</main>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>undersized-ui-text fixture</title>
<style>
/* Explicit pixel dimensions because jsdom does no layout. */
body { font-family: system-ui, sans-serif; font-size: 16px; }
.col { display: inline-block; width: 480px; vertical-align: top; }
/* --- should-flag styles --- */
.nav-link { font-size: 8px; }
.category { font-size: 8px; }
.meta { font-size: 9px; }
.btn-small { font-size: 10px; width: 120px; height: 24px; }
.cell-small { font-size: 9px; }
.caps-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; }
.footer-link { font-size: 8px; }
/* --- should-pass styles --- */
.legal { font-size: 10px; line-height: 1.5; } /* non-interactive footer smallprint at 10px */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); font-size: 4px; }
.sup-marker { font-size: 8px; }
.sub-marker { font-size: 8px; }
.em-parent { font-size: 20px; }
.em-child { font-size: 0.6em; } /* resolves to 12px, above the floor */
.mock-terminal { width: 400px; }
.term-line { font-size: 9px; } /* legitimately small code/terminal text */
.normal-link { font-size: 12px; } /* at the floor, allowed */
</style>
</head>
<body>
<!-- ================= SHOULD FLAG (functional/UI text below the 11px floor) ================= -->
<div class="col" id="should-flag">
<h2>Should flag</h2>
<!-- interactive: nav link at 8px -->
<nav aria-label="primary">
<a href="/docs" class="nav-link">Flag Nav Link</a>
</nav>
<!-- non-interactive furniture: category label at 8px -->
<span class="category">Flag Category</span>
<!-- non-interactive furniture: meta row at 9px -->
<span class="meta">Flag Meta Row</span>
<!-- interactive: button label at 10px -->
<button type="button" class="btn-small">Flag Button</button>
<!-- structural furniture: table cell at 9px -->
<table>
<tbody>
<tr><td class="cell-small">Flag Table Cell</td></tr>
</tbody>
</table>
<!-- decorative letterspaced uppercase micro-label at 10px: STILL functional, not exempt -->
<span class="caps-label">Flag Caps Label</span>
<!-- interactive text in a footer stays on the 11px floor: footer link at 8px -->
<footer>
<a href="/privacy" class="footer-link">Flag Footer Link</a>
</footer>
</div>
<!-- ================= SHOULD PASS ================= -->
<div class="col" id="should-pass">
<h2>Should pass</h2>
<!-- non-interactive legal fine print in a footer at 10px: floor drops to 10px for smallprint -->
<footer>
<p class="legal">Pass Legal Fine Print copyright 2026 all rights reserved across this jurisdiction</p>
</footer>
<!-- visually-hidden / screen-reader text: never rendered, exempt -->
<span class="sr-only">Pass Sr Only</span>
<!-- sup / sub markers are exempt by tag -->
<p>Reference<sup class="sup-marker">Pass Sup Marker</sup> and water<sub class="sub-marker">Pass Sub Marker</sub></p>
<!-- em-sized text relative to a large parent computes to 12px, above the floor -->
<div class="em-parent"><span class="em-child">Pass Em Sized</span></div>
<!-- code / terminal mock: legitimately small, exempt -->
<div class="mock-terminal">
<span class="term-line">Pass Terminal Line</span>
</div>
<!-- functional text exactly at the 12px floor is allowed -->
<a href="/home" class="normal-link">Pass Normal Link</a>
</div>
</body>
</html>
+21
View File
@@ -157,6 +157,19 @@ for (const name of listFixtures()) {
assert.match(body, /localhost:9999\/live\.js/);
return;
}
if (result.adapter === 'tanstack-start') {
const adapterResult = result.results[0];
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
const component = readFileSync(join(tmp, adapterResult.componentFile), 'utf-8');
assert.equal(adapterResult.inserted, true, 'TanStack Start root document was patched');
assert.match(rootDoc, /impeccable-live-tanstack-start/, 'root document got the adapter marker');
assert.match(rootDoc, /<ImpeccableLiveRoot \/>/, 'root document renders the mount component');
assert.doesNotMatch(rootDoc, /impeccable-live-start/, 'root document must not get the raw script block');
assert.doesNotMatch(rootDoc, /localhost:9999\/live\.js/, 'root document must not own live.js directly');
assert.match(component, /localhost:9999\/live\.js/, 'mount component loads live.js');
assert.match(component, /useEffect/, 'mount component appends the script on mount');
return;
}
for (const r of result.results) {
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
const body = readFileSync(join(tmp, r.file), 'utf-8');
@@ -189,6 +202,14 @@ for (const name of listFixtures()) {
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
return;
}
if (result.adapter === 'tanstack-start') {
const adapterResult = result.results[0];
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
assert.doesNotMatch(rootDoc, /ImpeccableLiveRoot/);
assert.doesNotMatch(rootDoc, /impeccable-live-tanstack-start/);
assert.equal(existsSync(join(tmp, adapterResult.componentFile)), false, 'TanStack mount component was removed');
return;
}
for (const r of result.results) {
const body = readFileSync(join(tmp, r.file), 'utf-8');
assert.doesNotMatch(body, /impeccable-live-start/);
+2
View File
@@ -113,6 +113,8 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
| `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). |
| `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. |
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
@@ -9,6 +9,6 @@
"preview": "astro preview"
},
"devDependencies": {
"astro": "^6.0.0"
"astro": "^7.1.0"
}
}
@@ -1,5 +1,5 @@
{
"name": "Astro 6 + Vite 7",
"name": "Astro 7 + Vite 7",
"config": {
"files": ["src/layouts/Layout.astro"],
"insertBefore": "</body>",
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite 8 + TanStack Router Fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"name": "tanstack-router-vite-fixture",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-router": "^1.132.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0"
}
}
@@ -0,0 +1,37 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import {
createRootRoute,
createRoute,
createRouter,
RouterProvider,
Outlet,
Link,
} from '@tanstack/react-router';
import Home from './routes/Home.jsx';
import About from './routes/About.jsx';
import './styles.css';
const rootRoute = createRootRoute({
component: () => (
<>
<nav className="nav">
<Link to="/">Home</Link>
<Link to="/about" data-testid="nav-about">About</Link>
</nav>
<Outlet />
</>
),
});
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Home });
const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: '/about', component: About });
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]);
const router = createRouter({ routeTree });
createRoot(document.getElementById('root')).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
@@ -0,0 +1,8 @@
export default function About() {
return (
<main className="page">
<h1 className="hero-title">About Page Hero</h1>
<p className="hero-hook">Lives on the /about route only mounts after navigation.</p>
</main>
);
}
@@ -0,0 +1,8 @@
export default function Home() {
return (
<main className="page">
<h2>Home</h2>
<p>Welcome. The hero we'll edit lives on the About page.</p>
</main>
);
}
@@ -0,0 +1,6 @@
body { margin: 0; font-family: system-ui, sans-serif; }
.nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #eee; }
.nav a { color: #111; text-decoration: none; }
.page { padding: 2rem; }
.hero-title { font-size: 2rem; margin: 0 0 0.5rem; }
.hero-hook { color: #555; }
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { host: '127.0.0.1', strictPort: false },
});
@@ -0,0 +1,49 @@
{
"name": "Vite 8 + TanStack Router (code-based SPA)",
"config": {
"files": ["index.html"],
"insertBefore": "</body>",
"commentSyntax": "html"
},
"sourceFiles": [
"index.html",
"src/main.jsx",
"src/routes/Home.jsx",
"src/routes/About.jsx",
"src/styles.css",
"vite.config.js"
],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps About hero in routes/About.jsx",
"args": { "classes": "hero-title", "tag": "h1" },
"expectedFile": "src/routes/About.jsx"
}
],
"runtime": {
"styling": "plain-css",
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
"readyTimeoutMs": 120000,
"preActions": [
{ "type": "click", "selector": "[data-testid='nav-about']" },
{ "type": "wait", "selector": "h1.hero-title" }
],
"reloadProbe": {
"preActions": [
{ "type": "click", "selector": "[data-testid='nav-about']" },
{ "type": "wait", "selector": "h1.hero-title" }
],
"expectSelector": "h1.hero-title"
},
"probe": {
"expectLiveInit": true,
"expectConsoleClean": true
},
"steer": {
"sourceFile": "src/routes/About.jsx"
}
}
}
@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
package-lock.json
@@ -0,0 +1,21 @@
{
"name": "tanstack-start-fixture",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --host 127.0.0.1",
"build": "vite build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-start": "^1.132.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0"
}
}
@@ -0,0 +1,12 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
export function getRouter() {
return createTanStackRouter({ routeTree, scrollRestoration: true });
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
@@ -0,0 +1,26 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'TanStack Start Fixture' },
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: Home,
});
function Home() {
return (
<main className="page">
<h1 className="hero-title">Start Home Hero</h1>
<p className="hero-hook">Server-rendered by TanStack Start.</p>
</main>
);
}
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import viteReact from '@vitejs/plugin-react';
export default defineConfig({
server: { host: '127.0.0.1' },
plugins: [tanstackStart(), viteReact()],
});
@@ -0,0 +1,40 @@
{
"name": "Vite 8 + TanStack Start (SSR, root-document adapter)",
"config": {
"files": ["src/routes/__root.tsx"],
"insertBefore": "<Scripts",
"commentSyntax": "jsx"
},
"sourceFiles": [
"src/routes/__root.tsx",
"src/routes/index.tsx",
"src/router.tsx",
"vite.config.js"
],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps index hero in routes/index.tsx",
"args": { "classes": "hero-title", "tag": "h1" },
"expectedFile": "src/routes/index.tsx"
}
],
"runtime": {
"styling": "plain-css",
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
"devCommand": ["npm", "run", "dev"],
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
"readyTimeoutMs": 180000,
"pickSelector": "h1.hero-title",
"reloadProbe": {
"expectSelector": "h1.hero-title"
},
"probe": {
"expectLiveInit": true,
"expectConsoleClean": true
},
"steer": {
"sourceFile": "src/routes/index.tsx"
}
}
}
@@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
.nitro/
.tanstack/
.output/
src/routeTree.gen.ts
package-lock.json
+114 -5
View File
@@ -13,6 +13,7 @@ import {
buildClaudeSettingsManifest,
buildClaudePluginHooksManifest,
buildCodexHooksManifest,
buildCodexPluginHooksManifest,
buildCursorHooksManifest,
buildGitHubHooksManifest,
buildGrokHooksManifest,
@@ -25,13 +26,46 @@ function readJson(rel) {
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'));
}
// The runtime probe every hook command must carry (issue #410): a node below
// the engines floor exits the command at 0 instead of dying on ESM parse. The
// expected floor comes from package.json engines, so probe and contract cannot
// drift apart.
const ENGINES_NODE_MAJOR = parseInt(
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).engines.node.replace(/[^\d.]/g, ''),
10,
);
const NODE_PROBE = `process.exit(parseInt(process.versions.node,10)>=${ENGINES_NODE_MAJOR}?0:1)`;
function expectCommand(command, expectedPath) {
assert.equal(typeof command, 'string');
assert.match(command, /^node "/);
// node-command providers carry the missing-file guard (issue #399: exits 0
// when absent, preserves node's exit code when present) plus the runtime
// probe. GitHub's portable `$(git rev-parse)` form is guarded too, so it
// lands in the same branch.
if (command.startsWith('[ ! -f "')) {
assert.match(command, /\|\| node "/);
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
} else {
assert.match(command, /^node "|^bash -c|\$\(git rev-parse/);
}
assert.ok(command.includes(expectedPath), `missing ${expectedPath} in ${command}`);
assert.ok(!command.includes('hook-probe.mjs'), `probe hook still referenced in ${command}`);
}
function manifestCommands(manifest) {
const commands = [];
const walk = (value) => {
if (Array.isArray(value)) { value.forEach(walk); return; }
if (value && typeof value === 'object') {
if (typeof value.command === 'string') commands.push(value.command);
if (typeof value.bash === 'string') commands.push(value.bash);
Object.values(value).forEach(walk);
}
};
walk(manifest.hooks);
return commands;
}
describe('hook manifest builders', () => {
it('builds Claude project settings for the real detector hook', () => {
const manifest = buildClaudeSettingsManifest();
@@ -56,6 +90,9 @@ describe('hook manifest builders', () => {
});
it('builds Codex project-local hooks for the real detector hook', () => {
// Default install dir is `.codex`: a `.codex`-directory install keeps the
// skill payload at `.codex/skills/...`, so the hook must point there (not at
// a hardcoded `.agents`, which no-ops on such installs).
const manifest = buildCodexHooksManifest();
assert.equal(manifest.description, undefined);
const group = manifest.hooks.PostToolUse[0];
@@ -65,7 +102,7 @@ describe('hook manifest builders', () => {
assert.equal(handler.type, 'command');
assert.equal(handler.timeout, 5);
assert.equal(handler.statusMessage, 'Checking UI changes');
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
assert.ok(!handler.command.includes('git rev-parse --show-toplevel'));
assert.ok(!handler.command.includes('${PLUGIN_ROOT}'));
assert.equal(manifest.hooks.SessionStart, undefined);
@@ -74,7 +111,31 @@ describe('hook manifest builders', () => {
// pass too.
const stop = manifest.hooks.Stop[0].hooks[0];
assert.equal(stop.timeout, 30);
expectCommand(stop.command, '.agents/skills/impeccable/scripts/hook.mjs');
expectCommand(stop.command, '.codex/skills/impeccable/scripts/hook.mjs');
});
it('derives the Codex hook payload path from the install dir', () => {
// Each install dir gets a manifest pointing at its own skills payload: a
// `.codex`-directory install at `.codex/skills`, a `.agents` (Codex repo
// skills) install at `.agents/skills`.
const codexDir = buildCodexHooksManifest('.codex');
expectCommand(codexDir.hooks.PostToolUse[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
expectCommand(codexDir.hooks.Stop[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
const agentsDir = buildCodexHooksManifest('.agents');
expectCommand(agentsDir.hooks.PostToolUse[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
expectCommand(agentsDir.hooks.Stop[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
assert.ok(!agentsDir.hooks.PostToolUse[0].hooks[0].command.includes('.codex/skills'));
// hooksJsonFor threads the provider's configDir through to the builder.
expectCommand(
hooksJsonFor('codex', { configDir: '.agents' }).hooks.PostToolUse[0].hooks[0].command,
'.agents/skills/impeccable/scripts/hook.mjs',
);
expectCommand(
hooksJsonFor('codex').hooks.PostToolUse[0].hooks[0].command,
'.codex/skills/impeccable/scripts/hook.mjs',
);
});
it('builds one Cursor pre-write blocking hook', () => {
@@ -131,6 +192,40 @@ describe('hook manifest builders', () => {
expectCommand(stop.command, '.grok/skills/impeccable/scripts/hook.mjs');
});
it('probes the node runtime everywhere, and notices only where a channel exists', () => {
// Claude Code and Codex render a `systemMessage` from hook stdout, so their
// manifests carry the one-time unsupported-runtime notice. Cursor (output is
// permission-shaped; a message would block the edit), Grok (stdout ignored),
// and Copilot (contract unconfirmed) get the silent probe only.
const withNotice = [
buildClaudeSettingsManifest(),
buildClaudePluginHooksManifest(),
buildCodexHooksManifest(),
buildCodexPluginHooksManifest(),
];
const probeOnly = [
buildCursorHooksManifest(),
buildGitHubHooksManifest(),
buildGrokHooksManifest(),
];
for (const manifest of [...withNotice, ...probeOnly]) {
for (const command of manifestCommands(manifest)) {
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
}
}
for (const manifest of withNotice) {
for (const command of manifestCommands(manifest)) {
assert.ok(command.includes('systemMessage'), `missing notice in ${command}`);
assert.ok(command.includes('node-unsupported'), `missing once-only marker in ${command}`);
}
}
for (const manifest of probeOnly) {
for (const command of manifestCommands(manifest)) {
assert.ok(!command.includes('systemMessage'), `unexpected notice in ${command}`);
}
}
});
it('routes supported hook builders and leaves other providers alone', () => {
assert.ok(hooksJsonFor('claude'));
assert.ok(hooksJsonFor('codex'));
@@ -185,11 +280,25 @@ describe('generated hook artifacts in repo', () => {
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
});
it('Codex project hooks reference hook.mjs in the .agents skill payload', () => {
it('Codex project hooks reference hook.mjs in the .codex skill payload', () => {
// The committed `.codex/hooks.json` is the distribution artifact for a
// `.codex`-directory install, whose skill payload lives at `.codex/skills/`
// (issue: it previously hardcoded `.agents/skills`, so the guarded hook
// no-opped on `.codex` installs). CLI installs that lay the skill down at
// `.agents/skills` rewrite the command to that path at install time.
const manifest = readJson('.codex/hooks.json');
const handler = manifest.hooks.PostToolUse[0].hooks[0];
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
assert.ok(!handler.command.includes('.agents/skills'));
// The self-consistent Codex bundle at `dist/codex/.codex/skills/` is a build
// artifact, not a tracked repo file; `bun run build` emits it and
// build.test.js verifies it there. This suite runs before the build (CI's
// `test:core` precedes the Build step), so it asserts only tracked outputs.
// The repo ships the Codex skill payload at `.agents/skills` (the
// layout CLI installs use, and where the rewritten command resolves).
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/SKILL.md')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook-lib.mjs')));
+143 -17
View File
@@ -53,6 +53,8 @@ import {
IMMEDIATE_TIER_RULES,
splitFindingsByTier,
perEditTieringActive,
ADVISORY_RULES,
isAdvisoryFinding,
payload,
extractFindingIgnoreValue,
resolveProjectPlatform,
@@ -416,6 +418,39 @@ describe('filterFindings()', () => {
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
});
it('drops advisory-rule findings by default', () => {
const findings = [
finding('side-tab', 1),
finding('em-dash-overuse', 2),
finding('gradient-text', 3),
];
const filtered = filterFindings(findings, '', '.html', {
ignoreRules: [],
limits: DEFAULT_CONFIG.limits,
});
assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'gradient-text']);
});
it('keeps advisory-rule findings when advisoryRules is "include"', () => {
const findings = [
finding('side-tab', 1),
finding('em-dash-overuse', 2),
];
const filtered = filterFindings(findings, '', '.html', {
ignoreRules: [],
advisoryRules: 'include',
limits: DEFAULT_CONFIG.limits,
});
assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'em-dash-overuse']);
});
it('recognizes advisory findings by rule id or explicit flag', () => {
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true);
assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false);
});
it('does not treat source comments as hook suppression', () => {
const content = [
'/* impeccable: ignore * */',
@@ -3147,12 +3182,12 @@ describe('runHook() — per-edit tiering', () => {
it('splitFindingsByTier partitions on IMMEDIATE_TIER_RULES', () => {
const { immediate, deferred } = splitFindingsByTier([
finding('dark-glow', 1),
finding('em-dash-overuse', 2),
finding('marketing-buzzword', 2),
finding('low-contrast', 3),
finding('side-tab', 4),
]);
assert.deepEqual(immediate.map((f) => f.antipattern), ['dark-glow', 'low-contrast']);
assert.deepEqual(deferred.map((f) => f.antipattern), ['em-dash-overuse', 'side-tab']);
assert.deepEqual(deferred.map((f) => f.antipattern), ['marketing-buzzword', 'side-tab']);
for (const f of immediate) assert.ok(IMMEDIATE_TIER_RULES.has(f.antipattern));
});
@@ -3167,13 +3202,13 @@ describe('runHook() — per-edit tiering', () => {
it('surfaces immediate-tier findings per edit and defers copy-tier ones', async () => {
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([
finding('em-dash-overuse', 3),
finding('marketing-buzzword', 3),
finding('dark-glow', 5),
]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /dark-glow/);
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
assert.doesNotMatch(r.stdout, /marketing-buzzword/);
assert.equal(r.audit.deferred, 1);
const cache = readCache(cwd);
@@ -3182,10 +3217,10 @@ describe('runHook() — per-edit tiering', () => {
it('emits a clean ack when all findings are deferred, and still marks the file touched', async () => {
const file = write('src/Copy.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const det = fakeDetector([finding('marketing-buzzword', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-deferred-only')), env: {}, cwd, detector: det });
assert.match(r.stdout, /No deterministic design-quality issues found/);
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
assert.doesNotMatch(r.stdout, /marketing-buzzword/);
assert.equal(r.audit.deferred, 1);
// The touched-file entry is what lets the Stop deep pass find this file.
@@ -3198,10 +3233,10 @@ describe('runHook() — per-edit tiering', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } }));
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const det = fakeDetector([finding('marketing-buzzword', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-all')), env: {}, cwd, detector: det });
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /em-dash-overuse/);
assert.match(r.stdout, /marketing-buzzword/);
assert.equal(r.audit.deferred, undefined);
});
@@ -3213,11 +3248,33 @@ describe('runHook() — per-edit tiering', () => {
toolName: 'edit',
toolArgs: JSON.stringify({ path: file }),
};
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const det = fakeDetector([finding('marketing-buzzword', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det });
assert.equal(r.audit.harness, 'github');
const out = JSON.parse(r.stdout);
assert.match(out.additionalContext, /em-dash-overuse/);
assert.match(out.additionalContext, /marketing-buzzword/);
});
it('skips advisory findings per edit by default and never nags about them', async () => {
const file = write('src/Copy.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-skip')), env: {}, cwd, detector: det });
// The only finding is advisory, so the file scans clean.
assert.match(r.stdout, /No deterministic design-quality issues found/);
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
});
it('includes advisory findings per edit when detector.advisoryRules is "include"', async () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
hook: { perEditRules: 'all' },
detector: { advisoryRules: 'include' },
}));
const file = write('src/Copy.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-include')), env: {}, cwd, detector: det });
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /em-dash-overuse/);
});
});
@@ -3257,14 +3314,14 @@ describe('runStopHook()', () => {
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([
finding('dark-glow', 5),
finding('em-dash-overuse', 3),
finding('marketing-buzzword', 3),
finding('side-tab', 7),
]);
// Per-edit pass: surfaces dark-glow, defers the other two.
const edit = await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
assert.match(edit.stdout, /dark-glow/);
assert.doesNotMatch(edit.stdout, /em-dash-overuse/);
assert.doesNotMatch(edit.stdout, /marketing-buzzword/);
// Stop deep pass: surfaces exactly the deferred remainder.
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
@@ -3272,7 +3329,7 @@ describe('runStopHook()', () => {
assert.equal(stop.audit.emitted, true);
const out = JSON.parse(stop.stdout);
assert.equal(out.hookSpecificOutput.hookEventName, 'Stop');
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
assert.match(out.hookSpecificOutput.additionalContext, /marketing-buzzword/);
assert.match(out.hookSpecificOutput.additionalContext, /side-tab/);
assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /dark-glow/);
assert.equal(stop.emission.kind, 'stop-deep-pass');
@@ -3288,11 +3345,11 @@ describe('runStopHook()', () => {
it('a second Stop fire is silent: deep-pass findings are remembered', async () => {
const sid = 'stop-twice';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
const det = fakeDetector([finding('marketing-buzzword', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.match(first.stdout, /em-dash-overuse/);
assert.match(first.stdout, /marketing-buzzword/);
const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(second.stdout, '');
@@ -3303,15 +3360,84 @@ describe('runStopHook()', () => {
const sid = 'stop-ignored';
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
detector: { ignoreRules: ['em-dash-overuse'] },
detector: { ignoreRules: ['marketing-buzzword'] },
}));
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('marketing-buzzword', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(stop.stdout, '');
assert.equal(stop.audit.skipped, 'stop-clean');
});
it('skips advisory findings in the deep pass by default', async () => {
const sid = 'stop-advisory';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
// Silent either way: the advisory finding is dropped at the per-edit pass, so
// the file is never recorded as touched, and the deep pass has nothing to say.
assert.equal(stop.stdout, '');
assert.ok(['stop-clean', 'no-touched-files'].includes(stop.audit.skipped));
});
it('surfaces advisory findings in the deep pass when advisoryRules is "include"', async () => {
const sid = 'stop-advisory-include';
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
detector: { advisoryRules: 'include' },
}));
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(stop.audit.emitted, true);
const out = JSON.parse(stop.stdout);
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
});
it('re-invoked with stop_hook_active:true exits 0 and silent even with pending findings (issue #400)', async () => {
const sid = 'stop-active';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('marketing-buzzword', 3)]);
// Prime a real touched-file + finding so a plain Stop pass would fire.
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
// Re-invocation after the previous fire kept the turn alive: the contract
// says exit clean, no re-block, before scanning.
const active = { ...stopEvent(sid), stop_hook_active: true };
const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det });
assert.equal(stop.exitCode, 0);
assert.equal(stop.stdout, '');
assert.equal(stop.audit.skipped, 'stop-clean');
assert.equal(stop.audit.emitted, undefined);
assert.equal(stop.audit.skipped, 'stop-hook-active');
});
it('stop_hook_active:false or absent still runs the deep pass as before', async () => {
const sid = 'stop-inactive';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('marketing-buzzword', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
// Explicit false (the stopEvent default).
const explicitFalse = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(explicitFalse.audit.emitted, true);
assert.match(explicitFalse.stdout, /marketing-buzzword/);
// Field absent entirely (legacy / non-Claude-Code payloads): same behavior.
const sid2 = 'stop-absent';
const file2 = write('src/Card2.tsx', 'noop');
await runHook({ stdinJson: JSON.stringify(editEvent(file2, sid2)), env: {}, cwd, detector: det });
const ev = stopEvent(sid2);
delete ev.stop_hook_active;
const absent = await runStopHook({ stdinJson: JSON.stringify(ev), env: {}, cwd, detector: det });
assert.equal(absent.audit.emitted, true);
assert.match(absent.stdout, /marketing-buzzword/);
});
it('honors kill switches and the re-entrancy guard', async () => {
+107 -2
View File
@@ -273,6 +273,73 @@ describe('live-browser.js regression guards', () => {
);
});
it('SSE error reply clears the durable session checkpoint like discarded', () => {
// Issue #362: `live-poll.mjs --reply <id> error "..."` is the documented
// abort flow in reference/live.md, and an agent error reply is terminal
// for the session it names. The 'error' case used to reset only the UI
// (hideBar + PICKING) while the localStorage checkpoint written for the
// GENERATING phase survived — so every reload resurrected a dead session
// the server no longer knew about, until the user hand-cleared the
// impeccable-live* keys in the console. The error path must tear down the
// named session exactly like 'discarded' does (markSessionHandled +
// cleanup, which includes clearSession), and drop a stored-but-not-
// current checkpoint that matches the errored id.
const errorCase = SOURCE.match(/case 'error':[\s\S]{0,2500}?setLiveState\('PICKING'\);\s*break;/);
assert.ok(errorCase, 'expected an SSE case \'error\' handler in live-browser.js');
assert.match(
errorCase[0],
/if \(msg\.id && msg\.id === currentSessionId\) \{[\s\S]{0,160}?markSessionHandled\(\);[\s\S]{0,80}?cleanup\(\);[\s\S]{0,80}?break;/,
'an error reply naming the current session must run the same markSessionHandled + cleanup teardown as \'discarded\' so the durable checkpoint is cleared',
);
assert.match(
errorCase[0],
/if \(msg\.id && loadSession\(\)\?\.id === msg\.id\) clearSession\(\);/,
'an error reply naming a stored-but-not-current session must drop that checkpoint so a reload cannot resurrect it',
);
});
it('a late accept failure is recognized after the optimistic teardown (#384)', () => {
// Accept is optimistic: POST /events acknowledging the intent schedules
// cleanupAcceptedSession(), which nulls pendingAcceptedSession before
// live-accept.mjs has actually run. When the accept later fails (missing
// markers, preview error, receipt conflict, source_locked), the SSE
// 'error' guard keyed on pendingAcceptedSession could no longer match,
// so the user got only the generic error toast with no hint that their
// variant was never written. An awaitingAcceptResult id must be set on
// the optimistic success path, survive cleanupAcceptedSession, be
// matched in the 'error' case with an explicit not-saved message, and
// be released when the real accept result arrives.
assert.match(
SOURCE,
/awaitingAcceptResult = \{ id: acceptedSessionId \};[\s\S]{0,400}?scheduleAcceptCleanup\(pending\);/,
'the optimistic POST-success path must record awaitingAcceptResult before scheduling the teardown',
);
const errorCase = SOURCE.match(/case 'error':[\s\S]{0,2600}?setLiveState\('PICKING'\);\s*break;/);
assert.ok(errorCase, 'expected an SSE case \'error\' handler in live-browser.js');
assert.match(
errorCase[0],
/if \(awaitingAcceptResult\?\.id && msg\.id === awaitingAcceptResult\.id\) \{[\s\S]{0,700}?awaitingAcceptResult = null;[\s\S]{0,700}?may not have been saved[\s\S]{0,300}?break;/,
'an error naming the awaited accept must clear the marker and warn that the variant may not have been saved (hedged: a carbonize-phase failure fires this after the source WAS promoted)',
);
// Accept unlocks at the first variant, so a late generation agent_done
// for the same session id can arrive after Accept; only a carbonize
// agent_done is provably accept-side and may close the window.
const agentDoneCase = SOURCE.match(/case 'agent_done':[\s\S]{0,1200}?break;/);
assert.ok(agentDoneCase, 'expected an SSE case \'agent_done\' handler in live-browser.js');
assert.match(
agentDoneCase[0],
/msg\.data\?\.carbonize === true && awaitingAcceptResult\?\.id && msg\.id === awaitingAcceptResult\.id/,
'agent_done must only release the awaited accept marker for carbonize completions, or a late generation agent_done reopens the #384 hole',
);
const cleanupFn = SOURCE.match(/function cleanupAcceptedSession\(\) \{[\s\S]{0,1200}?\n \}/);
assert.ok(cleanupFn, 'expected cleanupAcceptedSession in live-browser.js');
assert.doesNotMatch(
cleanupFn[0],
/awaitingAcceptResult\s*=/,
'cleanupAcceptedSession must not clear awaitingAcceptResult - surviving the teardown is the point',
);
});
it('handleServerLost preserves the current recoverable phase', () => {
assert.doesNotMatch(
SOURCE,
@@ -286,6 +353,41 @@ describe('live-browser.js regression guards', () => {
);
});
it('server-lost toast frames the disconnect as resumable, not ended', () => {
assert.doesNotMatch(
SOURCE,
/Live server disconnected\. Session ended\./,
'the "Session ended" copy made agents rationalize bailing to direct edits; the session is resumable',
);
assert.match(
SOURCE,
/Live server connection lost\. Your session is saved;[^']*restart live-poll\.mjs to continue\./,
'server-lost toast should tell the user the session is saved and how to continue',
);
});
it('the agent-phase progress bar advances monotonically', () => {
// A behind/resumed checkpoint must not move the visible bar backward.
assert.doesNotMatch(
SOURCE,
/generationPhase = msg\.phase \|\| generationPhase;/,
'raw phase assignment lets a behind checkpoint regress the visible bar to an earlier phase',
);
assert.match(
SOURCE,
/case 'agent_phase':[\s\S]{0,400}?if \(shouldAdvancePhase\(generationPhase, msg\.phase\)\) generationPhase = msg\.phase;/,
'agent_phase should only advance the phase when it moves forward',
);
// The rank table must order the lifecycle so scaffolding/source_ready sit
// below generating and the reviewable phases.
assert.match(SOURCE, /function shouldAdvancePhase\(current, next\)/);
assert.match(
SOURCE,
/scaffolding: 2,[\s\S]{0,120}?source_ready: 4,[\s\S]{0,120}?(generation_ready|generating): 5,/,
'scaffolding and source_ready must rank below generating',
);
});
it('source reinjection preserves the visible variant after cycling', () => {
assert.doesNotMatch(
SOURCE,
@@ -920,10 +1022,13 @@ describe('live-browser.js regression guards', () => {
);
assert.match(SOURCE, /tune\.disabled = true/, 'pending Tune must be visibly loading but non-interactive');
assert.match(SOURCE, /Tune controls are ready\./, 'parameter arrival needs a clear ready indication');
// Source-mode DOM injection is gated to the `done` branch (it races
// framework ownership mid-generation), but a params-only publication must
// still flip the Tune controls into their loading state on the checkpoint.
assert.match(
SOURCE,
/msg\.publicationKind !== 'params' && arrivedVariants >= targetArrived/,
'a params-only publication must refresh even though the variant count is unchanged',
/case 'variant_progress':[\s\S]{0,120}?if \(msg\.publicationKind === 'params'\) parameterGenerationState = 'loading';/,
'a params-only publication must mark Tune controls loading even though the variant count is unchanged',
);
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
});
+26 -7
View File
@@ -320,7 +320,7 @@ describe('live-browser source contracts', () => {
);
assert.match(
SOURCE,
/case 'complete':\s*case 'accept':\s*if \(maybeCompleteAcceptedSession\(msg\)\) break;/,
/case 'complete':\s*case 'accept':[\s\S]{0,400}?if \(maybeCompleteAcceptedSession\(msg\)\) break;/,
'final accepted DOM cleanup should be driven by explicit complete or harness accept replies',
);
assert.match(
@@ -340,8 +340,8 @@ describe('live-browser source contracts', () => {
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
assert.match(
SOURCE,
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
'starting a new generation should clear any stale accepted-session sentinel first',
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,400}?awaitingAcceptResult = null;[\s\S]{0,120}?currentSessionId = id8\(\);/,
'starting a new generation should clear any stale accepted-session sentinel (and the awaited accept-result marker, #384) first',
);
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
@@ -421,11 +421,30 @@ describe('live-browser source contracts', () => {
);
});
it('loads progressive source checkpoints through the no-HMR fallback', () => {
it('does not source-inject per variant_progress checkpoint (HMR owns mid-generation reconciliation)', () => {
// Isolate the variant_progress handler body.
const progressCase = SOURCE.match(/case 'variant_progress':[\s\S]*?break;/);
assert.ok(progressCase, 'variant_progress case should exist');
assert.doesNotMatch(
progressCase[0],
/injectVariantsFromSource\(/,
'source-mode progress must not source-inject per checkpoint; it races React/Vue ownership and triggers removeChild errors',
);
// The svelte-component progressive path stays.
assert.match(
SOURCE,
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
progressCase[0],
/injectSvelteComponentsFromManifest\(msg\.previewFile, msg\.id\)/,
'component-preview progressive delivery must still stream per checkpoint',
);
});
it('source-injects only on the final done branch, keeping the 750ms settle', () => {
const doneCase = SOURCE.match(/case 'done':[\s\S]*?break;\n {8}case /);
assert.ok(doneCase, 'done case should exist');
assert.match(
doneCase[0],
/setTimeout\([\s\S]{0,260}?injectVariantsFromSource\(msg\.file, msg\.id, \{ generationCompleted: true \}\)[\s\S]{0,40}?\}, 750\)/,
'done should source-inject via the 750ms fallback for harnesses without HMR',
);
});
});
+61 -14
View File
@@ -1292,14 +1292,12 @@ function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, s
}
/**
* Read the wrapped file, find the "insert below this line" marker, splice in
* the rendered variants block, write back.
* Splice the rendered variants block into an array of wrapper lines at the
* "insert below this line" marker. Pure: returns the new lines array. Used
* both against a whole file (wrapper already in source) and against a
* standalone wrapper block (deferred source write, agent writes it now).
*/
async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
const filePath = path.join(tmp, wrapInfo.file);
const src = await fs.readFile(filePath, 'utf-8');
const lines = src.split('\n');
function spliceVariantsIntoLines(lines, { sessionId, output, commentSyntax, file, styleMode }) {
// Find the "Variants: insert below this line" comment line — definitive
// marker, robust to any indentation off-by-one. Matches in any comment
// style (HTML / JSX / Astro).
@@ -1307,7 +1305,7 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
l.includes('Variants: insert below this line'),
);
if (markerIdx === -1) {
throw new Error('insert marker not found in ' + wrapInfo.file);
throw new Error('insert marker not found in ' + file);
}
const indent = (lines[markerIdx].match(/^\s*/) || [''])[0];
@@ -1320,26 +1318,73 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
sessionId,
indent: wrapperIndent,
output,
commentSyntax: wrapInfo.commentSyntax,
file: wrapInfo.file,
styleMode: wrapInfo.styleMode,
commentSyntax,
file,
styleMode,
});
const endMarkerIdx = lines.findIndex((line, index) =>
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
);
if (endMarkerIdx === -1) {
throw new Error('end marker not found in ' + wrapInfo.file);
throw new Error('end marker not found in ' + file);
}
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
const tailIdx = commentSyntax.open === '{/*'
? endMarkerIdx
: endMarkerIdx - 1;
const next = [
return [
...lines.slice(0, markerIdx + 1),
block,
...lines.slice(tailIdx),
];
}
/**
* Read the wrapped file, find the "insert below this line" marker, splice in
* the rendered variants block, write back. Used when the wrapper is already
* present in source (agent's own wrap fallback, no preflight).
*/
async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
const filePath = path.join(tmp, wrapInfo.file);
const src = await fs.readFile(filePath, 'utf-8');
const lines = src.split('\n');
const next = spliceVariantsIntoLines(lines, {
sessionId,
output,
commentSyntax: wrapInfo.commentSyntax,
file: wrapInfo.file,
styleMode: wrapInfo.styleMode,
});
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
/**
* Deferred source write (preflight computed the scaffold but left source
* untouched). Splice the variants into the scaffold's `wrapperBlock`, then
* replace the picked element's source range with the result in ONE write —
* the 3.5 atomic single-edit semantics. `replaceEndLine < replaceStartLine`
* expresses a pure insertion (insert mode).
*/
async function writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId, output }) {
const filePath = path.join(tmp, wrapInfo.file);
const src = await fs.readFile(filePath, 'utf-8');
const lines = src.split('\n');
const wrapperLines = String(wrapInfo.wrapperBlock).split('\n');
const splicedWrapper = spliceVariantsIntoLines(wrapperLines, {
sessionId,
output,
commentSyntax: wrapInfo.commentSyntax,
file: wrapInfo.file,
styleMode: wrapInfo.styleMode,
});
const startIdx = wrapInfo.replaceStartLine - 1;
const endIdx = wrapInfo.replaceEndLine - 1; // may be startIdx-1 for insertion
const next = [
...lines.slice(0, startIdx),
...splicedWrapper,
...lines.slice(endIdx + 1),
];
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
@@ -1661,6 +1706,8 @@ export async function runAgentLoop({
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
} else if (wrapInfo.sourceWritten === false) {
await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output });
} else {
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
}
+7 -3
View File
@@ -117,10 +117,14 @@ export function stopLiveServer(tmp) {
} catch { /* already gone */ }
}
export function runInject(tmp, port) {
export function runInject(tmp, port, token) {
const out = execFileSync(
process.execPath,
[join(SCRIPTS_DIR, 'live-inject.mjs'), '--port', String(port)],
[
join(SCRIPTS_DIR, 'live-inject.mjs'),
'--port', String(port),
...(token ? ['--token', String(token)] : []),
],
{
cwd: tmp,
encoding: 'utf-8',
@@ -296,7 +300,7 @@ export async function bootFixtureSession({
const injectStartedAt = Date.now();
trace('setup.inject.start', { fixture: name });
log(`live-inject --port ${live.port}`);
const injectResult = runInject(tmp, live.port);
const injectResult = runInject(tmp, live.port, live.token);
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
+101 -1
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
import {
buildGenerationPreflight,
runGenerationPreflight,
clearSourceResolutionCache,
} from '../skill/scripts/live/generation-preflight.mjs';
const SCRIPTS_DIR = path.resolve('skill/scripts');
@@ -26,6 +27,7 @@ test('builds a replace preflight from the picker locator', () => {
assert.equal(command.mode, 'replace');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-1', '--count', '3',
'--defer-source-write',
'--element-id', 'hero',
'--classes', 'hero hero--dark',
'--tag', 'SECTION',
@@ -48,11 +50,22 @@ test('builds an insert preflight from the anchor locator', () => {
assert.equal(command.mode, 'insert');
assert.deepEqual(command.args.slice(1), [
'--id', 'session-2', '--count', '2', '--position', 'before',
'--id', 'session-2', '--count', '2',
'--defer-source-write', '--position', 'before',
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
]);
});
test('replace preflight always requests a deferred source write', () => {
const command = buildGenerationPreflight({
type: 'generate',
id: 'session-defer',
count: 3,
element: { classes: ['hero'] },
}, SCRIPTS_DIR);
assert.ok(command.args.includes('--defer-source-write'));
});
test('returns scaffold metadata without exposing child-process details', async () => {
const calls = [];
const result = await runGenerationPreflight({
@@ -108,6 +121,93 @@ test('yields to the event loop instead of blocking on the child process', async
assert.equal(tickedDuringPreflight, true, 'the event loop must stay responsive during preflight');
});
test('caches the resolved source file and reuses it via --file on the next generate', async () => {
clearSourceResolutionCache();
const cache = new Map();
const event = {
type: 'generate',
id: 'sess-a',
count: 3,
pageUrl: '/pricing',
element: { classes: ['hero'], tagName: 'SECTION' },
};
const firstArgs = [];
const first = await runGenerationPreflight(event, {
scriptsDir: SCRIPTS_DIR,
cache,
async execFileImpl(_file, args) {
firstArgs.push(...args);
return { stdout: '{"file":"src/Pricing.jsx","sourceWritten":false}\n', stderr: '' };
},
});
assert.equal(first.ok, true);
assert.ok(!firstArgs.includes('--file'), 'first pass does the tree search, no --file');
// Second generate on the SAME target (new session id) should point --file at
// the cached resolution and skip the search.
const secondArgs = [];
const second = await runGenerationPreflight({ ...event, id: 'sess-b' }, {
scriptsDir: SCRIPTS_DIR,
cache,
async execFileImpl(_file, args) {
secondArgs.push(...args);
return { stdout: '{"file":"src/Pricing.jsx","sourceWritten":false}\n', stderr: '' };
},
});
assert.equal(second.ok, true);
const fileIdx = secondArgs.indexOf('--file');
assert.notEqual(fileIdx, -1, 'cached resolution injects --file');
assert.equal(secondArgs[fileIdx + 1], 'src/Pricing.jsx');
});
test('evicts the cached resolution when the preflight fails', async () => {
const cache = new Map();
const event = {
type: 'generate',
id: 'sess-c',
count: 3,
pageUrl: '/pricing',
element: { classes: ['hero'] },
};
await runGenerationPreflight(event, {
scriptsDir: SCRIPTS_DIR,
cache,
async execFileImpl() { return { stdout: '{"file":"src/Pricing.jsx"}\n', stderr: '' }; },
});
assert.equal(cache.size, 1);
const error = new Error('spawn failed');
error.stderr = 'live-wrap.mjs: element not found\n';
await runGenerationPreflight(event, {
scriptsDir: SCRIPTS_DIR,
cache,
execFileImpl: () => Promise.reject(error),
});
assert.equal(cache.size, 0, 'a failed resolution is evicted so the next run re-searches');
});
test('caches the route source file, not the svelte-component manifest', async () => {
const cache = new Map();
const event = {
type: 'generate',
id: 'sess-svelte',
count: 3,
pageUrl: '/',
element: { classes: ['hero'] },
};
await runGenerationPreflight(event, {
scriptsDir: SCRIPTS_DIR,
cache,
async execFileImpl() {
return {
stdout: '{"file":"node_modules/.impeccable-live/x/manifest.json","sourceFile":"src/routes/+page.svelte","previewMode":"svelte-component"}\n',
stderr: '',
};
},
});
assert.deepEqual([...cache.values()], ['src/routes/+page.svelte']);
});
test('reports a child-process failure without leaking internals or throwing', async () => {
const error = new Error('spawn failed');
error.stderr = 'live-wrap.mjs: element not found\n';
+103 -3
View File
@@ -5,7 +5,7 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync, execSync, spawn } from 'node:child_process';
@@ -167,7 +167,7 @@ describe('live-server integration', () => {
// rather than an inline copy, so the server must serialize the canonical
// vocabulary into /live.js (next to the token/port).
const { LIVE_COMMANDS } = await import('../skill/scripts/live/vocabulary.mjs');
const body = await (await fetch(`http://localhost:${server.port}/live.js`)).text();
const body = await (await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`)).text();
assert.match(body, /window\.__IMPECCABLE_VOCAB__\s*=/);
const injected = JSON.parse(body.match(/window\.__IMPECCABLE_VOCAB__\s*=\s*(\[.*?\]);/s)[1]);
assert.deepEqual(injected, LIVE_COMMANDS);
@@ -259,7 +259,7 @@ describe('live-server integration', () => {
});
it('/live.js serves script with token injected', async () => {
const res = await fetch(`http://localhost:${server.port}/live.js`);
const res = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'application/javascript');
const text = await res.text();
@@ -302,6 +302,64 @@ describe('live-server integration', () => {
);
});
it('/live.js returns 401 without the token and 200 with it', async () => {
const noToken = await fetch(`http://localhost:${server.port}/live.js`);
assert.equal(noToken.status, 401);
const wrongToken = await fetch(`http://localhost:${server.port}/live.js?token=not-the-token`);
assert.equal(wrongToken.status, 401);
const ok = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
assert.equal(ok.status, 200);
const body = await ok.text();
assert.ok(body.includes('__IMPECCABLE_LIVE_INIT__'), 'authorized /live.js returns the assembled bundle');
});
it('CORS: a remote origin gets no Access-Control-Allow-Origin on any route', async () => {
const evil = 'https://evil.example';
for (const path of ['/health', `/live.js?token=${server.token}`, `/status?token=${server.token}`]) {
const res = await fetch(`http://localhost:${server.port}${path}`, { headers: { Origin: evil } });
assert.equal(
res.headers.get('access-control-allow-origin'),
null,
`remote origin must not be reflected on ${path}`,
);
}
// Preflight from a remote origin is likewise unauthorized to read.
const preflight = await fetch(`http://localhost:${server.port}/poll`, {
method: 'OPTIONS',
headers: { Origin: evil, 'Access-Control-Request-Method': 'POST' },
});
assert.equal(preflight.headers.get('access-control-allow-origin'), null);
});
it('CORS: a loopback origin is reflected with Vary: Origin', async () => {
for (const origin of [
`http://localhost:${server.port}`,
'http://127.0.0.1:5173',
'http://[::1]:5173',
]) {
const res = await fetch(`http://localhost:${server.port}/health`, { headers: { Origin: origin } });
assert.equal(res.headers.get('access-control-allow-origin'), origin, `reflect ${origin}`);
const vary = res.headers.get('vary') || '';
assert.ok(/\bOrigin\b/i.test(vary), `Vary: Origin present for ${origin}, got "${vary}"`);
}
// A hostname that merely extends "localhost" must not pass the loopback test.
const spoof = await fetch(`http://localhost:${server.port}/health`, {
headers: { Origin: 'http://localhost.evil.com' },
});
assert.equal(spoof.headers.get('access-control-allow-origin'), null, 'localhost.evil.com must not be reflected');
});
it('token-guarded routes still work with a loopback Origin header', async () => {
const origin = `http://localhost:${server.port}`;
const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`, {
headers: { Origin: origin },
});
assert.equal(res.status, 200);
assert.equal(res.headers.get('access-control-allow-origin'), origin);
});
it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-'));
let designServer;
@@ -3080,6 +3138,48 @@ colors: {}
}
});
it('/source rejects an absolute path to a sibling directory sharing the root prefix', async () => {
// Sibling dir whose name extends the project dir name (projeto -> projeto-evil):
// a plain string prefix check on the resolved path lets it escape the root.
// Build it off the server's real cwd (macOS symlinks /var -> /private/var,
// and the server guards against its own process.cwd(), i.e. the realpath).
const siblingDir = realpathSync(serverCwd) + '-evil';
mkdirSync(siblingDir, { recursive: true });
const secretPath = join(siblingDir, 'secret.txt');
writeFileSync(secretPath, 'TOP SECRET SIBLING');
try {
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent(secretPath)}`);
// Drain the body so the socket doesn't hang regardless of status.
await res.text().catch(() => {});
assert.equal(res.status, 403);
} finally {
rmSync(siblingDir, { recursive: true, force: true });
}
});
it('/source rejects the project root itself (directory, not a file)', async () => {
// `.` resolves exactly to cwd; the route only serves files, so an empty
// relative path is not a legitimate request and must be forbidden.
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent('.')}`);
await res.text().catch(() => {});
assert.equal(res.status, 403);
});
it('/source still serves a legitimate nested in-root file', async () => {
const nestedDir = join(serverCwd, 'nested');
mkdirSync(nestedDir, { recursive: true });
const nestedPath = join(nestedDir, 'page.html');
writeFileSync(nestedPath, '<h1>in root</h1>\n');
try {
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent('nested/page.html')}`);
assert.equal(res.status, 200);
const text = await res.text();
assert.ok(text.includes('in root'));
} finally {
rmSync(nestedDir, { recursive: true, force: true });
}
});
it('/modern-screenshot.js serves the vendored UMD build', async () => {
const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`);
assert.equal(res.status, 200);
+175
View File
@@ -0,0 +1,175 @@
/**
* Unit tests for the TanStack Start live-mode adapter.
* Run with: node --test tests/live-tanstack-adapter.test.mjs
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
detectTanStackStartProject,
applyTanStackLiveAdapter,
removeTanStackLiveAdapter,
patchTanStackRoot,
unpatchTanStackRoot,
buildTanStackLiveRootComponent,
} from '../skill/scripts/live/tanstack-adapter.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT_TSX = `import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
export const Route = createRootRoute({
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
`;
function scaffold(tmp, { ext = 'tsx', rootBody = ROOT_TSX, startPackage = '@tanstack/react-start' } = {}) {
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
name: 'app',
dependencies: { '@tanstack/react-router': '^1', [startPackage]: '^1' },
}));
writeFileSync(join(tmp, 'src', 'routes', `__root.${ext}`), rootBody);
}
describe('tanstack-adapter — detection', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
it('detects a TanStack Start project from package + root route', () => {
scaffold(tmp);
const project = detectTanStackStartProject(tmp);
assert.equal(project.rootRoute, 'src/routes/__root.tsx');
assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.tsx');
assert.equal(project.componentImport, '../impeccable/ImpeccableLiveRoot');
});
it('mirrors the root-route extension for the mount component (jsx)', () => {
scaffold(tmp, { ext: 'jsx' });
const project = detectTanStackStartProject(tmp);
assert.equal(project.rootRoute, 'src/routes/__root.jsx');
assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.jsx');
});
it('detects @tanstack/solid-start and @tanstack/start too', () => {
scaffold(tmp, { startPackage: '@tanstack/solid-start' });
assert.ok(detectTanStackStartProject(tmp));
});
it('returns null without the Start package (plain TanStack Router SPA)', () => {
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
dependencies: { '@tanstack/react-router': '^1' },
}));
writeFileSync(join(tmp, 'src', 'routes', '__root.tsx'), ROOT_TSX);
assert.equal(detectTanStackStartProject(tmp), null);
});
it('returns null without a root route file', () => {
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
dependencies: { '@tanstack/react-start': '^1' },
}));
assert.equal(detectTanStackStartProject(tmp), null);
});
});
describe('tanstack-adapter — patch/unpatch round-trip', () => {
it('inserts the import + mount component before <Scripts />', () => {
const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
assert.match(patched, /import ImpeccableLiveRoot from '\.\.\/impeccable\/ImpeccableLiveRoot';/);
assert.match(patched, /\{\/\* impeccable-live-tanstack-start \*\/\}/);
assert.match(patched, /<ImpeccableLiveRoot \/>/);
// component renders before <Scripts />
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('<Scripts />'));
});
it('round-trips byte-for-byte (patch then unpatch)', () => {
const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
assert.notEqual(patched, ROOT_TSX);
assert.equal(unpatchTanStackRoot(patched), ROOT_TSX);
});
it('is idempotent (double patch adds one import + one mount)', () => {
const once = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
const twice = patchTanStackRoot(once, '../impeccable/ImpeccableLiveRoot');
assert.equal(twice, once);
assert.equal((twice.match(/<ImpeccableLiveRoot \/>/g) || []).length, 1);
assert.equal((twice.match(/^import ImpeccableLiveRoot/gm) || []).length, 1);
});
it('falls back to </body> when <Scripts /> is absent', () => {
const noScripts = ROOT_TSX.replace(/\s*<Scripts \/>/, '');
const patched = patchTanStackRoot(noScripts, '../impeccable/ImpeccableLiveRoot');
assert.match(patched, /<ImpeccableLiveRoot \/>/);
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('</body>'));
assert.equal(unpatchTanStackRoot(patched), noScripts);
});
it('builds a client-only mount component carrying the token', () => {
const body = buildTanStackLiveRootComponent(8123, 'tok-xyz');
assert.match(body, /http:\/\/localhost:8123\/live\.js\?token=tok-xyz/);
assert.match(body, /useEffect/);
assert.match(body, /typeof document === 'undefined'/);
assert.match(body, /data-impeccable-live-tanstack/);
});
});
describe('tanstack-adapter — apply/remove on disk', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
it('apply writes the component + patches root, remove restores byte-for-byte', () => {
scaffold(tmp);
const original = readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8');
const applied = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' });
assert.equal(applied.adapter, 'tanstack-start');
assert.equal(applied.inserted, true);
assert.ok(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')));
assert.match(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), /ImpeccableLiveRoot/);
assert.match(
readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'),
/localhost:9100\/live\.js\?token=T1/,
);
const removed = removeTanStackLiveAdapter({ cwd: tmp });
assert.equal(removed.removed, true);
assert.equal(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')), false);
assert.equal(existsSync(join(tmp, 'src/impeccable')), false, 'empty managed dir pruned');
assert.equal(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), original);
});
it('refuses to clobber an unmanaged file at the component path', () => {
scaffold(tmp);
mkdirSync(join(tmp, 'src/impeccable'), { recursive: true });
writeFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'export const mine = 1;\n');
const result = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' });
assert.equal(result.error, 'tanstack_component_conflict');
// unmanaged file untouched
assert.equal(
readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'),
'export const mine = 1;\n',
);
});
});
+37
View File
@@ -254,6 +254,43 @@ describe('wrapCli integration', () => {
});
it('--defer-source-write leaves source untouched and returns the wrapper block', () => {
const html = `<!DOCTYPE html>
<html>
<body>
<div class="hero-section">
<h1>Hello World</h1>
<p>Welcome to our site.</p>
</div>
</body>
</html>`;
const file = join(tmp, 'index.html');
writeFileSync(file, html);
const result = JSON.parse(execSync(
`node skill/scripts/live-wrap.mjs --id defer1 --count 3 --classes "hero-section" --defer-source-write --file "${file}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
));
// Source is NOT written by the preflight (no reload storm).
assert.equal(readFileSync(file, 'utf-8'), html);
// Deferred contract fields present for the agent's atomic edit.
assert.equal(result.sourceWritten, false);
assert.ok(typeof result.wrapperBlock === 'string' && result.wrapperBlock.length > 0);
assert.ok(result.wrapperBlock.includes('data-impeccable-variants="defer1"'));
assert.ok(result.wrapperBlock.includes('Variants: insert below this line'));
assert.ok(result.wrapperBlock.includes('impeccable-variants-end defer1'));
assert.equal(typeof result.replaceStartLine, 'number');
assert.equal(typeof result.replaceEndLine, 'number');
// The replace range points at the picked <div class="hero-section"> block
// (1-indexed lines 4..7 of the source above).
const lines = html.split('\n');
assert.ok(lines[result.replaceStartLine - 1].includes('class="hero-section"'));
assert.ok(lines[result.replaceEndLine - 1].includes('</div>'));
});
it('wraps a JSX element and uses JSX comment syntax', () => {
const jsx = `export default function App() {
return (
+357
View File
@@ -0,0 +1,357 @@
/**
* Deterministic smoke tests for the new-work interactive flow.
*
* Covers the parts a user actually touches: the serve-question decision page
* (pick, re-roll + steer + re-deal, canon, tab close) driven through a real
* browser by the scripted user bot, plus the offline fake image generator.
* No LLM calls; a real Chromium via Playwright supplies full page fidelity
* (heartbeats, re-roll reload, tab close). Kept OUT of `bun run test` like
* live-e2e; run it with `bun run test:new-work-e2e`.
*
* The concept-seed direction roll (challengers, ASSIGNED INDEX, the no
* PRODUCT.md gate) is already covered by tests/concept-seed.test.mjs and is
* not repeated here.
*
* One-time setup: npx playwright install chromium
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runUserBot } from './new-work-e2e/user-bot.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SERVE = path.join(ROOT, 'skill', 'scripts', 'serve-question.mjs');
const GENERATE = path.join(ROOT, 'skill', 'scripts', 'generate-image.mjs');
const CATALOG_DIR = path.join(ROOT, 'tests', 'fixtures', 'concept-catalog');
let playwright;
let browser;
before(async () => {
try {
playwright = await import('playwright');
} catch (err) {
throw new Error(
`Playwright is required for new-work-e2e tests (${err.message}). Run: npx playwright install chromium`,
);
}
try {
browser = await playwright.chromium.launch({ headless: true });
} catch (err) {
throw new Error(`Failed to launch Chromium (${err.message}). Run: npx playwright install chromium`);
}
});
after(async () => {
if (browser) await browser.close();
});
// --------------------------------------------------------------------------
// Workspace + serve-question helpers
// --------------------------------------------------------------------------
function makeWorkspace() {
const dir = mkdtempSync(path.join(tmpdir(), 'new-work-e2e-'));
writeFileSync(
path.join(dir, 'PRODUCT.md'),
'# Product\n\n## Register\n\nbrand\n\n## Platform\n\nweb\n',
);
return dir;
}
// serve-question writes its state under cwd; run everything from the workspace.
function run(args, cwd) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [SERVE, ...args], {
cwd,
env: { ...process.env, IMPECCABLE_QUESTION_FORCE: '1', IMPECCABLE_CATALOG_DIR: CATALOG_DIR },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (c) => { out += c; });
child.stderr.on('data', (c) => { err += c; });
child.on('exit', (code) => resolve({ code, out, err }));
});
}
async function startDaemon(cwd, payload, key) {
const payloadPath = path.join(cwd, `${key}.payload.json`);
writeFileSync(payloadPath, JSON.stringify(payload));
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', key], cwd);
assert.equal(started.code, 0, `--start failed: ${started.out} ${started.err}`);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, `no URL from --start: ${started.out}`);
return { url, payloadPath };
}
// Poll --wait until it settles on a terminal exit code (0 answered, 2 gone,
// 4 page closed); loop while it reports WAITING (3).
async function waitLoop(cwd, key, { poll = 30, max = 20 } = {}) {
for (let i = 0; i < max; i++) {
const res = await run(['--wait', '--key', key, '--poll', String(poll)], cwd);
if (res.code !== 3) return res;
}
throw new Error('waitLoop exceeded max iterations');
}
async function stopDaemon(cwd, key) {
await run(['--stop', '--key', key], cwd).catch(() => {});
}
function makeFakeImage(cwd, prompt, outName) {
const out = path.join(cwd, outName);
const res = spawnSyncGen(prompt, out);
assert.equal(res.status, 0, `generate-image fake failed: ${res.stderr}`);
return out;
}
function spawnSyncGen(prompt, out, size = null) {
const args = [GENERATE, '--prompt', prompt, '--out', out];
if (size) args.push('--size', size);
return spawnSync(process.execPath, args, {
env: { ...process.env, IMPECCABLE_IMAGE_GEN_FAKE: '1' },
encoding: 'buffer',
});
}
// --------------------------------------------------------------------------
// serve-question interactive cycles
// --------------------------------------------------------------------------
describe('new-work-e2e: serve-question decision page', () => {
it('(a) pick assigned returns the option, hero/board fields, and the CHOSEN CARD directive', async () => {
const cwd = makeWorkspace();
const key = 'pick';
const hero = makeFakeImage(cwd, 'Fillmore handbill hero', 'hero.png');
const board = makeFakeImage(cwd, 'Fillmore handbill board', 'board.png');
const payload = {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', hero, board },
{ id: 'challenger-teletext', label: 'Teletext Service', body: 'block-mosaic pages' },
],
reroll: true,
canon: true,
steer: true,
};
await startDaemon(cwd, payload, key);
try {
const bot = await runUserBot({
workspaceDir: cwd, key, browser,
policy: [{ pick: 'assigned', steer: 'warmer palette' }],
});
assert.equal(bot.results[0].action, 'pick');
const collected = await waitLoop(cwd, key);
assert.equal(collected.code, 0, collected.out);
assert.match(collected.out, /ANSWER: /);
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
assert.equal(answer.optionId, 'assigned');
assert.equal(answer.steer, 'warmer palette');
assert.ok(answer.hero, 'answer carries the chosen hero path');
assert.ok(answer.board, 'answer carries the chosen board path');
assert.match(collected.out, /CHOSEN CARD:/);
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(b) re-roll with steer keeps the server alive; --update re-deals; the next pick is terminal', async () => {
const cwd = makeWorkspace();
const key = 'reroll';
const payload1 = {
title: 'Choose the visual world',
options: [
{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' },
{ id: 'challenger-a', label: 'Alt One' },
],
reroll: true, steer: true, canon: true,
};
const payload2 = {
title: 'Choose the visual world',
options: [
{ id: 'assigned', label: 'Second Hand', kicker: 'THE ROLL' },
{ id: 'challenger-b', label: 'Alt Two' },
],
reroll: true, steer: true,
};
await startDaemon(cwd, payload1, key);
try {
// Bot drives the whole page: re-roll (with steer) then, after the page
// reloads into the next hand, pick the assigned card.
const botPromise = runUserBot({
workspaceDir: cwd, key, browser,
policy: [{ reroll: true, steer: 'colder, more restraint' }, { pick: 'assigned' }],
});
// First answer: the re-roll. Server must stay alive afterwards.
const first = await waitLoop(cwd, key);
assert.equal(first.code, 0, first.out);
assert.match(first.out, /"optionId":"reroll"/);
assert.match(first.out, /colder, more restraint/);
assert.ok(existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
'server state file survives a re-roll');
// Deliver the next hand; the live page reloads itself.
const nextPayloadPath = path.join(cwd, 'next.json');
writeFileSync(nextPayloadPath, JSON.stringify(payload2));
const updated = await run(['--update', '--key', key, '--payload', nextPayloadPath], cwd);
assert.equal(updated.code, 0, updated.out);
// Second answer: the terminal pick on the re-dealt hand.
const second = await waitLoop(cwd, key);
assert.equal(second.code, 0, second.out);
assert.match(second.out, /"optionId":"assigned"/);
const bot = await botPromise;
assert.equal(bot.results[0].action, 'reroll');
assert.ok(bot.results[0].reloaded, 'page reloaded into the next hand');
assert.equal(bot.results[1].action, 'pick');
// Terminal pick cleans the state file up.
assert.ok(!existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
'terminal pick removes the server state file');
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(c) canon click returns optionId canon and prints the CANON CHOSEN directive', async () => {
const cwd = makeWorkspace();
const key = 'canon';
const payload = {
title: 'Choose the visual world',
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
reroll: true, canon: true, steer: true,
};
await startDaemon(cwd, payload, key);
try {
await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ canon: true }] });
const collected = await waitLoop(cwd, key);
assert.equal(collected.code, 0, collected.out);
assert.match(collected.out, /"optionId":"canon"/);
assert.match(collected.out, /CANON CHOSEN:/);
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(d) closing the tab makes --wait exit 4 PAGE CLOSED', async () => {
const cwd = makeWorkspace();
const key = 'close';
const payload = {
title: 'Choose the visual world',
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
reroll: true, steer: true,
};
await startDaemon(cwd, payload, key);
try {
const bot = await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ close: true }] });
assert.equal(bot.results[0].action, 'close');
assert.ok(bot.results[0].beat > 0, 'a heartbeat landed before the tab closed');
// --wait must observe the stale heartbeat and report the closed page.
const res = await run(['--wait', '--key', key, '--poll', '30'], cwd);
assert.equal(res.code, 4, `expected exit 4, got ${res.code}: ${res.out}`);
assert.match(res.out, /PAGE CLOSED/);
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(e) an option with no hero renders a text-only card (no .media element)', async () => {
const cwd = makeWorkspace();
const key = 'textonly';
const hero = makeFakeImage(cwd, 'has a hero', 'hero.png');
const payload = {
title: 'Choose the visual world',
options: [
{ id: 'assigned', label: 'Text Only Direction', body: 'a grounded direction, no comp' },
{ id: 'challenger-hero', label: 'Has A Card', hero },
],
reroll: true, steer: true,
};
const { url } = await startDaemon(cwd, payload, key);
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil: 'load' });
await page.waitForSelector('button.choose');
const textOnlyMedia = await page.$('.card[data-id="assigned"] .media');
const heroMedia = await page.$('.card[data-id="challenger-hero"] .media');
const textOnlyFace = await page.$('.card[data-id="assigned"] .face.text-only');
await context.close();
assert.equal(textOnlyMedia, null, 'text-only card has no .media region');
assert.ok(textOnlyFace, 'text-only card carries the .text-only face class');
assert.ok(heroMedia, 'the hero card still renders its .media region');
} finally {
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
});
// --------------------------------------------------------------------------
// Fake image generation
// --------------------------------------------------------------------------
describe('new-work-e2e: fake image generation', () => {
it('is deterministic per prompt and encodes the SYNTHETIC marker', () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
try {
const a = path.join(cwd, 'a.png');
const b = path.join(cwd, 'b.png');
const r1 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
const r2 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', b);
assert.equal(r1.status, 0, r1.stderr?.toString());
assert.equal(r2.status, 0, r2.stderr?.toString());
assert.ok(existsSync(a) && existsSync(b), 'both files exist');
assert.match(r1.stdout.toString(), /\$0\.00/, 'cost line reads $0.00');
const bytesA = readFileSync(a);
const bytesB = readFileSync(b);
assert.ok(bytesA.equals(bytesB), 'same prompt yields identical bytes');
// Valid PNG signature + the SYNTHETIC marker (in the tEXt chunk).
assert.equal(bytesA.slice(0, 8).toString('hex'), '89504e470d0a1a0a');
assert.ok(bytesA.includes(Buffer.from('SYNTHETIC')), 'PNG carries the SYNTHETIC marker');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it('renders a different palette for a different prompt', () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
try {
const a = path.join(cwd, 'a.png');
const c = path.join(cwd, 'c.png');
spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
spawnSyncGen('Teletext broadcast mosaic, cold blue', c);
const bytesA = readFileSync(a);
const bytesC = readFileSync(c);
assert.ok(!bytesA.equals(bytesC), 'different prompts produce different images');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it('the SVG variant carries the readable prompt text and SYNTHETIC COMP label', () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
try {
const svg = path.join(cwd, 'comp.svg');
const res = spawnSyncGen('teletext broadcast mosaic', svg, '800x600');
assert.equal(res.status, 0, res.stderr?.toString());
const text = readFileSync(svg, 'utf8');
assert.match(text, /^<\?xml/, 'is an SVG document');
assert.match(text, /SYNTHETIC COMP/);
assert.match(text, /teletext/i, 'the prompt text is rendered');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
+65
View File
@@ -0,0 +1,65 @@
# new-work E2E
A cheap, deterministic smoke suite for the interactive parts of new-work: the
serve-question decision page and the offline image generator. It is kept out of
`bun run test` and runs on demand.
```bash
bun run test:new-work-e2e
```
One-time setup: `npx playwright install chromium` (the suite drives a real
Chromium so the page runs its own JS, exactly as a user's tab would).
## What it covers
`tests/new-work-e2e.test.mjs` opens the served decision page with a real
browser and drives it through the scripted user bot, then asserts on the
serve-question protocol output:
- **pick assigned** returns the chosen `optionId`, the typed steer, the
`hero`/`board` fields, and the `CHOSEN CARD` directive printed by `--wait`.
- **re-roll with steer** keeps the daemon alive, `--update` re-deals the next
hand, the page reloads itself, and the following pick is terminal (state file
cleaned up).
- **canon** returns `optionId: canon` and prints the `CANON CHOSEN` directive.
- **tab close** stops the page heartbeats so `--wait` exits 4 `PAGE CLOSED`.
- **text-only card** renders with no `.media` region when an option has no hero.
- **fake image generation**: same prompt yields identical bytes, the file
exists, the `SYNTHETIC` marker is present, and different prompts produce
different palettes.
The concept-seed direction roll (challengers, `ASSIGNED INDEX`, the no
PRODUCT.md gate) is already covered by `tests/concept-seed.test.mjs` and is not
repeated here.
## Pieces
- `user-bot.mjs` is a module plus CLI. Given a workspace dir it resolves the
running daemon from `.impeccable/questions/<key>.state.json`, opens the page,
and runs a JSON policy of real clicks: `{"pick":"assigned"}`,
`{"reroll":true,"steer":"warmer"}`, `{"pick":"challenger-*"}`,
`{"canon":true}`, `{"close":true}`. The deterministic tier passes an
already-launched browser in; the CLI launches its own Chromium.
- `IMPECCABLE_IMAGE_GEN_FAKE=1` switches `skill/scripts/generate-image.mjs` to
the offline stand-in: no OpenAI call, no key, a `$0.00` cost line, and a
deterministic image (SVG for `.svg` out with the wrapped prompt text and a
`SYNTHETIC COMP` label; a valid palette-stripe PNG otherwise, with the prompt
and marker in a PNG `tEXt` chunk).
## Planned LLM tier (not built yet)
The same scaffolding supports an opt-in LLM tier later, mirroring the two-layer
pattern in `tests/live-e2e`:
- A real model plays the user through the same scripted `user-bot.mjs` policy,
choosing and steering instead of following canned actions.
- `IMPECCABLE_IMAGE_GEN_FAKE` still stands in for image spend, so a full
concept-to-card cycle runs without paying per render.
- Assertions run against the tool-call trace via the skill-behavior harness,
the same way `tests/skill-behavior` keys on the trace rather than free-form
output.
Cost posture: the deterministic tier is free (no API calls, local Chromium).
The LLM tier hits a provider and costs money, so it stays opt-in and out of CI,
matching how `test:live-e2e` and `test:skill-behavior` are gated today.
+196
View File
@@ -0,0 +1,196 @@
/**
* Scripted user bot for the new-work interactive smoke suite.
*
* Given a workspace directory, it discovers a running serve-question daemon
* from `.impeccable/questions/<key>.state.json`, opens the served page in a
* real browser, and drives it through a scripted policy: it clicks the real
* `button.choose`, `#reroll`, and `#canon` controls, types into `#steer`, and
* closes the tab for the exit-4 path. Because a real page runs the page's own
* JS, heartbeats fire and re-roll reloads behave exactly as a user's would.
*
* The deterministic tier passes an already-launched Playwright browser in.
* Run as a CLI (`--workspace DIR --policy '<json>'`) it launches its own
* Chromium. The policy is an ordered list of actions:
*
* { "pick": "assigned" } click the assigned card
* { "pick": "challenger-*" } click the first matching card
* { "pickIndex": 1 } click the Nth choose button
* { "reroll": true, "steer": "warmer" } type the steer, click Re-roll
* { "canon": true } click Play it straight
* { "close": true } close the tab (stops heartbeats)
*
* A `steer` on any action is typed into `#steer` first when the field exists.
* After a re-roll the bot waits for the page to reload into the next hand
* (delivered out of band by `serve-question --update`) before the next action.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
function questionsDir(workspaceDir) {
return path.join(workspaceDir, '.impeccable', 'questions');
}
// Resolve the served URL from the daemon state file. When no key is given and
// several exist, the newest wins.
export function resolveQuestion(workspaceDir, key = null) {
const dir = questionsDir(workspaceDir);
if (!existsSync(dir)) throw new Error(`no questions dir at ${dir}`);
const stateFiles = readdirSync(dir).filter((f) => f.endsWith('.state.json'));
if (stateFiles.length === 0) throw new Error(`no *.state.json in ${dir}`);
let file;
if (key) {
file = `${key}.state.json`;
if (!stateFiles.includes(file)) throw new Error(`no state file for key ${key}`);
} else {
file = stateFiles
.map((f) => ({ f, mtime: readFileSync(path.join(dir, f), 'utf8') && f }))
.sort()
.pop().f;
}
const resolvedKey = file.replace(/\.state\.json$/, '');
const state = JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
return { key: resolvedKey, url: state.url, port: state.port, pid: state.pid };
}
function stateLastBeat(workspaceDir, key) {
try {
const state = JSON.parse(readFileSync(path.join(questionsDir(workspaceDir), `${key}.state.json`), 'utf8'));
return state.lastBeat || 0;
} catch {
return 0;
}
}
async function typeSteer(page, action) {
if (action.steer == null) return;
const steer = await page.$('#steer');
if (steer) await steer.fill(String(action.steer));
}
function chooseSelector(pick) {
if (pick.endsWith('*')) {
const prefix = pick.slice(0, -1);
return `button.choose[data-id^="${prefix}"]`;
}
return `button.choose[data-id="${pick}"]`;
}
async function runAction(page, action, { workspaceDir, key }) {
await typeSteer(page, action);
if (action.reroll) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'load', timeout: 60000 }).catch(() => {}),
page.click('#reroll'),
]);
// Fresh hand loaded: wait for the interactive controls of the next round.
await page.waitForSelector('button.choose', { timeout: 30000 });
return { action: 'reroll', reloaded: true };
}
if (action.canon) {
await page.click('#canon');
return { action: 'canon' };
}
if (action.close) {
// Make sure at least one heartbeat has been recorded so the --wait poll can
// later see the beat go stale (the exit-4 PAGE CLOSED path).
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !stateLastBeat(workspaceDir, key)) {
await page.waitForTimeout(200);
}
await page.close();
return { action: 'close', beat: stateLastBeat(workspaceDir, key) };
}
if (action.pickIndex != null) {
const buttons = await page.$$('button.choose');
const btn = buttons[action.pickIndex];
if (!btn) throw new Error(`no choose button at index ${action.pickIndex}`);
await btn.click();
return { action: 'pick', index: action.pickIndex };
}
if (action.pick) {
await page.click(chooseSelector(action.pick));
return { action: 'pick', id: action.pick };
}
throw new Error(`unknown action: ${JSON.stringify(action)}`);
}
/**
* Drive the served question page through the policy. Pass a launched
* Playwright `browser` (deterministic tier) or omit it to launch Chromium.
*/
export async function runUserBot({ workspaceDir, key = null, policy = [], browser = null }) {
let ownBrowser = null;
let pw = null;
if (!browser) {
pw = await import('playwright');
ownBrowser = await pw.chromium.launch({ headless: true });
browser = ownBrowser;
}
const question = resolveQuestion(workspaceDir, key);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(question.url, { waitUntil: 'load' });
await page.waitForSelector('button.choose', { timeout: 30000 });
const results = [];
let closed = false;
try {
for (const action of policy) {
const result = await runAction(page, action, { workspaceDir, key: question.key });
results.push(result);
if (result.action === 'close') { closed = true; break; }
// Give the answer POST time to land before the process may exit.
if (result.action === 'pick' || result.action === 'canon') {
await page.waitForTimeout(300);
}
}
} finally {
if (!closed) await context.close().catch(() => {});
if (ownBrowser) await ownBrowser.close().catch(() => {});
}
return { key: question.key, url: question.url, results };
}
// --------------------------------------------------------------------------
// CLI
// --------------------------------------------------------------------------
function cliArg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
const workspaceDir = cliArg('workspace');
const key = cliArg('key');
const policyRaw = cliArg('policy');
if (!workspaceDir || !policyRaw) {
console.error('user-bot: --workspace <dir> and --policy <json> are required.');
process.exit(1);
}
let policy;
try {
policy = JSON.parse(policyRaw);
} catch (err) {
console.error(`user-bot: --policy must be JSON (${err.message})`);
process.exit(1);
}
runUserBot({ workspaceDir, key, policy })
.then((out) => {
console.log(JSON.stringify(out));
process.exit(0);
})
.catch((err) => {
console.error(`user-bot: ${err.message}`);
process.exit(1);
});
}
+297 -1
View File
@@ -98,8 +98,11 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
}
if (providers.includes('.agents')) {
mkdirSync(join(bundleRoot, '.codex'), { recursive: true });
// Mirror production: the Codex bundle's `.codex/hooks.json` targets its own
// `.codex/skills` payload. The CLI installs the skill at `.agents/skills`, so
// the installer must rewrite this command to `.agents/skills` (see below).
writeFileSync(join(bundleRoot, '.codex', 'hooks.json'), JSON.stringify({
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".agents/skills/impeccable/scripts/hook.mjs"' }] }] },
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
}, null, 2));
}
return bundleRoot;
@@ -675,6 +678,12 @@ describe('skills install/update: local universal bundle e2e', () => {
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true);
expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(true);
// The CLI puts Codex's skill at `.agents/skills`, so the project-scope hook
// command must point there — not at the bundle's own `.codex/skills` path,
// which would resolve to a nonexistent file and silently no-op the hook.
const codexHooks = readFileSync(join(tmp, '.codex', 'hooks.json'), 'utf8');
expect(codexHooks).toContain('.agents/skills/impeccable/scripts/hook.mjs');
expect(codexHooks).not.toContain('.codex/skills/impeccable/scripts/hook.mjs');
rmSync(tmp, { recursive: true, force: true });
}, 15000);
@@ -875,6 +884,145 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(home, { recursive: true, force: true });
}, 15000);
// OpenCode reads global skills from its config directory, not ~/.opencode:
// $OPENCODE_CONFIG_DIR/skills, else $XDG_CONFIG_HOME/opencode/skills, else
// ~/.config/opencode/skills. Writing to ~/.opencode/skills produced an
// install `opencode debug skill` never saw (#406).
test('global install writes OpenCode skills to ~/.config/opencode/skills (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-oc-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-oc-'));
execSync('git init', { cwd: tmp });
mkdirSync(join(home, '.opencode'), { recursive: true });
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete env.OPENCODE_CONFIG_DIR;
delete env.XDG_CONFIG_HOME;
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
expect(output).toContain('Installed impeccable into: .opencode (global)');
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('OpenCode global dir honors OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-env-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-env-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const baseEnv = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete baseEnv.OPENCODE_CONFIG_DIR;
delete baseEnv.XDG_CONFIG_HOME;
run('skills install -y --providers=opencode --scope=global --no-hooks', {
cwd: tmp,
env: { ...baseEnv, OPENCODE_CONFIG_DIR: join(home, 'occfg') },
});
expect(existsSync(join(home, 'occfg', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
run('skills install -y --providers=opencode --scope=global --no-hooks', {
cwd: tmp,
env: { ...baseEnv, XDG_CONFIG_HOME: join(home, 'xdg') },
});
expect(existsSync(join(home, 'xdg', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 20000);
test('global OpenCode install migrates a legacy ~/.opencode/skills copy, sparing siblings (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-migrate-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-migrate-'));
execSync('git init', { cwd: tmp });
writeSkill(home, '.opencode', 'impeccable');
writeSkill(home, '.opencode', 'unrelated-skill');
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete env.OPENCODE_CONFIG_DIR;
delete env.XDG_CONFIG_HOME;
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
// The stranded legacy copy is gone; the sibling skill is untouched.
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
expect(existsSync(join(home, '.opencode', 'skills', 'unrelated-skill', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('OpenCode migration never follows a symlinked legacy skills dir (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-symlink-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-symlink-'));
execSync('git init', { cwd: tmp });
// Shared skill storage with ~/.opencode/skills symlinked at it. Deleting
// "the legacy copy" through the link would destroy the shared original.
writeSkill(join(home, '.config'), 'agents', 'impeccable');
mkdirSync(join(home, '.opencode'), { recursive: true });
symlinkSync(join(home, '.config', 'agents', 'skills'), join(home, '.opencode', 'skills'), 'dir');
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete env.OPENCODE_CONFIG_DIR;
delete env.XDG_CONFIG_HOME;
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
// The shared store behind the symlink is intact, link included.
expect(existsSync(join(home, '.config', 'agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(lstatSync(join(home, '.opencode', 'skills')).isSymbolicLink()).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('OpenCode migration leaves a home-rooted repo project install alone (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-homerepo-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-homerepo-'));
execSync('git init', { cwd: tmp });
// The home dir IS a repo (dotfiles setup): .opencode/skills there is a
// live project-scope install, not a stranded pre-#406 global one.
execSync('git init', { cwd: home });
writeSkill(home, '.opencode', 'impeccable');
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete env.OPENCODE_CONFIG_DIR;
delete env.XDG_CONFIG_HOME;
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('global install detects OpenCode from ~/.config/opencode alone (#406)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-detect-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-detect-'));
execSync('git init', { cwd: tmp });
// No ~/.opencode at all; only the config dir marks OpenCode as present.
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
delete env.OPENCODE_CONFIG_DIR;
delete env.XDG_CONFIG_HOME;
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
expect(output).toContain('Installed impeccable into: .opencode (global)');
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
// Project scope must stay at .pi/skills/ even when the git root IS the home
// dir (dotfiles repos), where scope can't be inferred from the path alone.
// An existing global install at ~/.pi/agent/skills must not swallow the
@@ -1253,6 +1401,154 @@ describe('hook manifest merge helpers', () => {
});
});
// ─── Hook command path resolution (issue #399, part 1) ───────────────────────
// The bundled Claude manifest ships a ${CLAUDE_PROJECT_DIR}-relative command.
// That resolves per-project, so a user-level (~/.claude/settings.local.json)
// hook — which fires in EVERY project — must be rewritten to the resolved
// absolute skill path, or Node crashes on every PostToolUse/Stop in projects
// without a local skill copy. Project-level hooks keep ${CLAUDE_PROJECT_DIR}.
// Both are wrapped with a missing-file guard so a missing script exits 0.
// A bundle whose Claude manifest mirrors production: ${CLAUDE_PROJECT_DIR}-relative.
function createProjectDirBundle(root) {
const bundleRoot = join(root, 'projdir-bundle');
const skillDir = join(bundleRoot, '.claude', 'skills', 'impeccable', 'scripts');
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'),
'---\nname: impeccable\nversion: 9.9.9-local\n---\nbundle\n');
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
hooks: {
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
] }],
Stop: [{ hooks: [
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
] }],
},
}, null, 2));
return bundleRoot;
}
function claudeHookCommands(manifestPath) {
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
return Object.values(parsed.hooks).flatMap(entries =>
entries.flatMap(entry => (entry.hooks || []).map(h => h.command)));
}
describe('copyProviderHooks: hook command path resolution (#399)', () => {
test('project-scope hook keeps ${CLAUDE_PROJECT_DIR} and adds a missing-file guard', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-project-'));
const bundleDir = createProjectDirBundle(tmp);
// skillRoot === root === a non-home project dir: keep the portable token.
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: tmp });
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
expect(commands.length).toBeGreaterThan(0);
for (const command of commands) {
expect(command).toContain('${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs');
expect(command).not.toContain(tmp); // no absolute rewrite for project scope
expect(command).toContain('[ ! -f '); // guarded so a missing file exits 0
expect(command).not.toContain('|| true'); // must preserve node's exit code
}
rmSync(tmp, { recursive: true, force: true });
});
// The user/global case (isHomeDir(root) true) is driven end-to-end through a
// child process below ('user-level update writes an absolute, guarded hook'),
// where HOME is set in the child's env so os.homedir() reflects it. It cannot
// be faked reliably in-process, so it is not unit-tested here.
test('project hook pointing at a global skill uses the absolute skill path', () => {
// --scope=global shape: manifest root is the project, skill lives in home.
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-split-'));
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-skillroot-'));
const bundleDir = createProjectDirBundle(tmp);
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
const absolute = join(skillHome, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
for (const command of commands) {
expect(command).toContain(absolute);
expect(command).not.toContain('${CLAUDE_PROJECT_DIR}');
expect(command).toContain('[ ! -f ');
}
rmSync(tmp, { recursive: true, force: true });
rmSync(skillHome, { recursive: true, force: true });
});
});
// ─── Update scope resolution (issue #399, part 2) ────────────────────────────
describe('skills update: names the resolved target and honors scope (#399)', () => {
test('user-level update writes an absolute, guarded hook to ~/.claude', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-user-'));
const home = mkdtempSync(join(tmpdir(), 'imp-update-user-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
// Seed a user-level install (skills only), then update it with hooks.
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
expect(existsSync(join(home, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
const output = run('skills update -y --user', { cwd: tmp, env });
expect(output).toContain('user level'); // scope named explicitly
expect(output).toContain('~'); // resolved home path named, not a bare ".claude"
const settingsPath = join(home, '.claude', 'settings.local.json');
expect(existsSync(settingsPath)).toBe(true);
const raw = readFileSync(settingsPath, 'utf8');
expect(raw).toContain(join(home, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
expect(raw).not.toContain('${CLAUDE_PROJECT_DIR}');
expect(raw).toContain('[ ! -f ');
// The project dir was never touched.
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 20000);
test('does not vendor impeccable into a repo that only tracks OTHER skills', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-vendor-'));
const home = mkdtempSync(join(tmpdir(), 'imp-update-vendor-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
// The repo tracks a first-party, NON-impeccable skill under .claude/skills.
writeSkill(tmp, '.claude', 'house-brand');
// A real user-level impeccable install exists.
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
const output = run('skills update -y', { cwd: tmp, env });
// Targets the user level, not the project's unrelated .claude/skills.
expect(output).toContain('user level');
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
expect(existsSync(join(tmp, '.claude', 'skills', 'house-brand'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 20000);
test('--user with no user-level install reports the resolved user path', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-nouser-'));
const home = mkdtempSync(join(tmpdir(), 'imp-update-nouser-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
// Only a project install exists; --user must not fall through to it.
run('skills install -y --providers=claude --no-hooks', { cwd: tmp, env });
expect(() => run('skills update -y --user', { cwd: tmp, env, stdio: 'pipe' })).toThrow();
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 20000);
});
// ─── Update fallback (remote direct download smoke) ──────────────────────────
describeRemote('skills update: refreshes from the production universal bundle', () => {