Build: ship the launcher instead of bundling the JS engine

readSourceFiles no longer copies cli/engine into the skill; the scripts
payload is the launcher (executable bit preserved through dist, plugin/, and
universal.zip), impeccable.cmd, VERSION (synced from ENGINE_VERSION on every
build), the page JS, and command-metadata.json. Hook manifests call
`<scripts>/impeccable hook` behind an existence guard (Codex adds a
commandWindows sibling calling impeccable.cmd; Cursor runs hook-before-edit;
GitHub keeps the git rev-parse form; Grok mirrors Claude); the Node probe and
systemMessage notice are gone. build:release fetches the pinned engine for
every target (lenient) and stages bin/<os-arch>/ into the dist skill copies
after root harness dirs and plugin/ were synced, so git-delivered trees stay
launcher-only. The detection-rule count check reads the vendored
extension/detector/antipatterns.json and is skipped when absent.
build:browser is a stub; the codex prefix rewrite leaves
`{{scripts_path}}/impeccable` alone.

Prepared with AI assistance (Claude Code).
This commit is contained in:
Paul Bakaus
2026-08-31 19:57:32 -07:00
parent dac3f77d89
commit 11a1ea64a6
13 changed files with 336 additions and 1348 deletions
-2
View File
@@ -65,8 +65,6 @@
"test:skill-behavior": "node scripts/run-tests.mjs skill-behavior",
"test:live-svelte-adapter-deepseek": "node scripts/run-tests.mjs live-svelte-adapter-deepseek",
"smoke:hooks": "node scripts/smoke-provider-hooks.mjs",
"bench:detector": "node scripts/benchmark-detector.mjs",
"bench:detector:browser": "node scripts/benchmark-detector.mjs --browser",
"audit": "bun audit --audit-level=moderate",
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
"postpack": "cp README.repo.md README.md && rm README.repo.md",
-583
View File
@@ -1,583 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createBrowserDetector,
createDetectorProfile,
detectHtml,
detectText,
detectUrl,
summarizeDetectorProfile,
walkDir,
} from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const FIXTURES = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
const BROWSER_FIXTURES = [
'cramped-padding.html',
'quality.html',
'body-text-viewport-edge.html',
];
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};
function parseArgs(argv) {
const args = {
browser: false,
json: false,
out: null,
quick: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--browser') args.browser = true;
else if (arg === '--json') args.json = true;
else if (arg === '--quick') args.quick = true;
else if (arg === '--out') args.out = argv[++i] || null;
else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length);
else if (arg === '--help') {
printUsage();
process.exit(0);
}
}
return args;
}
function printUsage() {
console.log(`Usage: node scripts/benchmark-detector.mjs [options]
Options:
--quick Run a small smoke benchmark subset
--browser Include browser-backed URL benchmarks
--json Print the benchmark report as JSON
--out FILE Write the benchmark report JSON to FILE
--help Show this help message`);
}
function nowMs() {
return typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
}
function roundMs(value) {
return Number(value.toFixed(3));
}
function isHtml(filePath) {
const ext = path.extname(filePath).toLowerCase();
return ext === '.html' || ext === '.htm';
}
function rel(filePath) {
return path.relative(ROOT, filePath);
}
function addEvent(profile, event) {
profile.events.push({
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
ms: Number.isFinite(event.ms) ? event.ms : 0,
findings: Number.isFinite(event.findings) ? event.findings : 0,
});
}
async function measureCase({ name, engine, mode, target, run }) {
const profile = createDetectorProfile();
const started = nowMs();
try {
const result = await run(profile);
const findings = Array.isArray(result)
? result.length
: (Number.isFinite(result?.findings) ? result.findings : 0);
return {
name,
engine,
mode,
target,
status: 'ok',
totalMs: roundMs(nowMs() - started),
findings,
profile: summarizeDetectorProfile(profile),
events: profile.events,
};
} catch (err) {
return {
name,
engine,
mode,
target,
status: 'failed',
totalMs: roundMs(nowMs() - started),
findings: 0,
error: err?.message || String(err),
profile: summarizeDetectorProfile(profile),
events: profile.events,
};
}
}
function skippedCase({ name, engine, mode, target, reason }) {
return {
name,
engine,
mode,
target,
status: 'skipped',
totalMs: 0,
findings: 0,
skipReason: reason,
profile: [],
events: [],
};
}
async function scanDirectory(files, fastMode, profile) {
const findings = [];
for (const file of files) {
if (!fastMode && isHtml(file)) {
findings.push(...await detectHtml(file, { profile }));
} else {
const content = fs.readFileSync(file, 'utf-8');
findings.push(...detectText(content, file, { profile }));
}
}
return findings;
}
function selectQuickFiles(files, predicate, preferredNames) {
const preferred = preferredNames
.map(name => files.find(file => path.basename(file) === name))
.filter(Boolean);
const fallback = files.filter(predicate).slice(0, preferredNames.length || 2);
return preferred.length ? preferred : fallback;
}
async function runFileBenchmarks(args) {
const files = walkDir(FIXTURES).sort();
const htmlFiles = files.filter(isHtml);
const textFiles = files.filter(file => !isHtml(file));
const selectedText = args.quick
? textFiles.slice(0, 2)
: textFiles;
const selectedHtml = args.quick
? selectQuickFiles(htmlFiles, isHtml, ['color.html', 'quality.html'])
: htmlFiles;
const directoryFiles = args.quick
? [...selectedHtml, ...selectedText].sort()
: files;
const cases = [];
for (const file of selectedText) {
cases.push(await measureCase({
name: `detectText:${rel(file)}`,
engine: 'regex',
mode: 'file',
target: rel(file),
run: (profile) => detectText(fs.readFileSync(file, 'utf-8'), file, { profile }),
}));
}
for (const file of selectedHtml) {
cases.push(await measureCase({
name: `detectHtml:${rel(file)}`,
engine: 'static-html',
mode: 'file',
target: rel(file),
run: (profile) => detectHtml(file, { profile }),
}));
}
cases.push(await measureCase({
name: args.quick ? 'directory-default:quick-fixtures' : 'directory-default:all-fixtures',
engine: 'mixed',
mode: 'directory-default',
target: rel(FIXTURES),
run: (profile) => scanDirectory(directoryFiles, false, profile),
}));
cases.push(await measureCase({
name: args.quick ? 'directory-fast:quick-fixtures' : 'directory-fast:all-fixtures',
engine: 'regex',
mode: 'directory-fast',
target: rel(FIXTURES),
run: (profile) => scanDirectory(directoryFiles, true, profile),
}));
return cases;
}
function startFixtureServer() {
const server = http.createServer((req, res) => {
let filePath;
const urlPath = req.url?.split('?')[0] || '/';
if (urlPath.startsWith('/fixtures/')) {
filePath = path.join(ROOT, 'tests', urlPath);
} else if (urlPath === '/js/detect-antipatterns-browser.js') {
filePath = path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js');
} else {
res.writeHead(404).end();
return;
}
try {
const body = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${address.port}`,
});
});
});
}
async function closeServer(server) {
await new Promise(resolve => server.close(resolve));
}
async function runBrowserBenchmarks(args) {
let serverInfo;
try {
serverInfo = await startFixtureServer();
} catch (err) {
return [
skippedCase({
name: 'browser:fixtures',
engine: 'browser',
mode: 'browser',
target: 'localhost',
reason: `localhost fixture server unavailable: ${err?.message || err}`,
}),
];
}
const cases = [];
const browserFiles = args.quick ? ['quality.html'] : BROWSER_FIXTURES;
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
const fresh = await measureCase({
name: `detectUrl:fresh-load:${fileName}`,
engine: 'browser',
mode: 'fresh-load',
target: url,
run: (profile) => detectUrl(url, { profile, waitUntil: 'load', settleMs: 100 }),
});
if (fresh.status === 'failed' && /Could not find Chrome|Failed to launch|executable|spawn|puppeteer/i.test(fresh.error || '')) {
cases.push(skippedCase({
name: `detectUrl:fresh-load:${fileName}`,
engine: 'browser',
mode: 'fresh-load',
target: url,
reason: `Chromium unavailable: ${fresh.error}`,
}));
} else {
cases.push(fresh);
}
}
const visualContrastUrl = `${serverInfo.baseUrl}/fixtures/antipatterns/visual-contrast.html`;
cases.push(await measureCase({
name: 'detectUrl:visual-contrast',
engine: 'browser',
mode: 'visual-contrast',
target: visualContrastUrl,
run: (profile) => detectUrl(visualContrastUrl, {
profile,
waitUntil: 'load',
settleMs: 0,
visualContrast: true,
}),
}));
cases.push(await measureCase({
name: 'detectUrl:warm-load',
engine: 'browser',
mode: 'warm-load',
target: serverInfo.baseUrl,
run: async (profile) => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
const findings = [];
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
findings.push(...await detector.detectUrl(url, { profile }));
}
} finally {
await detector.close();
}
return findings;
},
}));
cases.push(await measureCase({
name: 'detectUrl:warm-networkidle0',
engine: 'browser',
mode: 'warm-networkidle0',
target: serverInfo.baseUrl,
run: async (profile) => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
const findings = [];
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
findings.push(...await detector.detectUrl(url, {
profile,
waitUntil: 'networkidle0',
settleMs: 0,
}));
}
} finally {
await detector.close();
}
return findings;
},
}));
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch (err) {
cases.push(skippedCase({
name: 'browser:pure-vs-overlay',
engine: 'browser',
mode: 'pure-vs-overlay',
target: serverInfo.baseUrl,
reason: `puppeteer unavailable: ${err?.message || err}`,
}));
return cases;
}
cases.push(await measureCase({
name: 'browser:pure-vs-overlay',
engine: 'browser',
mode: 'pure-vs-overlay',
target: serverInfo.baseUrl,
run: async (profile) => {
let browser;
const launchStarted = nowMs();
try {
browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
addEvent(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser-overlay-bench',
target: serverInfo.baseUrl,
ms: nowMs() - launchStarted,
});
} catch (err) {
throw new Error(`Chromium unavailable: ${err?.message || err}`);
}
let findings = [];
try {
const page = await browser.newPage();
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${browserFiles[0]}`;
const browserScript = fs.readFileSync(path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js'), 'utf-8');
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
await new Promise(resolve => setTimeout(resolve, 100));
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const pureStarted = nowMs();
findings = await page.evaluate(() => {
const serialized = window.impeccableDetect({ decorate: false, serialize: true });
return serialized.flatMap(({ findings }) => findings.map(f => ({ id: f.type, snippet: f.detail })));
});
addEvent(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'pure-detect',
target: url,
ms: nowMs() - pureStarted,
findings: findings.length,
});
const overlayStarted = nowMs();
const overlayGroupCount = await page.evaluate(() => window.impeccableScan().length);
addEvent(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'overlay-scan',
target: url,
ms: nowMs() - overlayStarted,
findings: overlayGroupCount,
});
await page.close().catch(() => {});
} finally {
const closeStarted = nowMs();
await browser.close().catch(() => {});
addEvent(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser-overlay-bench',
target: serverInfo.baseUrl,
ms: nowMs() - closeStarted,
});
}
return findings;
},
}));
} finally {
await closeServer(serverInfo.server);
}
return cases.map(testCase => {
if (testCase.engine === 'browser' && testCase.status === 'failed' && /Chromium unavailable|Failed to launch|Could not find Chrome|executable|spawn|puppeteer/i.test(testCase.error || '')) {
return skippedCase({
name: testCase.name,
engine: testCase.engine,
mode: testCase.mode,
target: testCase.target,
reason: testCase.error,
});
}
return testCase;
});
}
function aggregateEvents(cases) {
const profile = createDetectorProfile();
for (const testCase of cases) {
if (Array.isArray(testCase.events)) profile.events.push(...testCase.events);
}
return summarizeDetectorProfile(profile);
}
function makeReport(args, cases) {
const summary = aggregateEvents(cases);
return {
version: 1,
createdAt: new Date().toISOString(),
cwd: ROOT,
quick: args.quick,
browser: args.browser,
cases: cases.map(({ events, ...testCase }) => testCase),
summary,
};
}
function pad(value, width) {
const str = String(value);
if (str.length >= width) return str.slice(0, width);
return str + ' '.repeat(width - str.length);
}
function printRows(rows, columns) {
const header = columns.map(col => pad(col.label, col.width)).join(' ');
console.log(header);
console.log(columns.map(col => '-'.repeat(col.width)).join(' '));
for (const row of rows) {
console.log(columns.map(col => pad(row[col.key] ?? '', col.width)).join(' '));
}
}
function printConsoleReport(report) {
console.log(`Detector benchmark ${report.quick ? '(quick)' : '(full)'}`);
console.log(`Cases: ${report.cases.length}`);
const caseRows = report.cases.map(testCase => ({
status: testCase.status,
engine: testCase.engine,
mode: testCase.mode,
totalMs: testCase.totalMs,
findings: testCase.findings,
target: testCase.target,
}));
printRows(caseRows, [
{ key: 'status', label: 'Status', width: 8 },
{ key: 'engine', label: 'Engine', width: 12 },
{ key: 'mode', label: 'Mode', width: 20 },
{ key: 'totalMs', label: 'Total ms', width: 10 },
{ key: 'findings', label: 'Findings', width: 8 },
{ key: 'target', label: 'Target', width: 60 },
]);
const skipped = report.cases.filter(testCase => testCase.status === 'skipped');
for (const testCase of skipped) {
console.log(`Skipped ${testCase.name}: ${testCase.skipReason}`);
}
console.log('\nSlowest profile groups');
const slowRows = report.summary.slice(0, 20).map(item => ({
engine: item.engine,
phase: item.phase,
ruleId: item.ruleId,
calls: item.calls,
totalMs: item.totalMs,
avgMs: item.avgMs,
p95: item.p95,
findings: item.findings,
target: item.target,
}));
printRows(slowRows, [
{ key: 'engine', label: 'Engine', width: 12 },
{ key: 'phase', label: 'Phase', width: 14 },
{ key: 'ruleId', label: 'Rule', width: 28 },
{ key: 'calls', label: 'Calls', width: 8 },
{ key: 'totalMs', label: 'Total ms', width: 10 },
{ key: 'avgMs', label: 'Avg ms', width: 8 },
{ key: 'p95', label: 'P95', width: 8 },
{ key: 'findings', label: 'Finds', width: 7 },
{ key: 'target', label: 'Target', width: 45 },
]);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const cases = [
...await runFileBenchmarks(args),
];
if (args.browser) {
cases.push(...await runBrowserBenchmarks(args));
}
const report = makeReport(args, cases);
const json = JSON.stringify(report, null, 2);
if (args.out) {
fs.writeFileSync(path.resolve(args.out), json + '\n');
}
if (args.json) {
process.stdout.write(json + '\n');
} else {
printConsoleReport(report);
if (args.out) console.log(`\nWrote JSON report to ${path.resolve(args.out)}`);
}
if (report.cases.some(testCase => testCase.status === 'failed')) {
process.exitCode = 1;
}
}
main().catch(err => {
console.error(err?.stack || err?.message || err);
process.exit(1);
});
+7 -44
View File
@@ -1,49 +1,12 @@
#!/usr/bin/env node
/**
* Generates cli/engine/detect-antipatterns-browser.js
* by concatenating the browser-safe detector modules and wrapping them in an IIFE.
* The in-page detector bundle is no longer built here. It is produced by the
* engine repo (the WASM rule core plus a thin DOM adapter) and vendored under
* extension/detector/ by `bun run build:extension`.
*
* Run: node scripts/build-browser-detector.js
* This stub keeps `bun run build:browser` a no-op so package scripts and CI
* steps that still call it do not break.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { bundleBrowserDetectorModules } from './lib/browser-detector-bundle.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js');
const SITE_OUTPUT = path.join(ROOT, 'site/public/js/detect-antipatterns-browser.js');
const code = bundleBrowserDetectorModules(ROOT);
const output = `/**
* Anti-Pattern Browser Detector for Impeccable
* Copyright (c) 2026 Paul Bakaus
* SPDX-License-Identifier: Apache-2.0
*
* GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs
* Rebuild: node scripts/build-browser-detector.js
*
* Usage: <script src="detect-antipatterns-browser.js"></script>
* Re-scan: window.impeccableScan()
*/
(function () {
if (typeof window === 'undefined') return;
${code}
})();
`;
fs.writeFileSync(OUTPUT, output);
console.log(`Generated ${path.relative(ROOT, OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
// The site consumes this bundle from its own repo. Only mirror it when that
// checkout is present, so a build here never recreates a stray `site/` tree.
if (fs.existsSync(path.dirname(path.dirname(SITE_OUTPUT)))) {
fs.mkdirSync(path.dirname(SITE_OUTPUT), { recursive: true });
fs.writeFileSync(SITE_OUTPUT, output);
console.log(`Generated ${path.relative(ROOT, SITE_OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
}
console.log('build:browser: the in-page bundle is produced by the engine repo; vendored under extension/detector by build:extension.');
process.exit(0);
+88 -12
View File
@@ -32,7 +32,7 @@ import {
verifyPluginAgentRewrite,
} from './lib/plugin-paths.js';
import { stageOpenAIPlugin } from './lib/openai-plugin.js';
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
import { ENGINE_TARGETS, binaryName, main as fetchEngineMain, readEngineVersion } from './fetch-engine.mjs';
// Sub-page generation is now handled by Astro content collections.
/**
@@ -60,8 +60,12 @@ function generateCounts(rootDir, skills, buildDir) {
commandCount = activeCommands.length;
}
// Count detection rules from the detector registry.
const detectionCount = new Set(ANTIPATTERNS.map(rule => rule.id)).size;
// Count detection rules from the engine's rule registry as vendored by
// build:extension (extension/detector/antipatterns.json). The registry
// lives in the engine repo now, so when that file is absent (fresh
// checkout, no extension build) the detection-count check is skipped
// rather than guessed.
const detectionCount = readDetectionRuleCount(rootDir);
// Validate counts in key files
const filesToCheck = [
@@ -99,7 +103,7 @@ function generateCounts(rootDir, skills, buildDir) {
// qualified "issues" both evaded the old pattern, which is how five
// stale counts shipped while the validator reported clean.
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(detector\s+)?(checks|patterns|rules|detections|issues)\b/gi;
for (const match of strippedContent.matchAll(detectPattern)) {
for (const match of detectionCount == null ? [] : strippedContent.matchAll(detectPattern)) {
const num = parseInt(match[1]);
if (match[4] === 'issues' && !match[2]) continue; // plain "issues" is prose, not a count claim
if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
@@ -113,10 +117,21 @@ function generateCounts(rootDir, skills, buildDir) {
console.error(`\n${errors} stale count reference(s) found. Update them to match source of truth.`);
}
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount == null ? 'detection rules unchecked (no extension/detector/antipatterns.json)' : `${detectionCount} detection rules`}`);
return errors;
}
function readDetectionRuleCount(rootDir) {
const registry = path.join(rootDir, 'extension', 'detector', 'antipatterns.json');
if (!fs.existsSync(registry)) return null;
try {
const rules = JSON.parse(fs.readFileSync(registry, 'utf-8'));
return new Set((Array.isArray(rules) ? rules : []).map(rule => rule.id)).size || null;
} catch {
return null;
}
}
/**
* Guard against plugin/skill version drift (issue #274). The pure comparison
* lives in ./lib/validate-plugin-versions.js (so it's unit-tested directly);
@@ -496,6 +511,54 @@ function syncRootHookManifests(rootDir) {
return synced;
}
/**
* Every skill copy in dist gets the engine binaries the launcher looks for
* (`scripts/bin/<os>-<arch>/impeccable[.exe]`), so the release zips are
* self-contained for installs without egress. Called only in release mode,
* and only after the root harness dirs and ./plugin have been synced from
* dist: those are git-delivered and must stay launcher-only (the binaries
* are gitignored and the launcher downloads them on first run).
*
* Source: skill/scripts/bin/, filled by scripts/fetch-engine.mjs --all. A
* target that could not be fetched is reported and left out; the launcher
* covers it at run time.
*/
async function stageEngineBinaries(rootDir, distDir) {
await fetchEngineMain(['--all', '--lenient']);
const binRoot = path.join(rootDir, 'skill', 'scripts', 'bin');
const present = ENGINE_TARGETS.filter(t => fs.existsSync(path.join(binRoot, t, binaryName(t))));
const missing = ENGINE_TARGETS.filter(t => !present.includes(t));
if (present.length === 0) {
console.warn(`⚠️ No engine binaries for v${readEngineVersion(rootDir)}; zips ship launcher-only (the launcher downloads on first run).`);
return;
}
let copies = 0;
for (const { provider, configDir } of Object.values(PROVIDERS)) {
const scriptsDir = path.join(distDir, provider, configDir, 'skills', 'impeccable', 'scripts');
if (!fs.existsSync(scriptsDir)) continue;
for (const target of present) {
const src = path.join(binRoot, target, binaryName(target));
const dest = path.join(scriptsDir, 'bin', target, binaryName(target));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
fs.chmodSync(dest, 0o755);
copies++;
}
}
console.log(`✓ Staged engine v${readEngineVersion(rootDir)} binaries into dist (${present.join(', ')}; ${copies} copies)${missing.length ? `; missing: ${missing.join(', ')}` : ''}`);
}
function syncEngineVersionFile(rootDir) {
const version = readEngineVersion(rootDir);
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`ENGINE_VERSION must be a semver string, got "${version}"`);
}
const dest = path.join(rootDir, 'skill', 'scripts', 'VERSION');
const current = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf-8') : null;
if (current !== `${version}\n`) fs.writeFileSync(dest, `${version}\n`);
console.log(`✓ Engine pinned at v${version} (skill/scripts/VERSION)`);
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
@@ -590,6 +653,10 @@ async function build() {
const buildDir = path.join(ROOT_DIR, 'build');
// The launcher reads scripts/VERSION to know which engine release to run
// or download; the root ENGINE_VERSION file is the source of truth for it.
syncEngineVersionFile(ROOT_DIR);
// Read source files (unified skills architecture)
const { skills } = readSourceFiles(ROOT_DIR);
const patterns = readPatterns(ROOT_DIR);
@@ -611,13 +678,6 @@ async function build() {
transform(skills, DIST_DIR, { skillsVersion });
}
// Assemble universal directory
assembleUniversal(DIST_DIR);
// Create ZIP bundles (individual + universal)
await createAllZips(DIST_DIR);
if (BUILD_OPTIONS.syncRootOutputs) {
// Copy all provider outputs to project root for direct GitHub installs and
// submodule users. `.codex/` is intentionally excluded: Codex no longer
@@ -794,6 +854,22 @@ async function build() {
const openAiPluginRoot = stageOpenAIPlugin(ROOT_DIR, DIST_DIR);
await createProviderZip(openAiPluginRoot, DIST_DIR, 'openai-plugin');
// Release builds ship the engine binaries inside every dist skill copy so
// universal.zip is self-contained. This runs after the root harness dirs,
// ./plugin, and the OpenAI plugin were staged from dist: git-delivered
// trees stay launcher-only.
// IMPECCABLE_BUNDLE_ENGINE=0 keeps the release zip launcher-only (the size
// grows by roughly 4 MB per target per provider copy otherwise).
if (BUILD_OPTIONS.syncRootOutputs && process.env.IMPECCABLE_BUNDLE_ENGINE !== '0') {
await stageEngineBinaries(ROOT_DIR, DIST_DIR);
}
// Assemble universal directory
assembleUniversal(DIST_DIR);
// Create ZIP bundles (universal)
await createAllZips(DIST_DIR);
// Generate authoritative counts and validate references
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
-30
View File
@@ -1,30 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const BROWSER_DETECTOR_MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/shared/fonts.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
function browserSafeModule(root, relPath) {
let code = fs.readFileSync(path.join(root, relPath), 'utf-8');
if (relPath === 'cli/engine/registry/antipatterns.mjs') {
const match = code.match(/const ANTIPATTERNS = \[[\s\S]*?\n\];/);
if (!match) throw new Error('Could not extract browser antipattern registry');
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[^}]*\};\n?/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
export function bundleBrowserDetectorModules(root) {
return BROWSER_DETECTOR_MODULES
.map((relPath) => browserSafeModule(root, relPath))
.join('\n');
}
+7 -1
View File
@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';
import {
cleanDir,
@@ -359,7 +360,12 @@ export function createTransformer(config) {
ensureDir(scriptsOutDir);
for (const script of skill.scripts) {
const scriptContent = replaceScriptProviderMarker(script.content, placeholderKey, provider);
writeFile(path.join(scriptsOutDir, script.name), scriptContent);
const outPath = path.join(scriptsOutDir, script.name);
writeFile(outPath, scriptContent);
// The launcher must stay executable in every provider copy; a
// plain write would drop the bit and `impeccable context` would
// fail with EACCES on the first session.
if (script.mode) fs.chmodSync(outPath, script.mode);
scriptCount++;
}
}
+60 -98
View File
@@ -22,7 +22,7 @@
* correct wherever Claude Code unpacks the plugin.
*/
export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/hook.mjs';
export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/impeccable';
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
@@ -34,12 +34,37 @@ const STATUS_MESSAGE = 'Checking UI changes';
const STOP_TIMEOUT_SECONDS = 30;
const STOP_STATUS_MESSAGE = 'Design deep pass';
function stopEntry(command) {
// The hook is a verb of the impeccable launcher that ships in the skill's
// scripts dir: `<scripts>/impeccable hook` (per-edit and Stop passes) and
// `<scripts>/impeccable hook-before-edit` (Cursor's preToolUse). The launcher
// runs the platform binary next to it, or downloads it once; no runtime probe
// is needed and there is no Node on the path to check.
export const LAUNCHER_NAME = 'impeccable';
export const LAUNCHER_NAME_WINDOWS = 'impeccable.cmd';
// A hook manifest can be copied into a user-level settings file (issue #399:
// user-level hooks fire in every project, where a project-relative path may
// not exist). Guard the invocation so a missing launcher exits 0 without
// swallowing the hook's real exit code when it is present: the `[ ! -f X ] ||
// X verb` form (not `... || true`) preserves the launcher's exit code, so
// Claude's exit-2 blocking signal still reaches the agent.
export const guardedLauncher = (launcherPath, verb = 'hook') =>
`[ ! -f "${launcherPath}" ] || "${launcherPath}" ${verb}`;
// cmd.exe form for harnesses that read a `commandWindows` sibling (Codex
// 0.146.0+ selects it on Windows; issue #452). `exit /b` forwards the
// launcher's errorlevel. Paths keep forward slashes; cmd.exe accepts them in
// quoted paths and it is the form the CLI already writes.
export const windowsLauncherCommand = (launcherCmdPath, verb = 'hook') =>
`if exist "${launcherCmdPath}" ("${launcherCmdPath}" ${verb} & exit /b)`;
function stopEntry(command, commandWindows) {
return {
hooks: [
{
type: 'command',
command,
...(commandWindows ? { commandWindows } : {}),
timeout: STOP_TIMEOUT_SECONDS,
statusMessage: STOP_STATUS_MESSAGE,
},
@@ -47,50 +72,31 @@ function stopEntry(command) {
};
}
const CLAUDE_PROJECT_HOOK = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs';
// The Node major the hook runtime requires, kept equal to the engines floor in
// package.json. The probe and the notice both derive from it so they cannot
// disagree about the supported version.
const NODE_MAJOR_FLOOR = 22;
// A hook manifest can be copied into a user-level settings file (issue #399:
// user-level hooks fire in every project, where a project-relative path may
// not exist). Guard node invocations so a missing file exits 0 without
// swallowing node's real exit code when the file is present.
//
// The runtime is guarded too (issue #410): a `node` on PATH too old for the
// hook's ESM syntax dies while hook.mjs is still being parsed, before the
// script's own always-exit-0 contract can run, so the harness reported a hook
// error on every edit and every Stop. Nothing written in ESM can report that
// condition, so the command string itself checks the version floor first, in
// ES5-only syntax that parses on any node old enough to fail it, and exits 0
// when the runtime is unsupported or missing.
//
// `notice` reports the dead runtime to the user. It is passed per harness
// because only some have a channel for it, checked against each harness's own
// hook reference on the events we hook:
// Claude Code / Codex: `systemMessage` on stdout is shown to the user -> notice
// Cursor: preToolUse output is permission-shaped and its `user_message`
// renders only on DENY, so warning would block the edit -> probe only
// Grok Build: PostToolUse stdout is ignored; Stop additionalContext
// reaches the model, but the node-version notice has no systemMessage
// channel on this harness -> probe only
// Copilot: output contract unconfirmed; do not guess a shape -> probe only
//
// The clamp avoids `<` and `>` deliberately: Volta's Windows shims run through
// `cmd /C`, which reads an angle bracket in the `-e` payload as redirection, so
// `>=` failed before node ran at all and the guard reported a missing runtime on
// a machine that had a supported one (volta-cli/volta#1791). Newlines break the
// same way, so this payload also has to stay on one line.
const NODE_PROBE = `node -e "process.exit(Math.min(parseInt(process.versions.node,10),${NODE_MAJOR_FLOOR})===${NODE_MAJOR_FLOOR}?0:1)" 2>/dev/null`;
const guardedNode = (hookPath, notice = '') => {
const probe = notice
? `! { ${NODE_PROBE} || { ${notice}; exit 0; }; }`
: `! ${NODE_PROBE}`;
return `[ ! -f "${hookPath}" ] || ${probe} || node "${hookPath}"`;
};
const launcherIn = (scriptsDir) => `${scriptsDir}/${LAUNCHER_NAME}`;
const launcherCmdIn = (scriptsDir) => `${scriptsDir}/${LAUNCHER_NAME_WINDOWS}`;
function buildClaudeCompatibleHooks(matcher, hookPath, notice = '') {
const command = guardedNode(hookPath, notice);
const CLAUDE_PROJECT_SCRIPTS = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts';
const CLAUDE_PLUGIN_SCRIPTS = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts';
const CODEX_PLUGIN_SCRIPTS = '${PLUGIN_ROOT}/skills/impeccable/scripts';
// Codex reads project hooks from `.codex/hooks.json`, but the skill payload the
// hook invokes lives under the install's own skills dir: a `.codex`-directory
// install keeps it at `.codex/skills/...`, while a `.agents` (Codex repo-skills)
// install keeps it at `.agents/skills/...`. Derive the path from the install dir
// so each generated manifest points at its own payload rather than a hardcoded
// `.agents`; otherwise the guarded hook silently no-ops on `.codex` installs.
const codexProjectScripts = (skillDir) => `${skillDir}/skills/impeccable/scripts`;
const CURSOR_SCRIPTS = '.cursor/skills/impeccable/scripts';
const GITHUB_PROJECT_SCRIPTS = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts';
// Grok project hooks are relative to the git/workspace root. Claude tool names
// in the matcher (Edit|Write|MultiEdit) alias to Grok's search_replace family.
const GROK_PROJECT_SCRIPTS = '.grok/skills/impeccable/scripts';
// `windows: true` adds the `commandWindows` sibling; only Codex-shaped
// consumers honor it, and an unknown key would fail Codex's strict parser if
// it were the other way round, so it stays opt-in per manifest.
function buildClaudeCompatibleHooks(matcher, scriptsDir, { windows = false } = {}) {
const command = guardedLauncher(launcherIn(scriptsDir));
const commandWindows = windows ? windowsLauncherCommand(launcherCmdIn(scriptsDir)) : undefined;
return {
PostToolUse: [
{
@@ -99,52 +105,21 @@ function buildClaudeCompatibleHooks(matcher, hookPath, notice = '') {
{
type: 'command',
command,
...(commandWindows ? { commandWindows } : {}),
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
Stop: [stopEntry(command)],
Stop: [stopEntry(command, commandWindows)],
};
}
// The message says `on PATH` deliberately: the common cause is a hook shell
// whose PATH misses the version manager, so a user already running Node 22
// needs to know the hook's PATH is at issue and not their install. Apostrophes
// cannot appear in it, since it travels inside a single-quoted shell string.
const NODE_NOTICE_TEXT = `The impeccable design hook is not running: no Node ${NODE_MAJOR_FLOOR} or newer on PATH. `
+ 'Install one, or remove the impeccable hook from your harness settings.';
// Claude Code and Codex both read `systemMessage`, so one payload serves both.
// The marker under ~/.impeccable holds it to one notice per machine (not per
// harness or per edit), and printf runs only after the marker write succeeds,
// so an unwritable HOME degrades to silence rather than a notice on every edit.
const SYSTEM_MESSAGE_NOTICE = 'D="$HOME/.impeccable"; [ -f "$D/node-unsupported" ] || '
+ '{ mkdir -p "$D" 2>/dev/null && : > "$D/node-unsupported" 2>/dev/null && '
+ `printf '%s' '{"systemMessage":"${NODE_NOTICE_TEXT}"}'; }`;
const CLAUDE_PLUGIN_HOOK = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
const CODEX_PLUGIN_HOOK = '${PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
// Codex reads project hooks from `.codex/hooks.json`, but the skill payload the
// hook invokes lives under the install's own skills dir: a `.codex`-directory
// install keeps it at `.codex/skills/...`, while a `.agents` (Codex repo-skills)
// install keeps it at `.agents/skills/...`. Derive the path from the install dir
// so each generated manifest points at its own payload rather than a hardcoded
// `.agents` — otherwise the guarded hook silently no-ops on `.codex` installs.
const codexProjectHook = (skillDir) => `${skillDir}/skills/impeccable/scripts/hook.mjs`;
const CURSOR_BEFORE_EDIT_SCRIPT = '.cursor/skills/impeccable/scripts/hook-before-edit.mjs';
const GITHUB_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs';
// Grok project hooks are relative to the git/workspace root. Claude tool names
// in the matcher (Edit|Write|MultiEdit) alias to Grok's search_replace family.
const GROK_PROJECT_HOOK = '.grok/skills/impeccable/scripts/hook.mjs';
export function buildClaudeSettingsManifest() {
return {
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
CLAUDE_PROJECT_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PROJECT_SCRIPTS),
};
}
@@ -156,11 +131,7 @@ export function buildClaudeSettingsManifest() {
// than `hooks`, failing the whole manifest (issue #330).
export function buildClaudePluginHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
CLAUDE_PLUGIN_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PLUGIN_SCRIPTS),
};
}
@@ -169,11 +140,7 @@ export function buildClaudePluginHooksManifest() {
// instead of relying on its Claude compatibility alias.
export function buildCodexPluginHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write|apply_patch',
CODEX_PLUGIN_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write|apply_patch', CODEX_PLUGIN_SCRIPTS, { windows: true }),
};
}
@@ -181,13 +148,8 @@ export function buildCodexPluginHooksManifest() {
// emitted command points at that install's payload. Defaults to `.codex` for the
// Codex provider, whose self-consistent bundle keeps the skill at `.codex/skills`.
export function buildCodexHooksManifest(skillDir = '.codex') {
const hookPath = codexProjectHook(skillDir);
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write|apply_patch',
hookPath,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write|apply_patch', codexProjectScripts(skillDir), { windows: true }),
};
}
@@ -197,7 +159,7 @@ export function buildCursorHooksManifest() {
hooks: {
preToolUse: [
{
command: guardedNode(CURSOR_BEFORE_EDIT_SCRIPT),
command: guardedLauncher(launcherIn(CURSOR_SCRIPTS), 'hook-before-edit'),
timeout: TIMEOUT_SECONDS,
},
],
@@ -224,7 +186,7 @@ export function buildGitHubHooksManifest() {
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: guardedNode(GITHUB_PROJECT_HOOK),
bash: guardedLauncher(launcherIn(GITHUB_PROJECT_SCRIPTS)),
timeoutSec: TIMEOUT_SECONDS,
},
],
@@ -239,7 +201,7 @@ export function buildGitHubHooksManifest() {
// https://docs.x.ai/build/features/hooks
export function buildGrokHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks('Edit|Write|MultiEdit', GROK_PROJECT_HOOK),
hooks: buildClaudeCompatibleHooks('Edit|Write|MultiEdit', GROK_PROJECT_SCRIPTS),
};
}
+20 -61
View File
@@ -9,18 +9,11 @@ import path from 'path';
// New installs write project config at .impeccable/live/config.json instead.
export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
const DETECTOR_BUNDLE_DIR = 'cli/engine';
// Detector source files that live OUTSIDE `cli/engine` but are imported by the
// bundled engine. `cli/engine/cli/main.mjs` imports `../../lib/impeccable-config.mjs`,
// which in the source CLI resolves to `cli/lib/impeccable-config.mjs`. The detector
// bundle copies `cli/engine/**` to `scripts/detector/**`, so from the bundled
// `scripts/detector/cli/main.mjs` that same `../../lib/...` import resolves to
// `scripts/lib/impeccable-config.mjs`. Copy the dependency there or the bundled
// detector fails at import time with "Cannot find module .../lib/impeccable-config.mjs".
const DETECTOR_EXTERNAL_DEPS = [
{ src: 'cli/lib/impeccable-config.mjs', dest: 'lib/impeccable-config.mjs' },
];
// Platform binaries under `scripts/bin/<os>-<arch>/` are fetched per machine
// (scripts/fetch-engine.mjs) and never part of the source skill read: the
// release build stages them into provider output separately, and the launcher
// downloads them on first run when they are absent.
export const SKILL_BINARY_DIR = 'bin';
// Walk the harness-dir skill tree and return any per-project script
// artifacts found, ready for restoration after a full sync rm+recopy.
@@ -52,46 +45,6 @@ export function restorePerProjectArtifacts(rootDir, stashed) {
}
}
function readDetectorBundleScripts(rootDir) {
const detectorDir = path.join(rootDir, DETECTOR_BUNDLE_DIR);
if (!fs.existsSync(detectorDir)) return [];
const scripts = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (!entry.isFile()) continue;
const relPath = path.relative(detectorDir, entryPath).split(path.sep).join('/');
scripts.push({
name: `detector/${relPath}`,
content: fs.readFileSync(entryPath, 'utf-8'),
filePath: entryPath,
generated: true,
});
}
};
walk(detectorDir);
// Pull in engine dependencies that live outside the bundle dir so the
// generated detector is self-contained (see DETECTOR_EXTERNAL_DEPS).
for (const { src, dest } of DETECTOR_EXTERNAL_DEPS) {
const srcPath = path.join(rootDir, src);
if (!fs.existsSync(srcPath)) continue;
scripts.push({
name: dest,
content: fs.readFileSync(srcPath, 'utf-8'),
filePath: srcPath,
generated: true,
});
}
return scripts;
}
function readSkillScripts(scriptsDir) {
const scripts = [];
@@ -102,6 +55,7 @@ function readSkillScripts(scriptsDir) {
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (dir === scriptsDir && entry.name === SKILL_BINARY_DIR) continue;
walk(entryPath);
continue;
}
@@ -109,10 +63,13 @@ function readSkillScripts(scriptsDir) {
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) continue;
const relPath = path.relative(scriptsDir, entryPath).split(path.sep).join('/');
// `mode` travels with the entry so the launcher keeps its executable
// bit in every provider copy (see writeScriptFile in the transformer).
scripts.push({
name: relPath,
content: fs.readFileSync(entryPath, 'utf-8'),
filePath: entryPath,
mode: fs.statSync(entryPath).mode & 0o777,
});
}
};
@@ -242,8 +199,8 @@ export function readFilesRecursive(dir, fileList = []) {
* The source manifest is `SKILL.src.md`, NOT `SKILL.md`, on purpose: the
* `vercel-labs/skills` CLI discovers a skill by finding a literal `SKILL.md`
* and copies that directory verbatim. If `skill/SKILL.md` existed, `npx skills`
* would install the UNCOMPILED source (unresolved `{{placeholders}}`, no vendored
* detector). Naming it `SKILL.src.md` hides it from discovery so the CLI falls
* would install the UNCOMPILED source (unresolved `{{placeholders}}` and
* `{{scripts_path}}`). Naming it `SKILL.src.md` hides it from discovery so the CLI falls
* through to a compiled harness dir (`.agents/skills/impeccable`) instead.
*/
export function readSourceFiles(rootDir) {
@@ -280,7 +237,6 @@ export function readSourceFiles(rootDir) {
if (fs.existsSync(scriptsDir)) {
scripts.push(...readSkillScripts(scriptsDir));
}
scripts.push(...readDetectorBundleScripts(rootDir));
const agents = [];
const agentsDir = path.join(skillDir, 'agents');
@@ -676,13 +632,15 @@ export function replacePlaceholders(content, provider, commandNames = [], allSki
// Replace `/skillname` invocations with the correct command prefix for this provider
// (e.g., `/normalize` → `$normalize` for Codex). Require the slash to be
// outside a path or URL so `.github/hooks/impeccable.json` and
// `.codex/skills/impeccable` remain untouched.
// outside a path or URL so `.github/hooks/impeccable.json`,
// `.codex/skills/impeccable`, and the launcher invocation
// `{{scripts_path}}/impeccable <verb>` (a `}}`-preceded path segment,
// resolved after this pass) remain untouched.
if (cmdPrefix !== '/' && allSkillNames.length > 0) {
const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
for (const name of sorted) {
result = result.replace(
new RegExp(`(?<![a-zA-Z0-9_./-])\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
new RegExp(`(?<![a-zA-Z0-9_./}>-])\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
cmdPrefix
);
}
@@ -695,9 +653,10 @@ export function replacePlaceholders(content, provider, commandNames = [], allSki
* Render the one explicit provider marker allowed in executable skill scripts.
*
* Do not run replacePlaceholders() across JavaScript source: slash-command
* heuristics can collide with regex literals and runtime paths. Scripts import
* their command prefix from lib/provider.mjs, whose declaration is replaced
* here by an exact string match.
* heuristics can collide with regex literals and runtime paths. Only exact
* marker lines are replaced. The shipped scripts today (the launcher and the
* page JS) carry no marker; the binary derives its provider from its install
* path or IMPECCABLE_PROVIDER_ID at run time.
*/
export function replaceScriptProviderMarker(content, provider, buildProvider = provider) {
const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS.cursor;
+23 -28
View File
@@ -48,24 +48,24 @@ const providerSmoke = {
fixture: 'src/__impeccable_provider_smoke_claude.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_claude.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_claude.html',
admin: '.claude/skills/impeccable/scripts/hook-admin.mjs',
hook: '.claude/skills/impeccable/scripts/hook.mjs',
launcher: '.claude/skills/impeccable/scripts/impeccable',
hookVerb: 'hook',
event: (file) => postToolUseEvent('confirmed-claude', file, 'Edit'),
},
codex: {
fixture: 'src/__impeccable_provider_smoke_codex.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_codex.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_codex.html',
admin: '.agents/skills/impeccable/scripts/hook-admin.mjs',
hook: '.agents/skills/impeccable/scripts/hook.mjs',
launcher: '.agents/skills/impeccable/scripts/impeccable',
hookVerb: 'hook',
event: (file) => postToolUseEvent('confirmed-codex', file, 'apply_patch'),
},
cursor: {
fixture: 'src/__impeccable_provider_smoke_cursor.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_cursor.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_cursor.html',
admin: '.cursor/skills/impeccable/scripts/hook-admin.mjs',
hook: '.cursor/skills/impeccable/scripts/hook-before-edit.mjs',
launcher: '.cursor/skills/impeccable/scripts/impeccable',
hookVerb: 'hook-before-edit',
event: (file) => ({
hook_event_name: 'preToolUse',
cwd: targetRepo,
@@ -393,7 +393,7 @@ function stripImpeccableHookEntry(entry) {
}
function containsImpeccableHook(value) {
if (typeof value === 'string') return value.includes('skills/impeccable/scripts/hook') || value.includes('.cursor/pre-log.mjs');
if (typeof value === 'string') return value.includes('skills/impeccable/scripts/impeccable') || value.includes('.cursor/pre-log.mjs');
if (Array.isArray(value)) return value.some(containsImpeccableHook);
if (value && typeof value === 'object') return Object.values(value).some(containsImpeccableHook);
return false;
@@ -403,24 +403,19 @@ function verifyInstallShape() {
const claude = readText('.claude/settings.local.json');
const codex = readText('.codex/hooks.json');
const cursor = readText('.cursor/hooks.json');
assertCount(claude, '.claude/skills/impeccable/scripts/hook.mjs', 1, 'Claude hook.mjs');
assertCount(codex, '.agents/skills/impeccable/scripts/hook.mjs', 1, 'Codex hook.mjs');
assertCount(cursor, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs', 1, 'Cursor preToolUse');
assertCount(cursor, '.cursor/skills/impeccable/scripts/hook-after-edit.mjs', 0, 'Cursor afterFileEdit');
assertCount(cursor, '.cursor/skills/impeccable/scripts/hook-stop.mjs', 0, 'Cursor stop');
assertCount(claude, '.claude/skills/impeccable/scripts/impeccable" hook', 1, 'Claude hook');
assertCount(codex, '.agents/skills/impeccable/scripts/impeccable" hook', 1, 'Codex hook');
assertCount(cursor, '.cursor/skills/impeccable/scripts/impeccable" hook-before-edit', 1, 'Cursor preToolUse');
for (const text of [claude, codex, cursor]) {
if (text.includes('hook-probe.mjs')) throw new Error('hook-probe.mjs still appears in hook manifests');
if (text.includes('.mjs')) throw new Error('a Node hook script still appears in hook manifests');
}
for (const rel of [
'.claude/skills/impeccable/scripts/hook.mjs',
'.claude/skills/impeccable/scripts/hook-lib.mjs',
'.claude/skills/impeccable/scripts/detector/cli/main.mjs',
'.agents/skills/impeccable/scripts/hook.mjs',
'.agents/skills/impeccable/scripts/hook-lib.mjs',
'.agents/skills/impeccable/scripts/detector/cli/main.mjs',
'.cursor/skills/impeccable/scripts/hook-before-edit.mjs',
'.cursor/skills/impeccable/scripts/hook-lib.mjs',
'.cursor/skills/impeccable/scripts/detector/cli/main.mjs',
'.claude/skills/impeccable/scripts/impeccable',
'.claude/skills/impeccable/scripts/VERSION',
'.agents/skills/impeccable/scripts/impeccable',
'.agents/skills/impeccable/scripts/VERSION',
'.cursor/skills/impeccable/scripts/impeccable',
'.cursor/skills/impeccable/scripts/VERSION',
]) {
assertPath(join(targetRepo, rel), rel);
}
@@ -484,7 +479,7 @@ function runDirectContractChecks() {
clearRuntimeState();
const file = writeBadFixture(directSmokeFile);
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'direct.ndjson') };
const claude = run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
const claude = run('.claude/skills/impeccable/scripts/impeccable', ['hook'], {
cwd: targetRepo,
env,
logName: 'direct-claude.log',
@@ -493,7 +488,7 @@ function runDirectContractChecks() {
requireFinding('direct Claude hook', `${claude.stdout}\n${readMaybe(join(smokeDir, 'direct.ndjson'))}`);
clearRuntimeState();
const codex = run('node', ['.agents/skills/impeccable/scripts/hook.mjs'], {
const codex = run('.agents/skills/impeccable/scripts/impeccable', ['hook'], {
cwd: targetRepo,
env,
logName: 'direct-codex.log',
@@ -502,7 +497,7 @@ function runDirectContractChecks() {
requireFinding('direct Codex hook', `${codex.stdout}\n${readMaybe(join(smokeDir, 'direct.ndjson'))}`);
clearRuntimeState();
const pre = run('node', ['.cursor/skills/impeccable/scripts/hook-before-edit.mjs'], {
const pre = run('.cursor/skills/impeccable/scripts/impeccable', ['hook-before-edit'], {
cwd: targetRepo,
env,
logName: 'direct-cursor-before.log',
@@ -543,8 +538,8 @@ function runConfirmedExceptionForProvider(provider) {
requireRuleFinding(`${provider} confirmed exception first hook`, `${first.stdout}\n${first.stderr}\n${readMaybe(join(smokeDir, beforeLog))}`, 'overused-font');
assertNoSpecificFontIgnoreConfig(provider);
run('node', [
providerSmoke[provider].admin,
run(providerSmoke[provider].launcher, [
'hooks',
'ignore-value',
'overused-font',
'Roboto',
@@ -745,7 +740,7 @@ function readSharedHookConfig() {
function runInstalledProviderHook(provider, file, logName) {
const smoke = providerSmoke[provider];
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
return run('node', [smoke.hook], {
return run(smoke.launcher, [smoke.hookVerb], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
+28 -55
View File
@@ -374,65 +374,38 @@ Please audit {{target}} for technical quality. Ask {{model}} for help.`;
});
});
// Resolve a relative import specifier against the importer's bundle-relative
// path, mirroring Node ESM resolution against the set of bundled script names.
// Returns the matching bundled name, or null if nothing resolves.
function resolveBundledImport(importerName, specifier, names) {
const dirParts = importerName.split('/').slice(0, -1);
const parts = dirParts.concat(specifier.split('/'));
const resolved = [];
for (const part of parts) {
if (part === '' || part === '.') continue;
if (part === '..') { resolved.pop(); continue; }
resolved.push(part);
}
const base = resolved.join('/');
// ESM needs an explicit extension, but be tolerant of extensionless and
// index specifiers so the check tracks real module-resolution behavior.
const candidates = [base, `${base}.mjs`, `${base}.js`, `${base}/index.mjs`, `${base}/index.js`];
return candidates.find((c) => names.has(c)) || null;
}
// Regression guard for issue #254: the bundled detector imported
// `../../lib/impeccable-config.mjs`, a file that lives outside `cli/engine` and
// was never copied into the bundle, so `/impeccable critique` crashed with
// "Cannot find module .../lib/impeccable-config.mjs". This walks every bundled
// script and asserts each relative import resolves to another bundled file, so
// any future out-of-bundle dependency fails the build instead of the user.
describe('bundled skill scripts are self-contained', () => {
// The skill's scripts dir ships the launcher, its Windows twin, the pinned
// engine VERSION, the page JS, and command-metadata.json. Nothing else: the
// verbs live in the engine binary the launcher runs, and platform binaries
// (scripts/bin/) are fetched per machine, never read as source.
describe('skill scripts payload', () => {
const ROOT_DIR = process.cwd();
const { skills } = utils.readSourceFiles(ROOT_DIR);
const scripts = skills[0]?.scripts ?? [];
const jsScripts = scripts.filter((s) => /\.(mjs|js)$/.test(s.name));
const names = new Set(scripts.map((s) => s.name));
// Static `import ... from '...'` and re-export `export ... from '...'` only;
// dynamic `import()` of computed paths (e.g. detect.mjs) is out of scope.
const importRe = /(?:^|[\s;])(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"]+)['"]/g;
// Drop comments first so an example like `// import ... from '...'` in a
// doc comment (detector/node/file-system.mjs has one) isn't read as a real import.
const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
test('the detector bundle includes its config dependency', () => {
expect(names.has('lib/impeccable-config.mjs')).toBe(true);
test('ships the launcher, VERSION, page JS, and command metadata', () => {
for (const expected of [
'impeccable', 'impeccable.cmd', 'VERSION', 'command-metadata.json',
'live-browser.js', 'live-browser-dom.js', 'live-browser-session.js', 'modern-screenshot.umd.js',
]) {
expect(names.has(expected)).toBe(true);
}
});
test('every relative import resolves to a bundled file', () => {
const broken = [];
for (const script of jsScripts) {
const source = stripComments(script.content);
importRe.lastIndex = 0;
let match;
while ((match = importRe.exec(source)) !== null) {
const specifier = match[1];
if (!specifier.startsWith('.')) continue; // bare/node specifiers
if (!resolveBundledImport(script.name, specifier, names)) {
broken.push(`${script.name} -> ${specifier}`);
}
}
}
expect(broken).toEqual([]);
test('ships no Node entry points and no bundled detector', () => {
expect([...names].filter((n) => n.endsWith('.mjs') || n.startsWith('detector/') || n.startsWith('lib/'))).toEqual([]);
});
test('never reads platform binaries as source', () => {
expect([...names].filter((n) => n.startsWith('bin/'))).toEqual([]);
});
test('the launcher is executable and VERSION matches ENGINE_VERSION', () => {
const launcher = scripts.find((s) => s.name === 'impeccable');
expect(launcher.mode & 0o111).not.toBe(0);
const version = scripts.find((s) => s.name === 'VERSION');
expect(version.content.trim()).toBe(fs.readFileSync(path.join(ROOT_DIR, 'ENGINE_VERSION'), 'utf-8').trim());
});
});
@@ -688,7 +661,7 @@ describe('agent bodies resolve placeholders on every surface that ships them', (
test('the asset producer ships a runnable embed-prompt command, never the raw token', () => {
for (const [relPath, scriptsPath] of SURFACES) {
const content = fs.readFileSync(path.join(DIST, relPath), 'utf-8');
expect(content).toContain(`node ${scriptsPath}/embed-prompt.mjs`);
expect(content).toContain(`${scriptsPath}/impeccable embed-prompt`);
expect(content).not.toContain('{{scripts_path}}');
}
});
@@ -703,7 +676,7 @@ describe('agent bodies resolve placeholders on every surface that ships them', (
name: 'impeccable-synthetic',
codexName: 'impeccable_synthetic',
description: 'synthetic agent',
body: 'Run `node {{scripts_path}}/embed-prompt.mjs` and ask {{model}}. <!-- rule:synthetic-marker -->',
body: 'Run `{{scripts_path}}/impeccable embed-prompt` and ask {{model}}. <!-- rule:synthetic-marker -->',
},
],
};
@@ -725,7 +698,7 @@ describe('agent bodies resolve placeholders on every surface that ships them', (
path.join(synthDist, 'codex/.codex/skills/impeccable/agents/impeccable_synthetic.toml'),
'utf-8'
);
expect(codexToml).toContain('node .codex/skills/impeccable/scripts/embed-prompt.mjs');
expect(codexToml).toContain('.codex/skills/impeccable/scripts/impeccable embed-prompt');
// The model name belongs to PROVIDER_PLACEHOLDERS and may change; what
// this pins is that {{model}} resolved to something.
expect(codexToml).toMatch(/and ask \S+\./);
+103 -106
View File
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import {
buildClaudeSettingsManifest,
@@ -26,30 +26,29 @@ 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(Math.min(parseInt(process.versions.node,10),${ENGINES_NODE_MAJOR})===${ENGINES_NODE_MAJOR}?0:1)`;
function expectCommand(command, expectedPath) {
// Every hook command is the launcher shipped in the skill's scripts dir,
// invoked as `<scripts>/impeccable <verb>` behind an existence guard: a
// missing launcher exits 0 (issue #399: user-level manifests fire in every
// project) and a present one keeps its own exit code, so Claude's exit-2
// blocking signal still reaches the agent. No runtime probe: the launcher
// runs a self-contained binary, so there is no Node on the path to check.
function expectCommand(command, expectedScriptsDir, verb = 'hook') {
assert.equal(typeof command, 'string');
// 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}`);
const launcher = `${expectedScriptsDir}/impeccable`;
assert.ok(command.includes(launcher), `missing ${launcher} in ${command}`);
assert.ok(
command.endsWith(`"] || "`) === false && command.includes(`" ] || "`),
`missing existence guard in ${command}`,
);
assert.match(command, new RegExp(`^\\[ ! -f "[^"]*/impeccable" \\] \\|\\| "[^"]*/impeccable" ${verb}$`));
assert.ok(!command.includes('node '), `hook command must not depend on node: ${command}`);
assert.ok(!command.includes('.mjs'), `hook command still names a Node script: ${command}`);
}
function expectWindowsCommand(command, expectedScriptsDir, verb = 'hook') {
assert.equal(typeof command, 'string');
const launcher = `${expectedScriptsDir}/impeccable.cmd`;
assert.equal(command, `if exist "${launcher}" ("${launcher}" ${verb} & exit /b)`);
}
function manifestCommands(manifest) {
@@ -77,7 +76,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, '.claude/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.claude/skills/impeccable/scripts');
assert.ok(handler.command.includes('${CLAUDE_PROJECT_DIR}'));
assert.equal(handler.args, undefined);
assert.equal(manifest.hooks.SessionStart, undefined);
@@ -87,7 +86,7 @@ describe('hook manifest builders', () => {
assert.equal(manifest.hooks.Stop[0].matcher, undefined);
assert.equal(stop.timeout, 30);
assert.equal(stop.statusMessage, 'Design deep pass');
expectCommand(stop.command, '.claude/skills/impeccable/scripts/hook.mjs');
expectCommand(stop.command, '.claude/skills/impeccable/scripts');
});
it('builds Codex project-local hooks for the real detector hook', () => {
@@ -103,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, '.codex/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.codex/skills/impeccable/scripts');
assert.ok(!handler.command.includes('git rev-parse --show-toplevel'));
assert.ok(!handler.command.includes('${PLUGIN_ROOT}'));
assert.equal(manifest.hooks.SessionStart, undefined);
@@ -112,7 +111,12 @@ describe('hook manifest builders', () => {
// pass too.
const stop = manifest.hooks.Stop[0].hooks[0];
assert.equal(stop.timeout, 30);
expectCommand(stop.command, '.codex/skills/impeccable/scripts/hook.mjs');
expectCommand(stop.command, '.codex/skills/impeccable/scripts');
// Codex 0.146.0+ selects `commandWindows` on Windows (issue #452), where
// the POSIX guard is not a command; that form calls impeccable.cmd.
expectWindowsCommand(handler.commandWindows, '.codex/skills/impeccable/scripts');
expectWindowsCommand(stop.commandWindows, '.codex/skills/impeccable/scripts');
});
it('derives the Codex hook payload path from the install dir', () => {
@@ -120,22 +124,22 @@ describe('hook manifest builders', () => {
// `.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');
expectCommand(codexDir.hooks.PostToolUse[0].hooks[0].command, '.codex/skills/impeccable/scripts');
expectCommand(codexDir.hooks.Stop[0].hooks[0].command, '.codex/skills/impeccable/scripts');
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');
expectCommand(agentsDir.hooks.PostToolUse[0].hooks[0].command, '.agents/skills/impeccable/scripts');
expectCommand(agentsDir.hooks.Stop[0].hooks[0].command, '.agents/skills/impeccable/scripts');
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',
'.agents/skills/impeccable/scripts',
);
expectCommand(
hooksJsonFor('codex').hooks.PostToolUse[0].hooks[0].command,
'.codex/skills/impeccable/scripts/hook.mjs',
'.codex/skills/impeccable/scripts',
);
});
@@ -149,7 +153,7 @@ describe('hook manifest builders', () => {
assert.equal(manifest.hooks.afterFileEdit, undefined);
assert.equal(manifest.hooks.stop, undefined);
assert.equal(manifest.hooks.sessionStart, undefined);
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs');
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts', 'hook-before-edit');
assert.equal(beforeEdit.timeout, 5);
});
@@ -166,7 +170,7 @@ describe('hook manifest builders', () => {
assert.equal(entry.timeoutSec, 5);
assert.equal(entry.timeout, undefined);
assert.equal(entry.command, undefined);
expectCommand(entry.bash, '.github/skills/impeccable/scripts/hook.mjs');
expectCommand(entry.bash, '.github/skills/impeccable/scripts');
assert.ok(entry.bash.includes('git rev-parse --show-toplevel'));
assert.equal(manifest.hooks.PostToolUse, undefined);
assert.equal(manifest.hooks.preToolUse, undefined);
@@ -182,7 +186,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, '.grok/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.grok/skills/impeccable/scripts');
assert.ok(!handler.command.includes('${CLAUDE_PROJECT_DIR}'));
assert.ok(!handler.command.includes('${GROK_PLUGIN_ROOT}'));
assert.equal(manifest.hooks.SessionStart, undefined);
@@ -190,51 +194,51 @@ describe('hook manifest builders', () => {
const stop = manifest.hooks.Stop[0].hooks[0];
assert.equal(stop.timeout, 30);
assert.equal(stop.statusMessage, 'Design deep pass');
expectCommand(stop.command, '.grok/skills/impeccable/scripts/hook.mjs');
expectCommand(stop.command, '.grok/skills/impeccable/scripts');
});
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 = [
it('emits commandWindows only for Codex-shaped manifests', () => {
// Codex reads a `commandWindows` sibling; Claude, Cursor, Grok, and Copilot
// have no per-platform field, and an unknown key is a risk under a strict
// parser, so it stays off everywhere else.
const withWindows = [buildCodexHooksManifest(), buildCodexPluginHooksManifest()];
const without = [
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}`);
const entries = (manifest) => {
const out = [];
const walk = (value) => {
if (Array.isArray(value)) { value.forEach(walk); return; }
if (value && typeof value === 'object') {
if (typeof value.command === 'string' || typeof value.bash === 'string') out.push(value);
Object.values(value).forEach(walk);
}
};
walk(manifest.hooks);
return out;
};
for (const manifest of withWindows) {
for (const entry of entries(manifest)) {
assert.equal(typeof entry.commandWindows, 'string', `missing commandWindows in ${JSON.stringify(entry)}`);
assert.ok(entry.commandWindows.includes('impeccable.cmd'));
}
}
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 without) {
for (const entry of entries(manifest)) {
assert.equal(entry.commandWindows, undefined, `unexpected commandWindows in ${JSON.stringify(entry)}`);
}
}
for (const manifest of probeOnly) {
for (const manifest of [...withWindows, ...without]) {
for (const command of manifestCommands(manifest)) {
assert.ok(!command.includes('systemMessage'), `unexpected notice in ${command}`);
assert.ok(!/node|systemMessage|node-unsupported/.test(command), `Node-era fragment in ${command}`);
}
}
});
// Volta's Windows shims exec through `cmd /C`, which claims `<`, `>`, and
// newlines from the `node -e` payload, so the probe died before node ran and
// the guard read that as a missing runtime (volta-cli/volta#1791). Every
// command is asserted to carry NODE_PROBE above, so this covers them all.
it('keeps the runtime probe free of characters cmd.exe re-parses', () => {
assert.ok(!/[<>\n]/.test(NODE_PROBE), `cmd.exe-unsafe character in probe: ${NODE_PROBE}`);
});
it('routes supported hook builders and leaves other providers alone', () => {
assert.ok(hooksJsonFor('claude'));
assert.ok(hooksJsonFor('codex'));
@@ -245,7 +249,14 @@ describe('hook manifest builders', () => {
});
});
describe('generated hook artifacts in repo', () => {
// The tracked provider outputs are regenerated on main by the sync workflow
// (`bun run build:release`), never in a feature PR. Until that sync lands after
// the launcher swap, the tracked manifests still describe the Node scripts;
// gate these assertions on the synced launcher so a source-first branch is
// not red for output it is not allowed to stage.
const SYNCED = fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/impeccable'));
describe('generated hook artifacts in repo', { skip: SYNCED ? false : 'generated provider output not yet synced (bun run build:release on main)' }, () => {
for (const rel of [
'.claude/settings.json',
'.cursor/hooks.json',
@@ -266,14 +277,12 @@ describe('generated hook artifacts in repo', () => {
assert.deepEqual(readJson('.github/hooks/impeccable.json'), buildGitHubHooksManifest());
});
it('Claude project settings reference hook.mjs in .claude/skills', () => {
it('Claude project settings reference the launcher in .claude/skills', () => {
const manifest = readJson('.claude/settings.json');
const handler = manifest.hooks.PostToolUse[0].hooks[0];
expectCommand(handler.command, '.claude/skills/impeccable/scripts/hook.mjs');
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/hook.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/hook-lib.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
expectCommand(handler.command, '.claude/skills/impeccable/scripts');
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts')));
});
it('Cursor project hooks reference only the pre-write runtime in .cursor/skills', () => {
@@ -281,15 +290,12 @@ describe('generated hook artifacts in repo', () => {
const beforeEdit = manifest.hooks.preToolUse[0];
assert.equal(Object.keys(manifest.hooks).length, 1);
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs');
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs')));
assert.equal(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-after-edit.mjs')), false);
assert.equal(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-stop.mjs')), false);
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-lib.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts', 'hook-before-edit');
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/impeccable')));
assert.equal(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs')), false);
});
it('Codex project hooks reference hook.mjs in the .codex skill payload', () => {
it('Codex project hooks reference the launcher 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
@@ -298,7 +304,7 @@ describe('generated hook artifacts in repo', () => {
const manifest = readJson('.codex/hooks.json');
const handler = manifest.hooks.PostToolUse[0].hooks[0];
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, '.codex/skills/impeccable/scripts');
assert.ok(!handler.command.includes('.agents/skills'));
// The self-consistent Codex bundle at `dist/codex/.codex/skills/` is a build
@@ -309,21 +315,17 @@ describe('generated hook artifacts in repo', () => {
// 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')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts')));
});
it('GitHub Copilot repo hooks reference hook.mjs in the .github skill payload', () => {
it('GitHub Copilot repo hooks reference the launcher in the .github skill payload', () => {
const manifest = readJson('.github/hooks/impeccable.json');
const entry = manifest.hooks.postToolUse[0];
assert.equal(entry.matcher, 'edit|create|apply_patch');
expectCommand(entry.bash, '.github/skills/impeccable/scripts/hook.mjs');
expectCommand(entry.bash, '.github/skills/impeccable/scripts');
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/SKILL.md')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/hook.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/hook-lib.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts')));
});
it('does not generate probe scripts into provider skill payloads', () => {
@@ -358,7 +360,7 @@ describe('generated hook artifacts in repo', () => {
const handler = manifest.hooks.PostToolUse[0].hooks[0];
assert.equal(manifest.hooks.PostToolUse[0].matcher, 'Edit|Write');
expectCommand(handler.command, 'skills/impeccable/scripts/hook.mjs');
expectCommand(handler.command, 'skills/impeccable/scripts');
// Resolves relative to the installed plugin, not a `.claude/skills/` layout.
assert.ok(handler.command.includes('${CLAUDE_PLUGIN_ROOT}'),
`plugin hook command must use $\{CLAUDE_PLUGIN_ROOT}: ${handler.command}`);
@@ -368,24 +370,14 @@ describe('generated hook artifacts in repo', () => {
// Stop deep pass ships in the plugin manifest too, plugin-root-relative.
const stop = manifest.hooks.Stop[0].hooks[0];
assert.equal(stop.timeout, 30);
expectCommand(stop.command, 'skills/impeccable/scripts/hook.mjs');
expectCommand(stop.command, 'skills/impeccable/scripts');
assert.ok(stop.command.includes('${CLAUDE_PLUGIN_ROOT}'));
// The script the plugin hook points at must ship inside the plugin payload.
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-lib.mjs')));
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts')));
});
it('keeps the marketplace hook repair matcher aligned with Claude Code', () => {
const hookAdmin = fs.readFileSync(
path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-admin.mjs'),
'utf8',
);
assert.match(hookAdmin, /matcher: 'Edit\|Write'/);
assert.doesNotMatch(hookAdmin, /matcher: 'Edit\|Write\|MultiEdit'/);
});
it('generated hook runtime can import the bundled detector', async () => {
it('generated skill payloads ship the executable launcher and no Node scripts', () => {
for (const scriptDir of [
'.claude/skills/impeccable/scripts',
'.cursor/skills/impeccable/scripts',
@@ -393,11 +385,16 @@ describe('generated hook artifacts in repo', () => {
'plugin/skills/impeccable/scripts',
]) {
const abs = path.join(REPO_ROOT, scriptDir);
assert.ok(fs.existsSync(path.join(abs, 'detector', 'detect-antipatterns.mjs')),
`detector bundle missing in ${scriptDir}`);
const hookLib = await import(pathToFileURL(path.join(abs, 'hook-lib.mjs')));
const detector = await hookLib.loadDetector();
assert.equal(typeof detector.detectText, 'function');
const launcher = path.join(abs, 'impeccable');
assert.ok(fs.existsSync(launcher), `launcher missing in ${scriptDir}`);
if (process.platform !== 'win32') {
assert.ok(fs.statSync(launcher).mode & 0o111, `launcher not executable in ${scriptDir}`);
}
assert.ok(fs.existsSync(path.join(abs, 'impeccable.cmd')), `impeccable.cmd missing in ${scriptDir}`);
assert.ok(fs.existsSync(path.join(abs, 'VERSION')), `VERSION missing in ${scriptDir}`);
assert.equal(fs.existsSync(path.join(abs, 'bin')), false, `${scriptDir} must stay launcher-only in git; binaries ship only in release zips`);
const stray = fs.readdirSync(abs).filter((f) => f.endsWith('.mjs') || f === 'detector' || f === 'lib');
assert.deepEqual(stray, [], `Node-era files still in ${scriptDir}`);
}
});
});
-27
View File
@@ -1,27 +0,0 @@
import { describe, expect, test } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { readSourceFiles } from '../../scripts/lib/utils.js';
const ROOT = process.cwd();
describe('skill detector bundle', () => {
test('adds the detector wrapper and engine files to skill scripts', () => {
const { skills } = readSourceFiles(ROOT);
const skill = skills.find(s => s.name === 'impeccable');
const scriptNames = new Set(skill.scripts.map(s => s.name));
expect(scriptNames.has('detect.mjs')).toBe(true);
expect(scriptNames.has('detector/detect-antipatterns.mjs')).toBe(true);
expect(scriptNames.has('detector/detect-antipatterns-browser.js')).toBe(true);
expect(scriptNames.has('detector/cli/main.mjs')).toBe(true);
expect(scriptNames.has('detector/engines/static-html/detect-html.mjs')).toBe(true);
});
test('critique references the bundled detector command', () => {
const critique = fs.readFileSync(path.join(ROOT, 'skill/reference/critique.md'), 'utf-8');
expect(critique).toContain('node {{scripts_path}}/detect.mjs --json [target]');
expect(critique).not.toContain('npx impeccable detect');
});
});
-301
View File
@@ -1,301 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import {
extractFindingIgnoreValue,
filterDetectionFindings,
getHookConsent,
setHookConsent,
getLocalConfigPath,
getConfigPath,
ensureConfigGitExclude,
readDetectionConfig,
readRawDetectionConfig,
shouldIgnoreDetectionFile,
writeDetectionConfig,
} from '../../cli/lib/impeccable-config.mjs';
describe('cli/lib/impeccable-config', () => {
let root;
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); });
afterEach(() => rmSync(root, { recursive: true, force: true }));
test('getHookConsent is undefined until a decision is recorded, then round-trips', () => {
expect(getHookConsent(root)).toBeUndefined();
setHookConsent(root, 'declined');
expect(getHookConsent(root)).toBe('declined');
setHookConsent(root, 'accepted');
expect(getHookConsent(root)).toBe('accepted');
});
test('setHookConsent preserves unrelated keys in config.local.json', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } }));
setHookConsent(root, 'declined');
const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
expect(raw.updateCheck).toBe(false);
expect(raw.hook.quiet).toBe(true);
expect(raw.hook.consent).toBe('declined');
});
test('config.local.json (per-developer) overrides config.json for consent', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } }));
writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } }));
expect(getHookConsent(root)).toBe('declined');
});
test('malformed config is tolerated (no throw, undefined consent)', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), '{ not json');
expect(getHookConsent(root)).toBeUndefined();
});
test('writing consent gitignores config.local.json via .git/info/exclude', () => {
execFileSync('git', ['init', '-q'], { cwd: root });
setHookConsent(root, 'declined');
const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect(exclude).toContain('.impeccable/config.local.json');
// Idempotent: a second write does not duplicate the marker block.
ensureConfigGitExclude(root);
const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1);
// It uses .git/info/exclude, not a tracked .gitignore.
expect(existsSync(join(root, '.gitignore'))).toBe(false);
});
test('ensureConfigGitExclude is a no-op outside a git repo', () => {
expect(ensureConfigGitExclude(root)).toBe(false);
});
test('readDetectionConfig merges shared and local detector filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
detector: {
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Avenir Next', reason: 'team default' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
],
designSystem: { enabled: false },
},
}));
writeFileSync(getLocalConfigPath(root), JSON.stringify({
detector: {
ignoreRules: ['gradient-text'],
ignoreFiles: ['src/local/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Avenir Next', reason: 'local override' },
{ rule: 'bounce-easing', value: 'bounce-ball' },
],
designSystem: { enabled: true },
},
}));
const cfg = readDetectionConfig(root);
expect(cfg.ignoreRules).toEqual(['side-tab', 'gradient-text']);
expect(cfg.ignoreFiles).toEqual(['src/legacy/**', 'src/local/**']);
expect(cfg.ignoreValues).toEqual([
{ rule: 'overused-font', value: 'avenir next', reason: 'local override' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
{ rule: 'bounce-easing', value: 'bounce-ball' },
]);
expect(cfg.designSystem).toEqual({ enabled: true });
});
test('readDetectionConfig remains backward-compatible with legacy hook filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
hook: {
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [{ rule: 'overused-font', value: 'Avenir Next' }],
designSystem: { enabled: false },
},
}));
const cfg = readDetectionConfig(root);
expect(cfg.ignoreRules).toEqual(['side-tab']);
expect(cfg.ignoreFiles).toEqual(['src/legacy/**']);
expect(cfg.ignoreValues).toEqual([{ rule: 'overused-font', value: 'avenir next' }]);
expect(cfg.designSystem).toEqual({ enabled: false });
});
test('writeDetectionConfig writes detector config and strips legacy hook filters', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
updateCheck: false,
hook: {
consent: 'accepted',
quiet: true,
ignoreRules: ['legacy-rule'],
ignoreFiles: ['legacy/**'],
ignoreValues: [{ rule: 'overused-font', value: 'Legacy' }],
},
}));
const config = readRawDetectionConfig(root);
config.ignoreRules.push('side-tab');
config.ignoreFiles.push('src/legacy/**');
writeDetectionConfig(root, config);
const raw = JSON.parse(readFileSync(getConfigPath(root), 'utf-8'));
expect(raw.updateCheck).toBe(false);
expect(raw.hook).toEqual({ consent: 'accepted', quiet: true });
expect(raw.detector.ignoreRules).toEqual(['legacy-rule', 'side-tab']);
expect(raw.detector.ignoreFiles).toEqual(['legacy/**', 'src/legacy/**']);
expect(raw.detector.ignoreValues).toEqual([{ rule: 'overused-font', value: 'legacy' }]);
expect(raw.detector.designSystem).toBeUndefined();
});
test('writeDetectionConfig local ignores do not create an implicit design-system override', () => {
execFileSync('git', ['init', '-q'], { cwd: root });
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({
detector: { designSystem: { enabled: false } },
}));
const local = readRawDetectionConfig(root, { local: true });
local.ignoreValues.push({ rule: 'overused-font', value: 'Inter' });
writeDetectionConfig(root, local, { local: true });
const rawLocal = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
expect(rawLocal.detector.designSystem).toBeUndefined();
expect(readDetectionConfig(root).designSystem).toEqual({ enabled: false });
expect(readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8')).toContain('.impeccable/config.local.json');
});
test('shouldIgnoreDetectionFile matches relative and absolute paths', () => {
const cfg = { ignoreFiles: ['src/legacy/**', '*.generated.tsx'] };
expect(shouldIgnoreDetectionFile(join(root, 'src', 'legacy', 'Card.tsx'), root, cfg)).toBe(true);
expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.generated.tsx'), root, cfg)).toBe(true);
expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.tsx'), root, cfg)).toBe(false);
});
test('filterDetectionFindings matches hook ignore value semantics', () => {
const findings = [
{ antipattern: 'overused-font', file: join(root, 'src', 'main.css'), line: 1, snippet: 'Primary font: Avenir Next' },
{ antipattern: 'overused-font', file: join(root, 'src', 'other.css'), line: 2, snippet: 'Primary font: Karla' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'demo.css'), line: 3, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'real.css'), line: 4, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-font', file: join(root, 'src', 'demo.css'), line: 5, ignoreValue: 'Avenir Next' },
{ antipattern: 'overused-font', file: join(root, 'src', 'fonts.css'), line: 6, snippet: 'Google Fonts: space grotesk' },
];
const filtered = filterDetectionFindings(findings, {
ignoreRules: [],
ignoreValues: [
{ rule: 'overused-font', value: 'avenir next' },
{ rule: 'design-system-color', value: '*', files: ['src/demo.css'] },
{ rule: 'design-system-font', value: '*' },
{ rule: 'overused-font', value: 'space grotesk' },
],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([
'overused-font:2',
'design-system-color:4',
'design-system-font:5',
]);
});
test('filterDetectionFindings honors file-scoped wildcard ignores for non-value-bearing rules', () => {
const findings = [
{ antipattern: 'side-tab', file: join(root, 'components', 'TopicCard.jsx'), line: 331, snippet: "borderLeft: '7px solid" },
{ antipattern: 'side-tab', file: join(root, 'components', 'Other.jsx'), line: 12, snippet: "borderLeft: '7px solid" },
];
const filtered = filterDetectionFindings(findings, {
ignoreRules: [],
ignoreValues: [{ rule: 'side-tab', value: '*', files: ['**/TopicCard.jsx'] }],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual(['side-tab:12']);
});
test('filterDetectionFindings matches equivalent design-system color values', () => {
const findings = [
{ antipattern: 'design-system-color', file: join(root, 'src', 'rgb.css'), line: 1, ignoreValue: 'rgb(139, 92, 246)' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'hex.css'), line: 2, ignoreValue: '#8b5cf6' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'alpha.css'), line: 3, ignoreValue: 'rgba(139, 92, 246, 0.5)' },
{ antipattern: 'design-system-color', file: join(root, 'src', 'other.css'), line: 4, ignoreValue: '#8b5cf7' },
{ antipattern: 'design-system-radius', file: join(root, 'src', 'radius.css'), line: 5, ignoreValue: 'rgb(139, 92, 246)' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#8b5cf6' },
{ rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' },
],
});
expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([
'design-system-color:3',
'design-system-color:4',
'design-system-radius:5',
]);
});
test('filterDetectionFindings normalizes every supported CSS color unit', () => {
const findings = [
{ antipattern: 'design-system-color', line: 1, ignoreValue: '#f00' },
{ antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100% 0% 0%)' },
{ antipattern: 'design-system-color', line: 3, ignoreValue: 'hsl(360deg 100% 50%)' },
{ antipattern: 'design-system-color', line: 4, ignoreValue: 'hsl(180deg 100% 50%)' },
{ antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(3.141592653589793rad 100% 50%)' },
{ antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0.5turn 100% 50%)' },
{ antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(200grad 100% 50%)' },
{ antipattern: 'design-system-color', line: 8, ignoreValue: 'rgba(255, 0, 0, 0.5)' },
{ antipattern: 'design-system-color', line: 9, ignoreValue: 'rgb(100% 0% 0% / 50%)' },
{ antipattern: 'design-system-color', line: 10, ignoreValue: 'hsla(0, 100%, 50%, 50%)' },
{ antipattern: 'design-system-color', line: 11, ignoreValue: '#f008' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#ff0000' },
{ rule: 'design-system-color', value: '#00ffff' },
{ rule: 'design-system-color', value: '#ff000080' },
{ rule: 'design-system-color', value: '#ff000088' },
],
});
expect(filtered).toEqual([]);
});
test('filterDetectionFindings rejects out-of-range and malformed CSS colors', () => {
const findings = [
{ antipattern: 'design-system-color', line: 1, ignoreValue: 'rgb(256 0 0)' },
{ antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100.1% 0% 0%)' },
{ antipattern: 'design-system-color', line: 3, ignoreValue: 'rgba(255, 0, 0, 101%)' },
{ antipattern: 'design-system-color', line: 4, ignoreValue: 'rgba(255, 0, 0, -0.1)' },
{ antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(0 100 50%)' },
{ antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0 101% 50%)' },
{ antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(0foo 100% 50%)' },
{ antipattern: 'design-system-color', line: 8, ignoreValue: '#ff00000' },
];
const filtered = filterDetectionFindings(findings, {
ignoreValues: [
{ rule: 'design-system-color', value: '#ff0000' },
{ rule: 'design-system-color', value: '#ff000080' },
],
});
expect(filtered.map((finding) => finding.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
test('extractFindingIgnoreValue handles fonts, Google font URLs, and motion snippets', () => {
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Primary font: Avenir Next (80% of text)' })).toBe('avenir next');
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'https://fonts.googleapis.com/css2?family=Alumni+Sans:wght@700' })).toBe('alumni sans');
expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Google Fonts: space grotesk' })).toBe('space grotesk');
expect(extractFindingIgnoreValue({ antipattern: 'bounce-easing', snippet: 'animation: bounce-ball 1s infinite' })).toBe('bounce-ball');
});
// This list is duplicated in skill/scripts/hook-lib.mjs. The two had drifted:
// font-size waivers worked in the hook but not in the CLI, so the same config
// filtered differently depending on which entry point read it.
test('extractFindingIgnoreValue covers design-system-font-size, matching the hook', () => {
expect(extractFindingIgnoreValue({ antipattern: 'design-system-font-size', ignoreValue: '0.82rem' })).toBe('0.82rem');
expect(extractFindingIgnoreValue({ antipattern: 'design-system-radius', ignoreValue: '18px' })).toBe('18px');
// A rule with no waivable value still extracts nothing.
expect(extractFindingIgnoreValue({ antipattern: 'side-tab', snippet: 'border-left: 4px' })).toBe('');
});
});