mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
* Stop the design hook lying about findings it already reported
Three fixes, all aimed at the hook being trustworthy enough that an agent
keeps reading it.
1. The session cache was append-only, so the hook lied and then went blind.
`rememberFindings` unioned new keys into the remembered set and nothing ever
removed them, and the pending ack took its count from that set rather than
from the live scan. Fixing two of three findings produced:
Still has 3 finding(s) flagged earlier this session
(overused-font:1:inter, overused-font:2:roboto, overused-font:3:geist)
with roboto and geist already gone. Worse, a finding that was fixed and then
reintroduced was deduped against the stale memory and never re-reported, so
the hook was permanently blind to that regression for the rest of the session.
The cache now syncs to the complete current scan on every scan, so the count
shrinks as work lands and a reintroduced finding reads as fresh. Dedup within
a session still works, because it compares against the previous scan rather
than against all history. A detector failure leaves the remembered set alone
instead of recording an empty scan as truth.
2. The size ceiling, for generated files that do not live under dist/.
`GENERATED_PATH` covered dist, build, out, .next, .cache, coverage and
.min., but repos commit browser bundles and vendored detector copies next to
source. The hook was reading and scanning a 215KB generated bundle, and
reporting findings in it. Added `generated` as a path segment, matched with
separators on both sides so authored names such as generated-utils.ts and
CodeGenerator.tsx still get scanned, plus a `limits.maxFileBytes` ceiling
defaulting to 128KB. In this codebase authored files top out at 86KB while
the bundles start at 215KB, so the gap is comfortable.
3. The clean ack repeated on every clean edit.
It carries no finding, only the standing steer that a silent hook is not a
verdict on the design. That steer is worth saying, but not dozens of times
per session. It now fires once per file per session and reports
`clean-ack-deduped` in the audit log so suppressed noise stays visible. The
pending ack is deliberately untouched: it names real unresolved work, and the
comment explaining why it must repeat still holds.
Verified end-to-end against the built hook: three findings, fix two and the
count drops to one naming only the survivor, fix the last and it goes clean,
edit again and it stays silent, reintroduce and it fires as fresh.
Generated provider output is deliberately left out; the sync workflow owns it.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Address review: three clean-ack and audit bugs in the dedupe change
All three were introduced by this PR and all three are fair catches.
Quiet mode spent the ack (bugbot). A clean scan marked cleanAcked and
persisted it even when quiet suppressed all output, so a later non-quiet run
in the same session never got the steer. The quiet decision is now hoisted
above the scan loop and quiet leaves the ack unspent.
Multi-file events lost the ack (copilot). The first clean target became
cleanWinner unconditionally; if that file was already acked, cleanAckDeduped
went true and the `!cleanWinner` guard meant a later target that had never
been acked could never win. A raw apply_patch touching two files would drop
the second file's ack entirely. The loop now keeps looking for a target that
is actually owed an ack.
audit.bytes leaked across targets (copilot). It was set when a file was
skipped as too-large and never cleared, so in a multi-file event a later
emitted result carried the skipped file's byte count. Cleared per iteration.
The tests use a raw apply_patch payload rather than MultiEdit, because
MultiEdit in this harness is single-file ({ file_path, edits: [] }) and would
not have exercised the multi-target paths at all. Verified the three tests
fail against the pre-fix code and pass after, so they are not passing for the
wrong reason.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Address review: font-size waivers silently did nothing
Two more review findings, both real.
Specific-value font-size waivers were dead config (greptile). The rule emits an
ignoreValue, and the hook's own directive footer tells the agent to waive
value-specific findings with `hooks ignore-value <rule> <value>`, but
`design-system-font-size` was missing from the direct-value rule set in
`extractFindingIgnoreValue`. The extracted value came back empty, so any
waiver naming an actual size was compared against nothing and silently
dropped. Only the `*` wildcard worked, which is why the framework-viz waiver
earlier in this branch appeared to function.
Reproduced against the built hook: with a `0.82rem` waiver the finding still
fired; it now goes clean, while a waiver naming a different size correctly
still fires, so this is not over-matching.
Wrong audit skip reason (bugbot). In a mixed multi-target run, an earlier UI
file whose ack was already spent set `cleanAckDeduped`, and a later non-UI
clean file became the winner. The tail then reported `clean-ack-deduped` when
the honest reason was `non-ui-ack`. Audit-label only, no behavior change.
Reordered so the winner is described first and dedupe is reported only when it
is genuinely why nothing was emitted.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Mirror the font-size waiver fix into the CLI's config reader
Bugbot caught that the previous commit only fixed one of two copies.
`extractFindingIgnoreValue` exists twice, in skill/scripts/hook-lib.mjs and in
cli/lib/impeccable-config.mjs, and the direct-value rule list is duplicated in
both. Adding design-system-font-size to the hook alone meant the same
.impeccable/config.json filtered differently depending on the entry point: a
size waiver was honored by the hook and ignored by `npx impeccable detect`.
The two functions are otherwise byte-identical, so this restores parity rather
than changing CLI behavior independently. The new test notes the duplication so
the next person knows the pair has drifted once already.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix the audit byte-count leak properly, not just one scan order
My earlier fix cleared audit.bytes at the top of each iteration, which was
wrong twice over, and bugbot caught both.
The clear sat below the sensitive, generated, extension, ignore-file and
file-missing continues, so a later target exiting through any of those never
reached it and kept the oversized file's size while audit.file pointed
somewhere else. It also only handled the bundle-scanned-first order; when the
oversized file came last, the byte count was set after the emitting file had
already been decided and rode along on its audit entry regardless.
The root problem was keeping per-file state on the shared audit object. The
size is now held in a local and attached only when the oversized skip is the
run's actual outcome, so it cannot describe a file other than the one being
reported. Tests cover both scan orders, an early-continue target after the
skip, and the single-oversized-file case where the count should still appear.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
3104 lines
127 KiB
JavaScript
3104 lines
127 KiB
JavaScript
/**
|
|
* Unit tests for the Impeccable design hook.
|
|
* Run: node --test tests/hook.test.mjs
|
|
*
|
|
* Exercises hook-lib.mjs through `runHook()` with an injected detector so the
|
|
* suite stays fast and detector-independent. A second block exercises the
|
|
* library helpers (config, cache, filter, render) directly.
|
|
*/
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
import {
|
|
ENVELOPE_PREFIX,
|
|
ALLOWED_EXTS,
|
|
ACK_EXTS,
|
|
DEFAULT_CONFIG,
|
|
SENSITIVE_PATH,
|
|
GENERATED_PATH,
|
|
truthy,
|
|
getConfigPath,
|
|
getLocalConfigPath,
|
|
ensureHookGitExcludes,
|
|
readConfig,
|
|
readCache,
|
|
persistCache,
|
|
resolveCacheCwd,
|
|
bumpEditCount,
|
|
rememberFindings,
|
|
dedupeAgainstCache,
|
|
filterFindings,
|
|
renderTemplate,
|
|
renderCleanAck,
|
|
renderPendingAck,
|
|
shouldEmitAckForFile,
|
|
matchConfiguredExtension,
|
|
matchesAnyGlob,
|
|
writeAuditLog,
|
|
suppressionNotice,
|
|
parseApplyPatchPaths,
|
|
resolveTargetFiles,
|
|
resolveHarness,
|
|
normalizeHookEvent,
|
|
expandScanTargets,
|
|
parseStaticStyleImports,
|
|
coLocatedStylesheets,
|
|
runHook,
|
|
payload,
|
|
extractFindingIgnoreValue,
|
|
resolveProjectPlatform,
|
|
isNativePlatform,
|
|
normalizeIgnoreValueEntries,
|
|
} from '../skill/scripts/hook-lib.mjs';
|
|
import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs';
|
|
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
|
|
|
|
function mkTmp() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-'));
|
|
}
|
|
|
|
function fakeDetector(findings) {
|
|
return {
|
|
detectText: () => findings,
|
|
detectHtml: () => findings,
|
|
};
|
|
}
|
|
|
|
function finding(id, line, extras = {}) {
|
|
return {
|
|
antipattern: id,
|
|
name: extras.name || 'Test finding',
|
|
description: extras.description || 'A test finding description.',
|
|
severity: extras.severity || 'warning',
|
|
file: extras.file || 'src/Card.tsx',
|
|
line,
|
|
snippet: extras.snippet || '<snippet>',
|
|
};
|
|
}
|
|
|
|
describe('truthy()', () => {
|
|
it('matches the documented values, case-insensitive', () => {
|
|
for (const v of ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'On']) {
|
|
assert.equal(truthy(v), true, `expected truthy("${v}")`);
|
|
}
|
|
for (const v of ['', '0', 'false', 'no', 'off', 'yep', undefined, null, 42]) {
|
|
assert.equal(truthy(v), false, `expected falsy(${JSON.stringify(v)})`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SENSITIVE_PATH / GENERATED_PATH', () => {
|
|
it('skips .env, .pem, id_rsa, secrets, credentials, .git', () => {
|
|
for (const p of [
|
|
'/x/.env', '/x/.env.production', '/x/server.pem', '/x/id_rsa',
|
|
'/x/id_rsa.pub', '/x/api-secret.json', '/x/client_secret.ts',
|
|
'/x/credentials.yml', '/x/.git/config',
|
|
]) {
|
|
assert.ok(SENSITIVE_PATH.test(p), `expected sensitive: ${p}`);
|
|
}
|
|
});
|
|
|
|
it('does not flag normal source files as sensitive', () => {
|
|
for (const p of [
|
|
'/x/src/Card.tsx',
|
|
'/x/app/page.html',
|
|
'/x/styles/main.css',
|
|
'/x/src/CredentialForm.tsx',
|
|
'/x/src/SecretPage.jsx',
|
|
'/x/src/secretary-dashboard.vue',
|
|
'/x/src/credentials-panel.tsx',
|
|
]) {
|
|
assert.ok(!SENSITIVE_PATH.test(p), `unexpected sensitive: ${p}`);
|
|
}
|
|
});
|
|
|
|
it('skips generated / lock / build output paths', () => {
|
|
for (const p of [
|
|
'/x/src/foo.generated.tsx', '/x/types.d.ts', '/x/bundle.min.js',
|
|
'/x/node_modules/lib/index.tsx', '/x/dist/Card.tsx', '/x/build/index.html',
|
|
'/x/pkg.lock.json', '/x/.next/server.js', '/x/coverage/report.html',
|
|
]) {
|
|
assert.ok(GENERATED_PATH.test(p), `expected generated: ${p}`);
|
|
}
|
|
});
|
|
|
|
it('skips committed build output living outside dist/', () => {
|
|
// Not every generated artifact lands in dist/. Repos commit browser
|
|
// bundles and detector copies next to source, and findings against them
|
|
// are never actionable.
|
|
for (const p of [
|
|
'/x/site/public/js/generated/counts.js',
|
|
'/x/src/generated/schema.ts',
|
|
'/x/app/generated/api.tsx',
|
|
]) {
|
|
assert.ok(GENERATED_PATH.test(p), `expected generated: ${p}`);
|
|
}
|
|
});
|
|
|
|
it('does not treat authored paths that merely mention generation as generated', () => {
|
|
for (const p of [
|
|
'/x/src/generateReport.ts',
|
|
'/x/src/generated-utils.ts',
|
|
'/x/src/components/CodeGenerator.tsx',
|
|
'/x/src/ui/regenerate-button.jsx',
|
|
]) {
|
|
assert.ok(!GENERATED_PATH.test(p), `unexpected generated: ${p}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('readConfig()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('returns defaults when file missing', () => {
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.limits.maxFindings, DEFAULT_CONFIG.limits.maxFindings);
|
|
});
|
|
|
|
it('parses hook runtime and legacy hook detector filters', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
hook: {
|
|
enabled: false,
|
|
ignoreRules: ['side-tab'],
|
|
ignoreFiles: ['src/legacy/**'],
|
|
minSeverity: 'error',
|
|
limits: { maxFindings: 2, maxChars: 1000 },
|
|
},
|
|
}));
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.enabled, false);
|
|
assert.deepEqual(cfg.ignoreRules, ['side-tab']);
|
|
assert.deepEqual(cfg.ignoreFiles, ['src/legacy/**']);
|
|
assert.deepEqual(cfg.ignoreValues, []);
|
|
assert.equal(cfg.limits.maxFindings, 2);
|
|
assert.equal(cfg.limits.maxChars, 1000);
|
|
});
|
|
|
|
it('merges shared config first and local config second', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
hook: {
|
|
enabled: false,
|
|
minSeverity: 'error',
|
|
limits: { maxFindings: 2, maxChars: 1000 },
|
|
},
|
|
detector: {
|
|
ignoreRules: ['side-tab'],
|
|
ignoreFiles: ['src/legacy/**'],
|
|
ignoreValues: [
|
|
{ rule: 'overused-font', value: 'inter', reason: 'team default' },
|
|
],
|
|
},
|
|
}));
|
|
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({
|
|
hook: {
|
|
enabled: true,
|
|
minSeverity: 'warning',
|
|
limits: { maxFindings: 4 },
|
|
},
|
|
detector: {
|
|
ignoreRules: ['gradient-text', 'side-tab'],
|
|
ignoreFiles: ['src/local/**'],
|
|
ignoreValues: [
|
|
{ rule: 'overused-font', value: 'Roboto' },
|
|
{ rule: 'overused-font', value: 'Inter', reason: 'local override' },
|
|
],
|
|
},
|
|
}));
|
|
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.enabled, true);
|
|
assert.deepEqual(cfg.ignoreRules, ['side-tab', 'gradient-text']);
|
|
assert.deepEqual(cfg.ignoreFiles, ['src/legacy/**', 'src/local/**']);
|
|
assert.deepEqual(cfg.ignoreValues, [
|
|
{ rule: 'overused-font', value: 'inter', reason: 'local override' },
|
|
{ rule: 'overused-font', value: 'roboto' },
|
|
]);
|
|
assert.equal(cfg.limits.maxFindings, 4);
|
|
assert.equal(cfg.limits.maxChars, 1000);
|
|
});
|
|
|
|
it('tolerates malformed JSON and falls back to defaults', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), '{ not json');
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.enabled, true);
|
|
});
|
|
|
|
it('ignores malformed local config while preserving valid shared config', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
hook: {
|
|
enabled: false,
|
|
ignoreRules: ['side-tab'],
|
|
limits: { maxFindings: 3 },
|
|
},
|
|
}));
|
|
fs.writeFileSync(getLocalConfigPath(cwd), '{ not json');
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.enabled, false);
|
|
assert.deepEqual(cfg.ignoreRules, ['side-tab']);
|
|
assert.equal(cfg.limits.maxFindings, 3);
|
|
});
|
|
|
|
it('parses detector.extensions entries and defaults engine to html', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
detector: {
|
|
extensions: [
|
|
{ ext: '.blade.php' },
|
|
{ ext: '.html.erb', engine: 'html' },
|
|
{ ext: '.d.ts.hbs', engine: 'text' },
|
|
'twig',
|
|
{ ext: '' },
|
|
{ engine: 'html' },
|
|
42,
|
|
],
|
|
},
|
|
}));
|
|
const cfg = readConfig(cwd);
|
|
assert.deepEqual(cfg.extensions, [
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
{ ext: '.html.erb', engine: 'html' },
|
|
{ ext: '.d.ts.hbs', engine: 'text' },
|
|
{ ext: '.twig', engine: 'html' },
|
|
]);
|
|
});
|
|
|
|
it('lets local-config detector.extensions override the shared engine per ext', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
detector: { extensions: [{ ext: '.blade.php', engine: 'html' }] },
|
|
}));
|
|
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({
|
|
detector: { extensions: [{ ext: '.blade.php', engine: 'text' }, { ext: '.twig' }] },
|
|
}));
|
|
const cfg = readConfig(cwd);
|
|
assert.deepEqual(cfg.extensions, [
|
|
{ ext: '.blade.php', engine: 'text' },
|
|
{ ext: '.twig', engine: 'html' },
|
|
]);
|
|
});
|
|
|
|
it('defaults detector.extensions to an empty list', () => {
|
|
assert.deepEqual(readConfig(cwd).extensions, []);
|
|
});
|
|
|
|
it('parses the new quiet and auditLog fields from the unified config', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
hook: { quiet: true, auditLog: '~/hook.ndjson' },
|
|
detector: { designSystem: { enabled: false } },
|
|
}));
|
|
const cfg = readConfig(cwd);
|
|
assert.equal(cfg.quiet, true);
|
|
assert.equal(cfg.auditLog, '~/hook.ndjson');
|
|
assert.deepEqual(cfg.designSystem, { enabled: false });
|
|
});
|
|
});
|
|
|
|
describe('readCache / persistCache / bumpEditCount', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('round-trips a session', () => {
|
|
const cache = readCache(cwd);
|
|
bumpEditCount(cache, 'sid-1', '/x/a.tsx');
|
|
bumpEditCount(cache, 'sid-1', '/x/a.tsx');
|
|
rememberFindings(cache, 'sid-1', '/x/a.tsx', [finding('side-tab', 12)]);
|
|
persistCache(cwd, cache);
|
|
|
|
const reloaded = readCache(cwd);
|
|
const file = reloaded.sessions['sid-1'].files['/x/a.tsx'];
|
|
assert.equal(file.editCount, 2);
|
|
assert.ok(file.findings.includes('side-tab:12'));
|
|
});
|
|
|
|
it('keeps same-line value-specific findings distinct in the cache', () => {
|
|
const cache = readCache(cwd);
|
|
const hotPink = {
|
|
...finding('design-system-color', 7, { snippet: 'Undocumented color #ff00aa' }),
|
|
ignoreValue: '#ff00aa',
|
|
};
|
|
const cyan = {
|
|
...finding('design-system-color', 7, { snippet: 'Undocumented color rgb(20, 180, 220)' }),
|
|
ignoreValue: 'rgb(20, 180, 220)',
|
|
};
|
|
|
|
assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [hotPink, cyan]);
|
|
rememberFindings(cache, 'sid-1', '/x/a.css', [hotPink]);
|
|
assert.deepEqual(dedupeAgainstCache([hotPink, cyan], cache, 'sid-1', '/x/a.css'), [cyan]);
|
|
});
|
|
|
|
it('garbage-collects oldest sessions over CACHE_MAX_SESSIONS', () => {
|
|
const cache = readCache(cwd);
|
|
// Stamp 10 sessions, each with a unique updatedAt so ordering is stable.
|
|
for (let i = 0; i < 10; i++) {
|
|
const id = `sid-${i}`;
|
|
cache.sessions[id] = { updatedAt: 1000 + i, files: {} };
|
|
}
|
|
persistCache(cwd, cache);
|
|
const reloaded = readCache(cwd);
|
|
assert.equal(Object.keys(reloaded.sessions).length, 8);
|
|
assert.ok(reloaded.sessions['sid-9'], 'newest preserved');
|
|
assert.ok(!reloaded.sessions['sid-0'], 'oldest gc-ed');
|
|
});
|
|
});
|
|
|
|
describe('ensureHookGitExcludes()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('adds hook runtime files to local git info exclude, not tracked .gitignore', () => {
|
|
execFileSync('git', ['init', '-q'], { cwd });
|
|
|
|
const result = ensureHookGitExcludes(cwd);
|
|
assert.equal(result.mode, 'git-info-exclude');
|
|
assert.equal(result.changed, true);
|
|
assert.equal(fs.existsSync(path.join(cwd, '.gitignore')), false);
|
|
|
|
const exclude = fs.readFileSync(path.join(cwd, '.git', 'info', 'exclude'), 'utf-8');
|
|
assert.match(exclude, /\.impeccable\/hook\.cache\.json/);
|
|
assert.match(exclude, /\.impeccable\/hook\.pending\.json/);
|
|
assert.match(exclude, /\.impeccable\/config\.local\.json/);
|
|
|
|
const second = ensureHookGitExcludes(cwd);
|
|
assert.equal(second.changed, false);
|
|
const rewritten = fs.readFileSync(path.join(cwd, '.git', 'info', 'exclude'), 'utf-8');
|
|
assert.equal((rewritten.match(/impeccable-hook-ignore-start/g) || []).length, 1);
|
|
});
|
|
});
|
|
|
|
describe('matchesAnyGlob()', () => {
|
|
it('handles `**`, `*`, basename, and `{}` alternation', () => {
|
|
assert.ok(matchesAnyGlob('src/legacy/Foo.tsx', ['src/legacy/**']));
|
|
assert.ok(matchesAnyGlob('src/Foo.generated.tsx', ['**/*.generated.tsx']));
|
|
assert.ok(matchesAnyGlob('src/Foo.generated.tsx', ['*.generated.tsx']));
|
|
// {ts,tsx} expands to (?:ts|tsx) so the actual file path is what matches.
|
|
assert.ok(matchesAnyGlob('src/widget/Foo.tsx', ['src/widget/Foo.{ts,tsx}']));
|
|
assert.ok(matchesAnyGlob('src/widget/Foo.ts', ['src/widget/Foo.{ts,tsx}']));
|
|
assert.ok(!matchesAnyGlob('src/widgets/Foo.tsx', ['src/legacy/**']));
|
|
assert.ok(!matchesAnyGlob('src/Foo.tsx', []));
|
|
});
|
|
});
|
|
|
|
describe('filterFindings()', () => {
|
|
it('drops by ignoreRules and ignores legacy minSeverity config', () => {
|
|
const content = [
|
|
'a', // line 1
|
|
'b', // line 2
|
|
].join('\n');
|
|
const findings = [
|
|
finding('side-tab', 1, { severity: 'warning' }),
|
|
finding('gradient-text', 2, { severity: 'warning' }),
|
|
finding('overused-font', 5, { severity: 'advisory' }),
|
|
];
|
|
const filtered = filterFindings(findings, content, '.ts', {
|
|
ignoreRules: ['side-tab'],
|
|
minSeverity: 'error',
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
|
});
|
|
|
|
it('does not treat source comments as hook suppression', () => {
|
|
const content = [
|
|
'/* impeccable: ignore * */',
|
|
'.card { font-family: "Roboto", sans-serif; }',
|
|
'<!-- impeccable: ignore side-tab -->',
|
|
'<div style="border-left: 4px solid #7c3aed; border-radius: 16px;">Card</div>',
|
|
].join('\n');
|
|
const filtered = filterFindings(
|
|
[finding('overused-font', 2), finding('side-tab', 4)],
|
|
content, '.html',
|
|
{ ignoreRules: [], minSeverity: 'warning', limits: DEFAULT_CONFIG.limits }
|
|
);
|
|
assert.deepEqual(filtered.map((f) => f.antipattern), ['overused-font', 'side-tab']);
|
|
});
|
|
|
|
it('drops only matching rule/value pairs from ignoreValues', () => {
|
|
const findings = [
|
|
finding('overused-font', 1, { snippet: 'Primary font: Inter (86% of text)' }),
|
|
finding('overused-font', 2, { snippet: 'Primary font: Roboto' }),
|
|
finding('bounce-easing', 3, { snippet: 'animation: bounce-ball' }),
|
|
finding('bounce-easing', 4, { snippet: 'animation: wobble-card' }),
|
|
finding('side-tab', 3),
|
|
];
|
|
const filtered = filterFindings(findings, '', '.css', {
|
|
ignoreRules: [],
|
|
ignoreValues: [
|
|
{ rule: 'overused-font', value: 'inter' },
|
|
{ rule: 'bounce-easing', value: 'bounce-ball' },
|
|
],
|
|
minSeverity: 'warning',
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['overused-font:2', 'bounce-easing:4', 'side-tab:3']);
|
|
});
|
|
|
|
it('honors a specific-value ignoreValues entry for design-system-font-size', () => {
|
|
// The rule carries an ignoreValue and the hook's own directive tells the
|
|
// agent to waive value-specific findings with `hooks ignore-value`, but
|
|
// font-size was missing from the direct-value rule set, so any waiver
|
|
// naming an actual size was filtered against an empty extracted value and
|
|
// silently did nothing. Only the `*` wildcard worked.
|
|
const findings = [
|
|
{ ...finding('design-system-font-size', 1), ignoreValue: '0.82rem' },
|
|
{ ...finding('design-system-font-size', 2), ignoreValue: '0.9rem' },
|
|
];
|
|
const filtered = filterFindings(findings, '', '.css', {
|
|
ignoreRules: [],
|
|
ignoreValues: [{ rule: 'design-system-font-size', value: '0.82rem' }],
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => f.ignoreValue), ['0.9rem']);
|
|
});
|
|
|
|
it('scopes ignoreValues to file globs when files are provided', () => {
|
|
const findings = [
|
|
{ ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' },
|
|
{ ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' },
|
|
{ ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/home-kinpaku.css' }), ignoreValue: 'oklch(60% 0.25 350 / 0.22)' },
|
|
];
|
|
const filtered = filterFindings(findings, '', '.css', {
|
|
ignoreRules: [],
|
|
ignoreValues: [
|
|
{ rule: 'design-system-color', value: '#8b5cf6', files: ['site/styles/main.css'] },
|
|
{ rule: 'design-system-color', value: 'oklch(60% 0.25 350 / 0.22)', file: 'site/styles/home-kinpaku.css' },
|
|
],
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => `${f.file}:${f.line}`), ['/tmp/project/site/styles/feature.css:2']);
|
|
});
|
|
|
|
it('matches equivalent design-system color ignore values', () => {
|
|
const findings = [
|
|
{ ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/rgb.css' }), ignoreValue: 'rgb(139, 92, 246)' },
|
|
{ ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/hex.css' }), ignoreValue: '#8b5cf6' },
|
|
{ ...finding('design-system-color', 3, { file: '/tmp/project/site/styles/alpha.css' }), ignoreValue: 'rgba(139, 92, 246, 0.5)' },
|
|
{ ...finding('design-system-color', 4, { file: '/tmp/project/site/styles/other.css' }), ignoreValue: '#8b5cf7' },
|
|
{ ...finding('design-system-radius', 5, { file: '/tmp/project/site/styles/radius.css' }), ignoreValue: 'rgb(139, 92, 246)' },
|
|
];
|
|
const filtered = filterFindings(findings, '', '.css', {
|
|
ignoreRules: [],
|
|
ignoreValues: [
|
|
{ rule: 'design-system-color', value: '#8b5cf6' },
|
|
{ rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' },
|
|
],
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), [
|
|
'design-system-color:3',
|
|
'design-system-color:4',
|
|
'design-system-radius:5',
|
|
]);
|
|
});
|
|
|
|
it('allows wildcard ignoreValues only when scoped to files', () => {
|
|
const findings = [
|
|
{ ...finding('design-system-color', 1, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: '#8b5cf6' },
|
|
{ ...finding('design-system-color', 2, { file: '/tmp/project/site/styles/feature.css' }), ignoreValue: '#8b5cf6' },
|
|
{ ...finding('design-system-font', 3, { file: '/tmp/project/site/styles/main.css' }), ignoreValue: 'Inter' },
|
|
];
|
|
const filtered = filterFindings(findings, '', '.css', {
|
|
ignoreRules: [],
|
|
ignoreValues: [
|
|
{ rule: 'design-system-color', value: '*', files: ['site/styles/main.css'] },
|
|
{ rule: 'design-system-font', value: '*' },
|
|
],
|
|
limits: DEFAULT_CONFIG.limits,
|
|
});
|
|
assert.deepEqual(filtered.map((f) => `${f.antipattern}:${f.line}`), ['design-system-color:2', 'design-system-font:3']);
|
|
});
|
|
|
|
it('extracts overused-font values from primary, CSS, and Google font snippets', () => {
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'Primary font: Open Sans (80% of text)' })),
|
|
'open sans',
|
|
);
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'body { font-family: "Inter", sans-serif; }' })),
|
|
'inter',
|
|
);
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('overused-font', 1, { snippet: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400' })),
|
|
'plus jakarta sans',
|
|
);
|
|
assert.equal(extractFindingIgnoreValue(finding('side-tab', 1)), '');
|
|
});
|
|
|
|
it('extracts bounce-easing values from motion snippets', () => {
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animation: bounce-ball' })),
|
|
'bounce-ball',
|
|
);
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'animate-bounce (Tailwind)' })),
|
|
'animate-bounce',
|
|
);
|
|
assert.equal(
|
|
extractFindingIgnoreValue(finding('bounce-easing', 1, { snippet: 'cubic-bezier(0.3, -0.4, 0.6, 1.4)' })),
|
|
'cubic-bezier(0.3, -0.4, 0.6, 1.4)',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('hook-admin.mjs', () => {
|
|
let cwd;
|
|
const script = path.resolve('skill', 'scripts', 'hook-admin.mjs');
|
|
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function runAdmin(args) {
|
|
return execFileSync(process.execPath, [script, ...args], {
|
|
cwd,
|
|
env: { ...process.env },
|
|
encoding: 'utf-8',
|
|
});
|
|
}
|
|
|
|
it('refuses an empty --file glob instead of silently writing a project-wide ignore', () => {
|
|
// `--file=` was dropped by filter(Boolean), so this reported success and
|
|
// stored an entry with no files: a broader suppression than was asked for.
|
|
for (const args of [['--file='], ['--file', ''], ['--files=']]) {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'overused-font', 'Inter', ...args]),
|
|
/requires a non-empty glob/,
|
|
`empty glob via ${args.join(' ')} must error`,
|
|
);
|
|
}
|
|
// `--file --reason "why"` consumed --reason as the scope and let the reason
|
|
// text fold into the value: stored value="* why" files=["--reason"], success.
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', '--reason', 'why']),
|
|
/requires a glob, got the flag --reason/,
|
|
'a following flag is not a glob',
|
|
);
|
|
assert.equal(fs.existsSync(path.join(cwd, '.impeccable', 'config.json')), false, 'nothing may be written');
|
|
});
|
|
|
|
it('matches an on-disk scope whose glob order differs from the sorted argv form', () => {
|
|
// Storage is canonical now, but configs written before that are not. Every key
|
|
// that hashes `files` must sort or a re-add duplicates the entry.
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({
|
|
detector: { ignoreValues: [
|
|
{ rule: 'design-system-font-size', value: '*', files: ['b.css', 'a.css'], createdAt: '2026-01-01T00:00:00.000Z' },
|
|
] },
|
|
}));
|
|
runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'a.css', '--file', 'b.css', '--reason', 're-add']);
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'config.json'), 'utf-8'));
|
|
const entries = cfg.detector.ignoreValues.filter((e) => e.rule === 'design-system-font-size');
|
|
assert.equal(entries.length, 1, 'an unsorted on-disk scope must match the sorted argv form, not duplicate');
|
|
assert.equal(entries[0].reason, 're-add', 'the existing entry is the one updated');
|
|
});
|
|
|
|
it('stores a multi-file scope in canonical order so argv order cannot duplicate it', () => {
|
|
runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'b.css', '--file', 'a.css']);
|
|
runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'a.css', '--file', 'b.css']);
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'config.json'), 'utf-8'));
|
|
const entries = cfg.detector.ignoreValues.filter((e) => e.rule === 'design-system-font-size');
|
|
assert.equal(entries.length, 1, 'the same scope in a different order is one entry, not two');
|
|
assert.deepEqual(entries[0].files, ['a.css', 'b.css']);
|
|
});
|
|
|
|
it('status shows the file scope of a scoped wildcard ignore', () => {
|
|
runAdmin(['ignore-value', 'design-system-font-size', '*', '--file', 'src/widget.js']);
|
|
const out = runAdmin(['status']);
|
|
// Printing `design-system-font-size=*` bare reads as the project-wide
|
|
// wildcard this command refuses, which is the opposite of what is on disk.
|
|
assert.match(out, /design-system-font-size=\*\s*\[src\/widget\.js\]/);
|
|
});
|
|
|
|
it('refuses a bare wildcard and names a project-wide command that actually works', () => {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'design-system-font-size', '*']),
|
|
(err) => /--file/.test(String(err.stderr)) && /ignore-rule design-system-font-size\./.test(String(err.stderr)),
|
|
);
|
|
// ignore-rule overused-font refuses on its own without --all-values, so the
|
|
// suggestion must carry the flag or it hands the user a second error.
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'overused-font', '*']),
|
|
(err) => /ignore-rule overused-font --all-values/.test(String(err.stderr)),
|
|
);
|
|
});
|
|
|
|
it('ignore-value writes shared config by default without creating local config', () => {
|
|
const out = runAdmin(['ignore-value', 'overused-font', 'Inter', '--reason', 'User confirmed Inter']);
|
|
assert.match(out, /overused-font=inter/);
|
|
assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false);
|
|
const raw = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
|
|
assert.equal(raw.hook, undefined);
|
|
const shared = raw.detector;
|
|
assert.deepEqual(shared.ignoreRules, []);
|
|
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
|
|
{ rule: 'overused-font', value: 'inter', reason: 'User confirmed Inter' },
|
|
]);
|
|
assert.match(shared.ignoreValues[0].createdAt, /^\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it('ignore-value --shared remains accepted for shared config', () => {
|
|
runAdmin(['ignore-value', 'overused-font', 'Open', 'Sans', '--shared', '--reason', 'Brand font']);
|
|
assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
|
|
{ rule: 'overused-font', value: 'open sans', reason: 'Brand font' },
|
|
]);
|
|
});
|
|
|
|
it('ignore-value --local writes private config and status reports local ignores', () => {
|
|
runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']);
|
|
runAdmin(['ignore-value', 'OVERUSED-FONT', '"Inter"', '--local', '--reason', 'Still intentional']);
|
|
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
|
|
const raw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8'));
|
|
assert.equal(raw.hook, undefined);
|
|
const local = raw.detector;
|
|
assert.equal(local.designSystem, undefined, 'local ignore should not override shared design-system state');
|
|
assert.equal(local.ignoreValues.length, 1);
|
|
assert.equal(local.ignoreValues[0].reason, 'Still intentional');
|
|
|
|
const status = runAdmin(['status']);
|
|
assert.match(status, /local file:\s+\.impeccable\/config\.local\.json/);
|
|
assert.match(status, /ignoreValues:\s+overused-font=inter/);
|
|
});
|
|
|
|
// detector.ignoreValues honours a `files` scope, which is the narrowest way to
|
|
// silence one noisy rule on one file. hook-admin could not write it, so the
|
|
// only reachable option was ignore-file, which silences every rule for that
|
|
// file forever.
|
|
it('ignore-value scopes a wildcard to files via --file', () => {
|
|
const out = runAdmin([
|
|
'ignore-value', 'design-system-font-size', '*',
|
|
'--file', 'src/overlay/widget.js',
|
|
'--reason', 'Widget builds its own type scale',
|
|
]);
|
|
assert.match(out, /scoped to src\/overlay\/widget\.js/);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(shared.ignoreValues, [{
|
|
rule: 'design-system-font-size',
|
|
value: '*',
|
|
files: ['src/overlay/widget.js'],
|
|
createdAt: shared.ignoreValues[0].createdAt,
|
|
reason: 'Widget builds its own type scale',
|
|
}]);
|
|
});
|
|
|
|
it('ignore-value accepts --file=, --files= and repeated --file', () => {
|
|
runAdmin(['ignore-value', 'side-tab', '*', '--file=a.css']);
|
|
runAdmin(['ignore-value', 'side-tab', '*', '--files=b.css']);
|
|
runAdmin(['ignore-value', 'low-contrast', '*', '--file', 'c.css', '--file', 'd.css']);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(
|
|
shared.ignoreValues.map(({ rule, files }) => ({ rule, files })),
|
|
[
|
|
{ rule: 'side-tab', files: ['a.css'] },
|
|
{ rule: 'side-tab', files: ['b.css'] },
|
|
{ rule: 'low-contrast', files: ['c.css', 'd.css'] },
|
|
],
|
|
'each distinct file scope is its own entry; a rule+value-only key overwrote them',
|
|
);
|
|
});
|
|
|
|
it('ignore-value refuses a wildcard with no file scope', () => {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'design-system-font-size', '*']),
|
|
/Wildcard value ignores must be scoped with --file/,
|
|
'a bare wildcard is ignore-rule\'s job, not a per-file waiver',
|
|
);
|
|
assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config');
|
|
});
|
|
|
|
it('ignore-value --file requires a glob', () => {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'side-tab', '*', '--file']),
|
|
/--file requires a glob/,
|
|
);
|
|
});
|
|
|
|
it('ignore-value rejects an unknown flag instead of folding it into the value', () => {
|
|
// `--shard` (a typo for --shared) used to store the value "inter --shard",
|
|
// which matches nothing, while reporting a successful suppression.
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shard']),
|
|
/Unknown ignore-value flag: --shard/,
|
|
);
|
|
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
|
|
});
|
|
|
|
// Every write runs the entries through normalizeIgnoreValueEntries. Emitting a
|
|
// different key order than the one on disk rewrote all untouched entries.
|
|
it('an unrelated edit leaves existing ignoreValues byte-identical', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
const seeded = {
|
|
detector: {
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [
|
|
{
|
|
rule: 'bounce-easing',
|
|
value: 'bounce-ball',
|
|
createdAt: '2026-06-15T04:15:03.164Z',
|
|
reason: 'Intentional',
|
|
},
|
|
{
|
|
rule: 'design-system-color',
|
|
value: '*',
|
|
files: ['site/styles/demo.css'],
|
|
createdAt: '2026-06-15T23:37:38.170Z',
|
|
reason: 'Deliberate off-system demo',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify(seeded, null, 2) + '\n');
|
|
const before = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector.ignoreValues;
|
|
|
|
runAdmin(['ignore-file', 'some/other/**']);
|
|
|
|
const after = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(after.ignoreFiles, ['some/other/**'], 'the intended change still lands');
|
|
assert.equal(
|
|
JSON.stringify(after.ignoreValues),
|
|
JSON.stringify(before),
|
|
'untouched ignoreValues must keep their exact key order, or every config diff churns',
|
|
);
|
|
});
|
|
|
|
// hook-lib.mjs (skill, ships into harness dirs) and cli/lib/impeccable-config.mjs
|
|
// (CLI + Pages functions) carry independent copies of this normalizer by
|
|
// necessity. They write the same file, so a key-order drift between them makes
|
|
// the config churn depending on which tool touched it last.
|
|
it('both config normalizers emit identical entries', () => {
|
|
const input = [
|
|
{ rule: 'BOUNCE-EASING', value: 'Bounce-Ball', reason: ' r ', createdAt: '2026-01-01T00:00:00.000Z' },
|
|
{ rule: 'design-system-color', value: '*', files: [' a.css ', 'b.css', 'a.css'], createdAt: '2026-02-02T00:00:00.000Z' },
|
|
{ rule: 'side-tab', value: '*', file: 'legacy.css' },
|
|
{ rule: '', value: 'dropped' },
|
|
];
|
|
assert.equal(
|
|
JSON.stringify(normalizeIgnoreValueEntries(input)),
|
|
JSON.stringify(normalizeIgnoreValueEntriesCli(input)),
|
|
'skill/scripts/hook-lib.mjs and cli/lib/impeccable-config.mjs must agree, key order included',
|
|
);
|
|
// And pin the canonical order itself, which is what the config on disk uses.
|
|
const full = { rule: 'side-tab', value: '*', files: ['a.css'], createdAt: '2026-01-01T00:00:00.000Z', reason: 'r' };
|
|
assert.deepEqual(
|
|
Object.keys(normalizeIgnoreValueEntries([full])[0]),
|
|
['rule', 'value', 'files', 'createdAt', 'reason'],
|
|
);
|
|
});
|
|
|
|
it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
// A recorded per-developer consent in the local file...
|
|
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined' } }));
|
|
runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']);
|
|
const localRaw = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8'));
|
|
assert.equal(localRaw.hook.consent, 'declined', 'consent must survive a local ignore-value edit');
|
|
assert.equal(localRaw.detector.ignoreValues.length, 1);
|
|
|
|
// ...and a shared quiet flag survives an on/off toggle.
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } }));
|
|
runAdmin(['off']);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
|
assert.equal(shared.enabled, false);
|
|
assert.equal(shared.quiet, true, 'quiet must survive an enable/disable toggle');
|
|
});
|
|
|
|
it('hooks on accepts declined consent and installs missing provider manifests', () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined', quiet: true } }));
|
|
for (const provider of ['.claude', '.agents', '.cursor', '.github']) {
|
|
fs.mkdirSync(path.join(cwd, provider, 'skills', 'impeccable', 'scripts'), { recursive: true });
|
|
}
|
|
fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
{ matcher: 'OtherTool', hooks: [{ type: 'command', command: 'node "./local-hook.mjs"' }] },
|
|
{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] },
|
|
],
|
|
},
|
|
}));
|
|
|
|
const out = runAdmin(['on']);
|
|
assert.match(out, /Recorded local hook consent/);
|
|
assert.match(out, /Installed or repaired hook manifests for: \.claude, \.agents, \.cursor, \.github/);
|
|
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
|
assert.equal(shared.enabled, true);
|
|
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
|
|
assert.equal(local.consent, 'accepted');
|
|
assert.equal(local.quiet, true, 'unrelated local hook fields survive consent repair');
|
|
|
|
const claude = fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8');
|
|
assert.match(claude, /local-hook\.mjs/);
|
|
assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 1);
|
|
|
|
const codex = fs.readFileSync(path.join(cwd, '.codex', 'hooks.json'), 'utf-8');
|
|
assert.match(codex, /\.agents\/skills\/impeccable\/scripts\/hook\.mjs/);
|
|
const cursor = fs.readFileSync(path.join(cwd, '.cursor', 'hooks.json'), 'utf-8');
|
|
assert.match(cursor, /\.cursor\/skills\/impeccable\/scripts\/hook-before-edit\.mjs/);
|
|
const github = JSON.parse(fs.readFileSync(path.join(cwd, '.github', 'hooks', 'impeccable.json'), 'utf-8'));
|
|
assert.equal(github.hooks.postToolUse[0].matcher, 'edit|create|apply_patch');
|
|
assert.match(github.hooks.postToolUse[0].bash, /\.github\/skills\/impeccable\/scripts\/hook\.mjs/);
|
|
});
|
|
|
|
it('ignore-rule overused-font requires explicit broad suppression', () => {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-rule', 'overused-font']),
|
|
/ignore-value overused-font <font>|--all-values/,
|
|
);
|
|
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
|
|
});
|
|
|
|
it('ignore-rule overused-font --all-values writes a whole-rule suppression', () => {
|
|
const out = runAdmin(['ignore-rule', 'overused-font', '--all-values', '--reason', 'User asked to ignore overused fonts generally']);
|
|
assert.match(out, /Added "overused-font" to detector\.ignoreRules/);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(shared.ignoreRules, ['overused-font']);
|
|
assert.deepEqual(shared.ignoreValues, []);
|
|
});
|
|
|
|
it('ignore-rule still allows non-value rules without --all-values', () => {
|
|
runAdmin(['ignore-rule', 'side-tab']);
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(shared.ignoreRules, ['side-tab']);
|
|
});
|
|
|
|
it('ignore-value rejects conflicting scope flags', () => {
|
|
assert.throws(
|
|
() => runAdmin(['ignore-value', 'overused-font', 'Inter', '--shared', '--local']),
|
|
/Pass only one scope flag/,
|
|
);
|
|
});
|
|
|
|
it('ignore-file writes shared config that suppresses a later hook run', async () => {
|
|
const file = path.join(cwd, 'src/ConfirmedCard.html');
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, '<div style="border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px;">Card</div>');
|
|
|
|
runAdmin(['ignore-file', 'src/ConfirmedCard.html']);
|
|
|
|
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
|
assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']);
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'confirmed-ignore-file',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: file },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: fakeDetector([finding('side-tab', 1)]),
|
|
});
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'config-ignore-file');
|
|
});
|
|
});
|
|
|
|
describe('renderTemplate()', () => {
|
|
it('starts with the versioned envelope and caps to maxFindings', () => {
|
|
const findings = Array.from({ length: 12 }, (_, i) =>
|
|
finding('side-tab', i + 1, { name: `R${i}`, description: 'd' }));
|
|
const text = renderTemplate(findings, '/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' });
|
|
assert.ok(text.startsWith(`${ENVELOPE_PREFIX} Design hook findings requiring review in Card.tsx (12 issue(s)):`));
|
|
assert.match(text, /\.\.\. and 7 more \(see \/impeccable audit\)\./);
|
|
// Exactly 5 finding lines.
|
|
const lines = text.split('\n').filter((l) => l.startsWith('- '));
|
|
assert.equal(lines.length, 5);
|
|
assert.ok(text.length <= DEFAULT_CONFIG.limits.maxChars);
|
|
});
|
|
|
|
it('emits a directive footer (imperative + judgment clause + confirmed ignore guidance)', () => {
|
|
// Steers the model: imperative "handle", explicit context judgment
|
|
// before editing, and "acknowledge" so the user sees the resolution
|
|
// in the chat reply. See `directiveFooter()` in hook-lib.mjs for
|
|
// the rationale.
|
|
const text = renderTemplate(
|
|
[finding('side-tab', 1, { name: 'X' })],
|
|
'/x/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' }
|
|
);
|
|
assert.match(text, /Handle these before finalizing/);
|
|
assert.match(text, /fix findings that are real design problems/);
|
|
assert.match(text, /classify contextually intentional findings as false positives/);
|
|
assert.match(text, /Use context judgment before editing/);
|
|
assert.match(text, /not automatically a defect/);
|
|
assert.match(text, /literal or domain-appropriate motion/);
|
|
assert.match(text, /Do not change intentional design just to satisfy the hook/);
|
|
assert.match(text, /Suppress a finding only after the user explicitly confirms it is intentional/);
|
|
assert.match(text, /do not silence a real finding with an inline ignore comment/);
|
|
assert.match(text, /inline `impeccable-disable <rule>` comment only when the waiver must travel with a file/);
|
|
assert.match(text, /ignore-value \.\.\. --shared/);
|
|
assert.match(text, /ignore-rule overused-font --all-values/);
|
|
assert.match(text, /\/impeccable hooks ignore-file Card\.tsx/);
|
|
assert.match(text, /ignore-rule <id>/);
|
|
assert.match(text, /\/impeccable audit/);
|
|
});
|
|
|
|
it('shows the exact value-specific command for overused-font findings', () => {
|
|
const text = renderTemplate(
|
|
[finding('overused-font', 1, { name: 'Overused font', snippet: 'body { font-family: "Roboto", sans-serif; }' })],
|
|
'/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' }
|
|
);
|
|
assert.match(text, /\/impeccable hooks ignore-value overused-font Roboto --shared/);
|
|
assert.match(text, /ignore-rule overused-font --all-values/);
|
|
});
|
|
|
|
it('shows the exact value-specific command for bounce-easing findings', () => {
|
|
const text = renderTemplate(
|
|
[finding('bounce-easing', 1, { name: 'Bounce or elastic easing', snippet: 'animation: bounce-ball' })],
|
|
'/x/main.css', DEFAULT_CONFIG, { cwd: '/x' }
|
|
);
|
|
assert.match(text, /\/impeccable hooks ignore-value bounce-easing bounce-ball --shared/);
|
|
});
|
|
|
|
it('drops the L<line> prefix when line is 0', () => {
|
|
const text = renderTemplate(
|
|
[finding('side-tab', 0, { name: 'X' })],
|
|
'/x/a.tsx', DEFAULT_CONFIG, { cwd: '/x' }
|
|
);
|
|
assert.match(text, /^- \[side-tab\]/m);
|
|
});
|
|
|
|
it('does not suggest ignore-value for rules that cannot be value-filtered', () => {
|
|
const text = renderTemplate(
|
|
[finding('side-tab', 1, {
|
|
name: 'Side tab',
|
|
ignoreValue: 'Inter',
|
|
})],
|
|
'/x/a.tsx', DEFAULT_CONFIG, { cwd: '/x' }
|
|
);
|
|
assert.doesNotMatch(text, /\/impeccable hooks ignore-value side-tab Inter/);
|
|
});
|
|
|
|
it('clamps oversize output to maxChars', () => {
|
|
const huge = Array.from({ length: 5 }, (_, i) =>
|
|
finding('side-tab', i + 1, { name: 'X', description: 'y'.repeat(2000) }));
|
|
const text = renderTemplate(huge, '/x/a.tsx',
|
|
{ ...DEFAULT_CONFIG, limits: { maxFindings: 5, maxChars: 500 } },
|
|
{ cwd: '/x' });
|
|
assert.ok(text.length <= 500);
|
|
});
|
|
});
|
|
|
|
describe('writeAuditLog()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('appends NDJSON when IMPECCABLE_HOOK_LOG is set', () => {
|
|
const log = path.join(cwd, 'audit.ndjson');
|
|
writeAuditLog({ IMPECCABLE_HOOK_LOG: log }, { event: 'PostToolUse', emitted: true });
|
|
writeAuditLog({ IMPECCABLE_HOOK_LOG: log }, { event: 'PostToolUse', emitted: false });
|
|
const body = fs.readFileSync(log, 'utf-8');
|
|
assert.equal(body.trim().split('\n').length, 2);
|
|
for (const line of body.trim().split('\n')) {
|
|
const obj = JSON.parse(line);
|
|
assert.ok(obj.ts && obj.event === 'PostToolUse');
|
|
}
|
|
});
|
|
|
|
it('is a no-op when IMPECCABLE_HOOK_LOG is unset', () => {
|
|
assert.equal(writeAuditLog({}, { event: 'x' }, cwd), false);
|
|
});
|
|
|
|
it('falls back to the unified config hook.auditLog when the env var is unset', () => {
|
|
const log = path.join(cwd, 'from-config.ndjson');
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: log } }));
|
|
assert.equal(writeAuditLog({}, { event: 'PostToolUse' }, cwd), true);
|
|
assert.equal(fs.readFileSync(log, 'utf-8').trim().split('\n').length, 1);
|
|
});
|
|
|
|
it('prefers the env var over config hook.auditLog', () => {
|
|
const envLog = path.join(cwd, 'from-env.ndjson');
|
|
const cfgLog = path.join(cwd, 'from-config.ndjson');
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: cfgLog } }));
|
|
writeAuditLog({ IMPECCABLE_HOOK_LOG: envLog }, { event: 'PostToolUse' }, cwd);
|
|
assert.equal(fs.existsSync(envLog), true);
|
|
assert.equal(fs.existsSync(cfgLog), false);
|
|
});
|
|
|
|
it('resolves config auditLog from entry.cwd (the event project root), not the fallback cwd', () => {
|
|
const projectDir = path.join(cwd, 'project');
|
|
const log = path.join(cwd, 'event-cwd.ndjson');
|
|
fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'),
|
|
JSON.stringify({ hook: { auditLog: log } }));
|
|
// The fallback cwd (root) has no config; entry.cwd points at the project.
|
|
assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true);
|
|
assert.equal(fs.existsSync(log), true);
|
|
});
|
|
|
|
it('resolves a relative auditLog path against the project root, not the process cwd', () => {
|
|
const projectDir = path.join(cwd, 'project');
|
|
fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'),
|
|
JSON.stringify({ hook: { auditLog: 'logs/hook.ndjson' } }));
|
|
assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true);
|
|
// Written under the project root, not the fallback cwd.
|
|
assert.equal(fs.existsSync(path.join(projectDir, 'logs', 'hook.ndjson')), true);
|
|
assert.equal(fs.existsSync(path.join(cwd, 'logs', 'hook.ndjson')), false);
|
|
});
|
|
});
|
|
|
|
describe('payload()', () => {
|
|
it('produces hookSpecificOutput for Claude/Codex', () => {
|
|
const obj = JSON.parse(payload('hello'));
|
|
assert.equal(obj.hookSpecificOutput.hookEventName, 'PostToolUse');
|
|
assert.equal(obj.hookSpecificOutput.additionalContext, 'hello');
|
|
});
|
|
|
|
it('produces additional_context for Cursor', () => {
|
|
const obj = JSON.parse(payload('hello', 'PostToolUse', 'cursor'));
|
|
assert.equal(obj.additional_context, 'hello');
|
|
assert.equal(obj.hookSpecificOutput, undefined);
|
|
});
|
|
|
|
it('produces top-level additionalContext for GitHub Copilot', () => {
|
|
const obj = JSON.parse(payload('hello', 'PostToolUse', 'github'));
|
|
assert.equal(obj.additionalContext, 'hello');
|
|
assert.equal(obj.hookSpecificOutput, undefined);
|
|
assert.equal(obj.additional_context, undefined);
|
|
});
|
|
});
|
|
|
|
describe('runHook()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function eventFor(file, sessionId = 'sid-1') {
|
|
return {
|
|
session_id: sessionId,
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: file },
|
|
};
|
|
}
|
|
|
|
function writeFixture(rel, body) {
|
|
const abs = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
function writeDesignMd() {
|
|
fs.writeFileSync(path.join(cwd, 'DESIGN.md'), `---
|
|
typography:
|
|
body:
|
|
fontFamily: "IBM Plex Sans, Arial, sans-serif"
|
|
colors:
|
|
ink: "#241f1a"
|
|
rounded:
|
|
md: "8px"
|
|
---
|
|
|
|
# Design System
|
|
`);
|
|
}
|
|
|
|
function designFinding(value = 'Poppins') {
|
|
return {
|
|
...finding('design-system-font', 1, {
|
|
name: 'Font outside DESIGN.md',
|
|
description: 'A font is used that is not declared in DESIGN.md typography.',
|
|
snippet: `font-family: "${value}", sans-serif;`,
|
|
}),
|
|
ignoreValue: value,
|
|
};
|
|
}
|
|
|
|
function designAwareDetector({ stale = false } = {}) {
|
|
return {
|
|
loadDesignSystemForCwd: (projectCwd) => (
|
|
fs.existsSync(path.join(projectCwd, 'DESIGN.md'))
|
|
? { present: true, hasFonts: true, mdNewerThanJson: stale }
|
|
: null
|
|
),
|
|
detectText: (_content, _filePath, options = {}) => (
|
|
options.designSystem ? [designFinding()] : []
|
|
),
|
|
detectHtml: (_filePath, options = {}) => (
|
|
options.designSystem ? [designFinding()] : []
|
|
),
|
|
};
|
|
}
|
|
|
|
it('emits findings on first fire, then a pending-ack on subsequent dedup hits', async () => {
|
|
// The "no silent fires" policy turns the previously-silent dedup hit
|
|
// into a pending re-nudge that keeps the unresolved finding in the
|
|
// model's context across turns. Findings emission still wins outright
|
|
// over the nudge (`renderTemplate` text), so r1 is unchanged from
|
|
// before. r2 is what changed: silent → pending ack.
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
|
|
|
const r1 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r1.exitCode, 0);
|
|
assert.ok(r1.stdout.includes(ENVELOPE_PREFIX));
|
|
assert.match(r1.stdout, /Design hook findings requiring review/);
|
|
assert.equal(r1.audit.emitted, true);
|
|
|
|
const r2 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r2.exitCode, 0);
|
|
assert.ok(r2.stdout.includes(ENVELOPE_PREFIX));
|
|
assert.match(r2.stdout, /Still has 1 finding\(s\) flagged earlier this session/);
|
|
assert.match(r2.stdout, /side-tab:1/);
|
|
assert.equal(r2.audit.emitted, true);
|
|
assert.equal(r2.audit.kind, 'pending');
|
|
});
|
|
|
|
it('handles a GitHub Copilot edit event end-to-end and emits additionalContext', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
|
const githubEvent = {
|
|
sessionId: 'gh-1',
|
|
cwd,
|
|
toolName: 'edit',
|
|
toolArgs: JSON.stringify({ path: file, old_str: 'a', new_str: 'b' }),
|
|
};
|
|
|
|
const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.audit.harness, 'github');
|
|
assert.equal(r.audit.emitted, true);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.ok(out.additionalContext.includes(ENVELOPE_PREFIX));
|
|
assert.match(out.additionalContext, /Design hook findings requiring review/);
|
|
assert.equal(out.hookSpecificOutput, undefined);
|
|
});
|
|
|
|
it('handles a GitHub Copilot apply_patch event end-to-end (interactive/cloud path)', async () => {
|
|
// The real bug the live test caught: interactive Copilot edits via
|
|
// apply_patch (raw patch string in toolArgs), which the matcher and runtime
|
|
// must both cover — not just the edit/create tools seen in `copilot -p`.
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
|
const patch = [
|
|
'*** Begin Patch',
|
|
`*** Update File: ${file}`,
|
|
'+noop',
|
|
'*** End Patch',
|
|
].join('\n');
|
|
const githubEvent = { sessionId: 'gh-ap', cwd, toolName: 'apply_patch', toolArgs: patch };
|
|
|
|
const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.audit.harness, 'github');
|
|
assert.equal(r.audit.tool, 'apply_patch');
|
|
assert.equal(r.audit.emitted, true);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.ok(out.additionalContext.includes(ENVELOPE_PREFIX));
|
|
assert.match(out.additionalContext, /Design hook findings requiring review/);
|
|
});
|
|
|
|
it('emits a clean ack when the file has zero findings', async () => {
|
|
// No-silent-fires policy: a successful scan that finds nothing still
|
|
// emits a short positive nudge so the hook stays a conversational
|
|
// presence on every fire.
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([]); // no findings
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.ok(r.stdout.includes(ENVELOPE_PREFIX));
|
|
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.match(r.stdout, /keep following the project design system and the impeccable skill guidance/);
|
|
assert.equal(r.audit.emitted, true);
|
|
assert.equal(r.audit.kind, 'clean');
|
|
});
|
|
|
|
it('does not emit clean acks for plain .ts files', async () => {
|
|
const file = writeFixture('src/server.ts', 'export const value = 1;');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {},
|
|
cwd,
|
|
detector: fakeDetector([]),
|
|
});
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'non-ui-ack');
|
|
});
|
|
|
|
it('still emits findings for plain .ts files', async () => {
|
|
const file = writeFixture('src/styles.ts', 'export const css = "border-left: 4px solid #7c3aed";');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {},
|
|
cwd,
|
|
detector: fakeDetector([finding('side-tab', 1)]),
|
|
});
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.match(r.stdout, /side-tab/);
|
|
});
|
|
|
|
it('does not emit pending acks for plain .js files', async () => {
|
|
const file = writeFixture('src/build.js', 'export const value = 1;');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.match(first.stdout, /Design hook findings requiring review/);
|
|
|
|
const second = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(second.stdout, '');
|
|
assert.equal(second.audit.skipped, 'non-ui-ack');
|
|
});
|
|
|
|
it('IMPECCABLE_HOOK_QUIET=1 suppresses clean and pending acks, keeps findings emission', async () => {
|
|
// The opt-out kill switch for users who want the old silent-on-clean
|
|
// behavior. Findings still emit because those are real signals; the
|
|
// QUIET switch only quiets the conversational acks.
|
|
const fileA = writeFixture('src/A.tsx', 'noop');
|
|
const fileB = writeFixture('src/B.tsx', 'noop');
|
|
|
|
// Clean file: silent under QUIET.
|
|
const detClean = fakeDetector([]);
|
|
const rClean = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(fileA)),
|
|
env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detClean,
|
|
});
|
|
assert.equal(rClean.stdout, '');
|
|
assert.equal(rClean.audit.emitted, false);
|
|
assert.equal(rClean.audit.quiet, true);
|
|
|
|
// Findings file: still emits.
|
|
const detFindings = fakeDetector([finding('side-tab', 1)]);
|
|
const rFindings = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(fileB)),
|
|
env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detFindings,
|
|
});
|
|
assert.ok(rFindings.stdout.includes(ENVELOPE_PREFIX));
|
|
assert.match(rFindings.stdout, /Design hook findings requiring review/);
|
|
assert.equal(rFindings.audit.emitted, true);
|
|
});
|
|
|
|
it('config quiet:true suppresses the clean ack like the env switch', async () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } }));
|
|
const file = writeFixture('src/Quiet.tsx', 'noop');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: fakeDetector([]),
|
|
});
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.quiet, true);
|
|
});
|
|
|
|
it('re-entrancy guard short-circuits when IMPECCABLE_HOOK_DEPTH is set', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: { IMPECCABLE_HOOK_DEPTH: '1' },
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.reentrant, true);
|
|
});
|
|
|
|
it('re-entrancy guard treats numeric CLAUDE_HOOK_DEPTH values as active', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: { CLAUDE_HOOK_DEPTH: '2' },
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.reentrant, true);
|
|
});
|
|
|
|
it('IMPECCABLE_HOOK_DISABLED kill switch', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
for (const v of ['1', 'true', 'yes', 'on', 'TRUE']) {
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: { IMPECCABLE_HOOK_DISABLED: v },
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.equal(r.stdout, '', `expected silent for value ${v}`);
|
|
assert.equal(r.audit.skipped, 'env-disabled');
|
|
}
|
|
});
|
|
|
|
it('config-disabled silences cleanly', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false } }));
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'config-disabled');
|
|
});
|
|
|
|
it('skips the scan when PRODUCT.md declares a native platform', async () => {
|
|
// The web rule engine has no business flagging React Native screens; the
|
|
// hook watches .tsx/.ts/.js, which is exactly what a native project is
|
|
// made of, so the platform field gates the whole scan.
|
|
for (const platform of ['ios', 'android', 'adaptive']) {
|
|
writeFixture('PRODUCT.md', `# App\n\n## Register\n\nproduct\n\n## Platform\n\n${platform}\n`);
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, `native-${platform}`)), env: {}, cwd, detector: det });
|
|
assert.equal(r.stdout, '', `expected silence for platform ${platform}`);
|
|
assert.equal(r.audit.skipped, 'native-platform');
|
|
assert.equal(r.audit.platform, platform);
|
|
}
|
|
});
|
|
|
|
it('still scans when PRODUCT.md declares web (or has no platform field)', async () => {
|
|
writeFixture('PRODUCT.md', '# App\n\n## Register\n\nproduct\n\n## Platform\n\nweb\n');
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'web-platform')), env: {}, cwd, detector: det });
|
|
assert.match(r.stdout, /Side-tab/);
|
|
});
|
|
|
|
it('only unlocks design-system detector findings when DESIGN.md exists', async () => {
|
|
const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }');
|
|
const det = designAwareDetector();
|
|
|
|
const withoutDesign = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file, 'design-system-off')),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.match(withoutDesign.stdout, /No deterministic design-quality issues found/);
|
|
assert.doesNotMatch(withoutDesign.stdout, /design-system-font/);
|
|
|
|
writeDesignMd();
|
|
const withDesign = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file, 'design-system-on')),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.match(withDesign.stdout, /Design hook findings requiring review/);
|
|
assert.match(withDesign.stdout, /design-system-font/);
|
|
assert.match(withDesign.stdout, /ignore-value design-system-font Poppins --shared/);
|
|
});
|
|
|
|
it('respects detector.designSystem.enabled=false', async () => {
|
|
writeDesignMd();
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
detector: { designSystem: { enabled: false } },
|
|
}));
|
|
const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }');
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file, 'design-system-disabled')),
|
|
env: {},
|
|
cwd,
|
|
detector: designAwareDetector(),
|
|
});
|
|
|
|
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.doesNotMatch(r.stdout, /design-system-font/);
|
|
});
|
|
|
|
it('suppresses design-system findings through ignore-value', async () => {
|
|
writeDesignMd();
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
detector: {
|
|
ignoreValues: [
|
|
{ rule: 'design-system-font', value: 'Poppins' },
|
|
],
|
|
},
|
|
}));
|
|
const file = writeFixture('src/Card.tsx', '.card { font-family: "Poppins", sans-serif; }');
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file, 'design-system-ignore-value')),
|
|
env: {},
|
|
cwd,
|
|
detector: designAwareDetector(),
|
|
});
|
|
|
|
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.doesNotMatch(r.stdout, /design-system-font/);
|
|
});
|
|
|
|
it('adds a non-blocking note when DESIGN.md is newer than the sidecar', async () => {
|
|
writeDesignMd();
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = {
|
|
loadDesignSystemForCwd: () => ({ present: true, mdNewerThanJson: true }),
|
|
detectText: () => [],
|
|
detectHtml: () => [],
|
|
};
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file, 'design-system-stale-sidecar')),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
|
|
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.match(r.stdout, /DESIGN\.md is newer than \.impeccable\/design\.json/);
|
|
assert.match(r.stdout, /\/impeccable document/);
|
|
});
|
|
|
|
it('rejects sensitive paths before reading file content', async () => {
|
|
const file = path.join(cwd, '.env');
|
|
fs.writeFileSync(file, 'SECRET=42');
|
|
const det = { detectText: () => { throw new Error('should not run'); } };
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({ ...eventFor(file), tool_input: { file_path: file } }),
|
|
env: {}, cwd, detector: det,
|
|
});
|
|
assert.equal(r.audit.skipped, 'sensitive');
|
|
});
|
|
|
|
it('rejects generated paths', async () => {
|
|
const file = writeFixture('dist/Card.tsx', 'noop');
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd });
|
|
assert.equal(r.audit.skipped, 'generated');
|
|
});
|
|
|
|
it('rejects path traversal in file_path', async () => {
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({ ...eventFor('/foo/../etc/passwd') }),
|
|
env: {}, cwd,
|
|
});
|
|
assert.equal(r.audit.skipped, 'sensitive');
|
|
});
|
|
|
|
it('rejects extensions outside the allowlist', async () => {
|
|
const file = writeFixture('docs/README.md', 'noop');
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd });
|
|
assert.equal(r.audit.skipped, 'extension');
|
|
});
|
|
|
|
it('config ignoreFiles glob suppresses', async () => {
|
|
const file = writeFixture('src/legacy/Foo.tsx', 'noop');
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
|
detector: { ignoreFiles: ['src/legacy/**'] },
|
|
}));
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'config-ignore-file');
|
|
});
|
|
|
|
it('emits one-shot suppression notice on the 7th edit and silences after', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
let last;
|
|
for (let i = 0; i < 8; i++) {
|
|
// Use a different line each time so we don't dedup; we want to hit
|
|
// edit-count, not the dedup cache.
|
|
const f = [finding('side-tab', i + 1)];
|
|
last = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: { detectText: () => f, detectHtml: () => f },
|
|
});
|
|
}
|
|
// The 7th call (index 6) crosses the threshold; the 8th (index 7) is silent.
|
|
assert.equal(last.stdout, '', '8th edit should be silent');
|
|
assert.equal(last.audit.suppressed, true);
|
|
});
|
|
|
|
it('emits suppressionNotice text on the threshold-crossing edit', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
let r;
|
|
for (let i = 0; i < 7; i++) {
|
|
const f = [finding('side-tab', i + 1)];
|
|
r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: { detectText: () => f, detectHtml: () => f },
|
|
});
|
|
}
|
|
assert.ok(r.stdout.includes('Suppressing further design hints'));
|
|
assert.match(r.stdout, /More than 6 edits in this session reached/);
|
|
assert.match(r.stdout, /Run \/impeccable audit to revisit/);
|
|
});
|
|
|
|
it('handles MultiEdit and apply_patch payload shapes (file_path field)', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
for (const event of [
|
|
{ ...eventFor(file), tool_name: 'MultiEdit', tool_input: { file_path: file, edits: [] } },
|
|
{ ...eventFor(file), tool_name: 'apply_patch', tool_input: { file_path: file, command: '...' } },
|
|
]) {
|
|
const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
// First call emits; second is dedup-silent. Reset by using fresh session.
|
|
assert.ok(r.stdout.length >= 0);
|
|
}
|
|
});
|
|
|
|
it('parses Codex apply_patch command when file_path is omitted', async () => {
|
|
writeFixture('src/Card.tsx', '<div className="border-l-4" />');
|
|
const event = {
|
|
session_id: 'sid-codex-ap',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'apply_patch',
|
|
tool_input: {
|
|
command: '*** Begin Patch\n*** Update File: src/Card.tsx\n*** End Patch',
|
|
},
|
|
};
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
});
|
|
|
|
it('detector throw is swallowed; never breaks turn', async () => {
|
|
const file = writeFixture('src/Card.tsx', 'noop');
|
|
const det = { detectText: () => { throw new Error('boom'); } };
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
it('awaits the real async HTML detector before deciding a page is clean', async () => {
|
|
const file = writeFixture('index.html', [
|
|
'<!doctype html>',
|
|
'<html><body>',
|
|
'<div style="border-left: 4px solid #6366f1; border-radius: 8px; padding: 16px;">Feature</div>',
|
|
'</body></html>',
|
|
].join('\n'));
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {},
|
|
cwd,
|
|
detector: { detectHtml, detectText },
|
|
});
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.doesNotMatch(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.ok(r.audit.findings > 0);
|
|
});
|
|
|
|
it('honors an inline impeccable-disable comment so the hook scans the file clean', async () => {
|
|
// The hook runs the same engine as `npx impeccable detect`, so an in-file
|
|
// waiver suppresses hook findings exactly like a config ignore would.
|
|
const flagged = writeFixture('src/Flagged.tsx', 'const css = "font-family: Inter";');
|
|
const flaggedRun = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(flagged)), env: {}, cwd, detector: { detectHtml, detectText },
|
|
});
|
|
assert.match(flaggedRun.stdout, /Design hook findings requiring review/);
|
|
assert.ok(flaggedRun.audit.findings > 0);
|
|
|
|
const waived = writeFixture('src/Waived.tsx',
|
|
'const css = "font-family: Inter"; // impeccable-disable-line overused-font');
|
|
const waivedRun = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(waived)), env: {}, cwd, detector: { detectHtml, detectText },
|
|
});
|
|
assert.match(waivedRun.stdout, /No deterministic design-quality issues found/);
|
|
assert.equal(waivedRun.audit.findings, 0);
|
|
});
|
|
|
|
it('malformed stdin → silent skip', async () => {
|
|
const r = await runHook({ stdinJson: '{not json', env: {}, cwd });
|
|
assert.equal(r.audit.skipped, 'stdin-malformed');
|
|
});
|
|
|
|
it('missing file → silent skip (race protection)', async () => {
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(path.join(cwd, 'src/Vanished.tsx'))),
|
|
env: {}, cwd,
|
|
});
|
|
assert.equal(r.audit.skipped, 'file-missing');
|
|
});
|
|
});
|
|
|
|
describe('runHook() — cache write gating (issues #344, #305)', () => {
|
|
// The hook must be a no-op on disk in projects that never earned an
|
|
// `.impeccable/` footprint: skipped files never dirty the cache, and a
|
|
// dirty cache is only persisted when there are fresh findings or the
|
|
// project already opted in (an `.impeccable/` dir exists).
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function eventFor(file, sessionId = 'gate-sid') {
|
|
return {
|
|
session_id: sessionId,
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: file },
|
|
};
|
|
}
|
|
|
|
function write(rel, body, base = cwd) {
|
|
const abs = path.join(base, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
it('non-UI edit (.md) does not create .impeccable/', async () => {
|
|
const file = write('notes/todo.md', '# notes');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: fakeDetector([finding('side-tab', 1)]),
|
|
});
|
|
assert.equal(r.audit.skipped, 'extension');
|
|
assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist');
|
|
});
|
|
|
|
it('clean UI edit in a project with no footprint does not create .impeccable/, still acks', async () => {
|
|
const file = write('src/Card.tsx', 'noop');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: fakeDetector([]),
|
|
});
|
|
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
|
assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist');
|
|
});
|
|
|
|
it('detector-missing path does not create .impeccable/', async () => {
|
|
const file = write('src/Card.tsx', 'noop');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: {},
|
|
});
|
|
assert.equal(r.audit.skipped, 'detector-missing');
|
|
assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), '.impeccable should not exist');
|
|
});
|
|
|
|
it('fresh findings create the cache, and dedup works on the next run', async () => {
|
|
const file = write('src/Card.tsx', 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.match(first.stdout, /Design hook findings requiring review/);
|
|
assert.ok(fs.existsSync(path.join(cwd, '.impeccable', 'hook.cache.json')), 'cache should exist');
|
|
|
|
const second = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.doesNotMatch(second.stdout, /Design hook findings requiring review/);
|
|
assert.match(second.stdout, /flagged earlier this session/);
|
|
});
|
|
|
|
it('clean UI edit in an opted-in project (existing .impeccable/) still persists editCount', async () => {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
const file = write('src/Card.tsx', 'noop');
|
|
await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: fakeDetector([]) });
|
|
|
|
const cache = readCache(cwd);
|
|
assert.equal(cache.sessions['gate-sid'].files[file].editCount, 1);
|
|
});
|
|
|
|
it('umbrella launch keys the cache to the edited file\'s project root', async () => {
|
|
// cwd is the umbrella: no .git / package.json / .impeccable of its own.
|
|
write('app/package.json', '{"name":"child"}');
|
|
const file = write('app/src/Card.tsx', 'noop');
|
|
const child = path.join(cwd, 'app');
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify(eventFor(file)),
|
|
env: {}, cwd, detector: fakeDetector([finding('side-tab', 1)]),
|
|
});
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.equal(r.audit.cwd, child);
|
|
assert.ok(fs.existsSync(path.join(child, '.impeccable', 'hook.cache.json')), 'cache should land in the child project');
|
|
assert.ok(!fs.existsSync(path.join(cwd, '.impeccable')), 'umbrella root should stay clean');
|
|
});
|
|
});
|
|
|
|
describe('runHook() — oversized files', () => {
|
|
let cwd;
|
|
beforeEach(() => {
|
|
cwd = mkTmp();
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
});
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
const event = (file) => JSON.stringify({
|
|
session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit', tool_input: { file_path: file },
|
|
});
|
|
|
|
it('skips a file past the size ceiling, since a huge single file is a bundle', async () => {
|
|
const file = path.join(cwd, 'bundle.js');
|
|
fs.writeFileSync(file, `/* ${'x'.repeat(200 * 1024)} */`);
|
|
const r = await runHook({
|
|
stdinJson: event(file), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.ok(!r.audit.emitted);
|
|
assert.equal(r.audit.skipped, 'too-large');
|
|
});
|
|
|
|
it('still scans a large but plausibly authored stylesheet', async () => {
|
|
const file = path.join(cwd, 'main.css');
|
|
fs.writeFileSync(file, `/* ${'x'.repeat(90 * 1024)} */`);
|
|
const r = await runHook({
|
|
stdinJson: event(file), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.equal(r.audit.emitted, true);
|
|
});
|
|
|
|
// `bytes` describes the file that was skipped. It must never ride along on an
|
|
// audit entry whose `file` is something else, in either scan order, and must
|
|
// survive the early-continue paths that sit above the size check.
|
|
function patchEvent(...files) {
|
|
return JSON.stringify({
|
|
session_id: `sid-${files.length}-${files[0]}`, cwd,
|
|
hook_event_name: 'PostToolUse', tool_name: 'apply_patch',
|
|
tool_input: {
|
|
command: `*** Begin Patch\n${files.map(f => `*** Update File: ${f}`).join('\n')}\n*** End Patch`,
|
|
},
|
|
});
|
|
}
|
|
|
|
it('does not leak a skipped file\'s byte count when the bundle is scanned first', async () => {
|
|
const big = path.join(cwd, 'bundle.js');
|
|
const small = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`);
|
|
fs.writeFileSync(small, 'noop');
|
|
const r = await runHook({
|
|
stdinJson: patchEvent(big, small), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.match(r.stdout, /a\.css/);
|
|
assert.equal(r.audit.bytes, undefined, 'bytes belongs to the skipped file, not this one');
|
|
});
|
|
|
|
it('does not leak a skipped file\'s byte count when the bundle is scanned last', async () => {
|
|
const small = path.join(cwd, 'a.css');
|
|
const big = path.join(cwd, 'bundle.js');
|
|
fs.writeFileSync(small, 'noop');
|
|
fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`);
|
|
const r = await runHook({
|
|
stdinJson: patchEvent(small, big), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.match(r.stdout, /a\.css/);
|
|
assert.equal(r.audit.bytes, undefined, 'the emitted file is not the oversized one');
|
|
});
|
|
|
|
it('does not leak a byte count past an early-continue target', async () => {
|
|
// `generated` is checked before the size gate, so a later generated target
|
|
// returns without ever reaching the point where bytes would be cleared.
|
|
const big = path.join(cwd, 'bundle.js');
|
|
const gen = path.join(cwd, 'dist', 'Card.tsx');
|
|
fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`);
|
|
fs.mkdirSync(path.dirname(gen), { recursive: true });
|
|
fs.writeFileSync(gen, 'noop');
|
|
const r = await runHook({
|
|
stdinJson: patchEvent(big, gen), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.ok(!r.audit.emitted);
|
|
assert.equal(r.audit.bytes, undefined, 'bytes must not describe a different file');
|
|
});
|
|
|
|
it('still records the byte count when the oversized file is the outcome', async () => {
|
|
const big = path.join(cwd, 'bundle.js');
|
|
fs.writeFileSync(big, `/* ${'x'.repeat(200 * 1024)} */`);
|
|
const r = await runHook({
|
|
stdinJson: patchEvent(big), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.equal(r.audit.skipped, 'too-large');
|
|
assert.ok(r.audit.bytes > 200 * 1024, 'the skip reason should still carry its size');
|
|
});
|
|
|
|
it('honors a configured limits.maxFileBytes', async () => {
|
|
fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({
|
|
hook: { limits: { maxFileBytes: 1024 } },
|
|
}));
|
|
const file = path.join(cwd, 'small.css');
|
|
fs.writeFileSync(file, `/* ${'x'.repeat(4096)} */`);
|
|
const r = await runHook({
|
|
stdinJson: event(file), env: {}, cwd,
|
|
detector: fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]),
|
|
});
|
|
assert.equal(r.audit.skipped, 'too-large');
|
|
});
|
|
});
|
|
|
|
describe('runHook() — the session cache tracks the current scan', () => {
|
|
let cwd;
|
|
beforeEach(() => {
|
|
cwd = mkTmp();
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
});
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function eventFor(file, sessionId = 'sid-1') {
|
|
return {
|
|
session_id: sessionId,
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: file },
|
|
};
|
|
}
|
|
|
|
// A detector whose findings change between runs, so the cache can be
|
|
// observed as the file is progressively fixed.
|
|
function mutableDetector(initial = []) {
|
|
let current = initial;
|
|
return {
|
|
set(next) { current = next; },
|
|
detectText: () => current.slice(),
|
|
detectHtml: () => current.slice(),
|
|
};
|
|
}
|
|
|
|
function fontFinding(line, value) {
|
|
return { ...finding('overused-font', line, { name: 'Overused font' }), ignoreValue: value };
|
|
}
|
|
|
|
const run = (file, det) => runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
|
|
it('counts the current scan in the pending ack, not the session history', async () => {
|
|
const file = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(file, 'noop');
|
|
const det = mutableDetector([
|
|
fontFinding(1, 'inter'), fontFinding(2, 'roboto'), fontFinding(3, 'geist'),
|
|
]);
|
|
|
|
const r1 = await run(file, det);
|
|
assert.match(r1.stdout, /\(3 issue\(s\)\)/);
|
|
|
|
// Fix two of the three. The pending ack must not keep naming them.
|
|
det.set([fontFinding(1, 'inter')]);
|
|
const r2 = await run(file, det);
|
|
assert.equal(r2.audit.kind, 'pending');
|
|
assert.match(r2.stdout, /Still has 1 finding\(s\)/);
|
|
assert.match(r2.stdout, /overused-font:1:inter/);
|
|
assert.ok(!r2.stdout.includes('roboto'), 'must not name a finding that was fixed');
|
|
assert.ok(!r2.stdout.includes('geist'), 'must not name a finding that was fixed');
|
|
});
|
|
|
|
it('reports a reintroduced finding as fresh instead of swallowing it', async () => {
|
|
const file = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(file, 'noop');
|
|
const det = mutableDetector([fontFinding(1, 'inter')]);
|
|
|
|
const r1 = await run(file, det);
|
|
assert.match(r1.stdout, /Design hook findings requiring review/);
|
|
|
|
// Fixed: the hook goes clean and must forget the finding.
|
|
det.set([]);
|
|
const r2 = await run(file, det);
|
|
assert.equal(r2.audit.kind, 'clean');
|
|
|
|
// Reintroduced: this is a regression and has to surface as fresh, not be
|
|
// deduped against a stale memory of the same key.
|
|
det.set([fontFinding(1, 'inter')]);
|
|
const r3 = await run(file, det);
|
|
assert.equal(r3.audit.emitted, true);
|
|
assert.match(r3.stdout, /Design hook findings requiring review/, 'a reintroduced finding must fire again');
|
|
});
|
|
|
|
it('still dedupes an unchanged finding within a session', async () => {
|
|
// Guard against over-correcting: forgetting fixed findings must not turn
|
|
// every repeat edit back into a full findings dump.
|
|
const file = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(file, 'noop');
|
|
const det = mutableDetector([fontFinding(1, 'inter')]);
|
|
|
|
await run(file, det);
|
|
const r2 = await run(file, det);
|
|
assert.equal(r2.audit.kind, 'pending');
|
|
assert.match(r2.stdout, /Still has 1 finding\(s\)/);
|
|
});
|
|
});
|
|
|
|
describe('runHook() — clean-ack noise', () => {
|
|
let cwd;
|
|
beforeEach(() => {
|
|
cwd = mkTmp();
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
});
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
const event = (file, sessionId = 'sid-1') => JSON.stringify({
|
|
session_id: sessionId, cwd, hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit', tool_input: { file_path: file },
|
|
});
|
|
|
|
it('emits the clean ack once per file per session, then stays silent', async () => {
|
|
const a = path.join(cwd, 'a.css');
|
|
const b = path.join(cwd, 'b.css');
|
|
fs.writeFileSync(a, 'noop');
|
|
fs.writeFileSync(b, 'noop');
|
|
const det = fakeDetector([]);
|
|
|
|
const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det });
|
|
assert.equal(r1.audit.kind, 'clean', 'first clean scan of a file still acks');
|
|
|
|
const r2 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det });
|
|
assert.ok(!r2.audit.emitted, 'repeat clean scans of the same file stay silent');
|
|
assert.equal(r2.audit.skipped, 'clean-ack-deduped');
|
|
assert.equal(r2.stdout, '');
|
|
|
|
// A different file gets its own first ack.
|
|
const r3 = await runHook({ stdinJson: event(b), env: {}, cwd, detector: det });
|
|
assert.equal(r3.audit.kind, 'clean');
|
|
|
|
// A new session starts over, since the steer is per-session context.
|
|
const r4 = await runHook({ stdinJson: event(a, 'sid-2'), env: {}, cwd, detector: det });
|
|
assert.equal(r4.audit.kind, 'clean');
|
|
});
|
|
|
|
it('picks a not-yet-acked file when an earlier target was already acked', async () => {
|
|
// A multi-file event (apply_patch, MultiEdit) must not lose the ack for a
|
|
// file the session has never acked just because an earlier target in the
|
|
// same run was already deduped.
|
|
const a = path.join(cwd, 'a.css');
|
|
const b = path.join(cwd, 'b.css');
|
|
fs.writeFileSync(a, 'noop');
|
|
fs.writeFileSync(b, 'noop');
|
|
const det = fakeDetector([]);
|
|
|
|
// Ack a on its own first.
|
|
const r1 = await runHook({ stdinJson: event(a), env: {}, cwd, detector: det });
|
|
assert.equal(r1.audit.kind, 'clean');
|
|
|
|
// Now touch a and b together. a is spent; b has never been acked.
|
|
const multi = JSON.stringify({
|
|
session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch',
|
|
tool_input: {
|
|
command: `*** Begin Patch\n*** Update File: ${a}\n*** Update File: ${b}\n*** End Patch`,
|
|
},
|
|
});
|
|
const r2 = await runHook({ stdinJson: multi, env: {}, cwd, detector: det });
|
|
assert.equal(r2.audit.kind, 'clean', 'b has never been acked and should win');
|
|
assert.match(r2.stdout, /b\.css/);
|
|
});
|
|
|
|
it('reports non-ui-ack when the winner was not ack-eligible, even after a dedupe', async () => {
|
|
// Mixed multi-target run: one UI file whose ack is already spent, plus a
|
|
// non-UI file. Nothing is emitted either way, but the audit reason must
|
|
// describe the winner rather than the earlier dedupe.
|
|
const css = path.join(cwd, 'a.css');
|
|
const ts = path.join(cwd, 'b.ts');
|
|
fs.writeFileSync(css, 'noop');
|
|
fs.writeFileSync(ts, 'export const a = 1;');
|
|
const det = fakeDetector([]);
|
|
|
|
await runHook({ stdinJson: event(css), env: {}, cwd, detector: det });
|
|
|
|
const multi = JSON.stringify({
|
|
session_id: 'sid-1', cwd, hook_event_name: 'PostToolUse', tool_name: 'apply_patch',
|
|
tool_input: {
|
|
command: `*** Begin Patch\n*** Update File: ${css}\n*** Update File: ${ts}\n*** End Patch`,
|
|
},
|
|
});
|
|
const r = await runHook({ stdinJson: multi, env: {}, cwd, detector: det });
|
|
assert.ok(!r.audit.emitted);
|
|
assert.equal(r.audit.skipped, 'non-ui-ack');
|
|
});
|
|
|
|
it('does not spend the clean ack while quiet mode is suppressing output', async () => {
|
|
// Quiet emits nothing, so it must not consume the once-per-session ack and
|
|
// leave a later non-quiet run silent.
|
|
const file = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(file, 'noop');
|
|
const det = fakeDetector([]);
|
|
|
|
const quiet = await runHook({ stdinJson: event(file), env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: det });
|
|
assert.ok(!quiet.audit.emitted);
|
|
|
|
const loud = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det });
|
|
assert.equal(loud.audit.kind, 'clean', 'the ack must survive a quiet run');
|
|
});
|
|
|
|
it('keeps re-nudging with the pending ack, which is the informative one', async () => {
|
|
const file = path.join(cwd, 'a.css');
|
|
fs.writeFileSync(file, 'noop');
|
|
const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]);
|
|
|
|
await runHook({ stdinJson: event(file), env: {}, cwd, detector: det });
|
|
const r2 = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det });
|
|
const r3 = await runHook({ stdinJson: event(file), env: {}, cwd, detector: det });
|
|
assert.equal(r2.audit.kind, 'pending');
|
|
assert.equal(r3.audit.kind, 'pending', 'the unresolved-finding nudge must not be deduped away');
|
|
});
|
|
});
|
|
|
|
describe('resolveCacheCwd()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('keeps the session cwd when it already looks like a project root', () => {
|
|
for (const marker of ['.git', '.impeccable']) {
|
|
const dir = path.join(cwd, `root-${marker}`);
|
|
fs.mkdirSync(path.join(dir, marker), { recursive: true });
|
|
const file = path.join(dir, 'nested', 'app', 'src', 'Card.tsx');
|
|
assert.equal(resolveCacheCwd(file, dir), dir);
|
|
}
|
|
const pkgDir = path.join(cwd, 'root-pkg');
|
|
fs.mkdirSync(pkgDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pkgDir, 'package.json'), '{}');
|
|
assert.equal(resolveCacheCwd(path.join(pkgDir, 'src', 'Card.tsx'), pkgDir), pkgDir);
|
|
});
|
|
|
|
it('climbs to the nearest marker root when the session cwd is a bare umbrella', () => {
|
|
const child = path.join(cwd, 'app');
|
|
fs.mkdirSync(path.join(child, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(child, 'package.json'), '{}');
|
|
assert.equal(resolveCacheCwd(path.join(child, 'src', 'Card.tsx'), cwd), child);
|
|
});
|
|
|
|
it('falls back to the session cwd when no marker is found or the path is unsafe', () => {
|
|
const file = path.join(cwd, 'app', 'src', 'Card.tsx');
|
|
assert.equal(resolveCacheCwd(file, cwd), cwd);
|
|
assert.equal(resolveCacheCwd('', cwd), cwd);
|
|
assert.equal(resolveCacheCwd(`${cwd}/../etc/Card.tsx`, cwd), cwd);
|
|
});
|
|
});
|
|
|
|
describe('suppressionNotice()', () => {
|
|
it('starts with envelope and mentions /impeccable audit', () => {
|
|
const text = suppressionNotice('src/Card.tsx');
|
|
assert.ok(text.startsWith(ENVELOPE_PREFIX));
|
|
assert.match(text, /More than 6 edits in this session reached/);
|
|
assert.match(text, /\/impeccable audit/);
|
|
});
|
|
});
|
|
|
|
describe('ALLOWED_EXTS', () => {
|
|
it('covers the documented design-relevant extensions', () => {
|
|
for (const ext of ['.tsx', '.jsx', '.html', '.css', '.vue', '.svelte', '.astro', '.ts', '.js', '.scss', '.sass', '.less', '.htm']) {
|
|
assert.ok(ALLOWED_EXTS.has(ext), `missing: ${ext}`);
|
|
}
|
|
for (const ext of ['.md', '.py', '.go', '.json']) {
|
|
assert.ok(!ALLOWED_EXTS.has(ext), `unexpected allowed: ${ext}`);
|
|
}
|
|
});
|
|
|
|
it('keeps clean/pending acknowledgements to UI-ish files', () => {
|
|
for (const ext of ['.tsx', '.jsx', '.html', '.css', '.vue', '.svelte', '.astro', '.scss', '.sass', '.less', '.htm']) {
|
|
assert.ok(ACK_EXTS.has(ext), `missing ack extension: ${ext}`);
|
|
assert.equal(shouldEmitAckForFile(`/x/src/App${ext}`), true);
|
|
}
|
|
for (const ext of ['.ts', '.js']) {
|
|
assert.ok(!ACK_EXTS.has(ext), `unexpected ack extension: ${ext}`);
|
|
assert.equal(shouldEmitAckForFile(`/x/src/tool${ext}`), false);
|
|
}
|
|
});
|
|
|
|
it('acks configured html-engine extensions but not text-engine ones', () => {
|
|
const config = {
|
|
extensions: [
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
{ ext: '.d.ts.hbs', engine: 'text' },
|
|
],
|
|
};
|
|
assert.equal(shouldEmitAckForFile('/x/views/card.blade.php', config), true);
|
|
assert.equal(shouldEmitAckForFile('/x/templates/types.d.ts.hbs', config), false);
|
|
assert.equal(shouldEmitAckForFile('/x/views/card.blade.php'), false);
|
|
});
|
|
});
|
|
|
|
describe('matchConfiguredExtension()', () => {
|
|
const extensions = [
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
{ ext: '.html.erb', engine: 'html' },
|
|
{ ext: '.twig', engine: 'html' },
|
|
];
|
|
|
|
it('matches double extensions against the end of the filename', () => {
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('/app/resources/views/Card.blade.php', extensions),
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
);
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('app/views/users/show.html.erb', extensions),
|
|
{ ext: '.html.erb', engine: 'html' },
|
|
);
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('/templates/base.twig', extensions),
|
|
{ ext: '.twig', engine: 'html' },
|
|
);
|
|
});
|
|
|
|
it('is case-insensitive on the filename', () => {
|
|
assert.ok(matchConfiguredExtension('/views/Card.BLADE.PHP', extensions));
|
|
});
|
|
|
|
it('prefers the longest matching suffix regardless of config order', () => {
|
|
const overlapping = [
|
|
{ ext: '.php', engine: 'text' },
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
];
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('/views/card.blade.php', overlapping),
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
);
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('/views/card.blade.php', overlapping.slice().reverse()),
|
|
{ ext: '.blade.php', engine: 'html' },
|
|
);
|
|
assert.deepEqual(
|
|
matchConfiguredExtension('/app/Controller.php', overlapping),
|
|
{ ext: '.php', engine: 'text' },
|
|
);
|
|
});
|
|
|
|
it('does not match unrelated files or bare dotfile-like names', () => {
|
|
assert.equal(matchConfiguredExtension('/app/Http/Controller.php', extensions), null);
|
|
assert.equal(matchConfiguredExtension('/views/.blade.php', extensions), null);
|
|
assert.equal(matchConfiguredExtension('/src/Card.tsx', extensions), null);
|
|
});
|
|
|
|
it('returns null for empty or missing config', () => {
|
|
assert.equal(matchConfiguredExtension('/views/card.blade.php', []), null);
|
|
assert.equal(matchConfiguredExtension('/views/card.blade.php', undefined), null);
|
|
});
|
|
});
|
|
|
|
describe('renderCleanAck() / renderPendingAck()', () => {
|
|
it('renderCleanAck stays short and ends with the steer line', () => {
|
|
const text = renderCleanAck('/x/src/App.jsx', { cwd: '/x' });
|
|
assert.match(text, /^\[impeccable@1\] Design hook scanned src\/App\.jsx\. No deterministic design-quality issues found\./);
|
|
assert.match(text, /keep following the project design system and the impeccable skill guidance/);
|
|
// Budget guard: should fit comfortably under a single context-message
|
|
// injection (~200 chars). Hard upper bound 240 chars.
|
|
assert.ok(text.length < 240, `clean ack too long: ${text.length} chars`);
|
|
});
|
|
|
|
it('renderPendingAck quotes up to 3 known findings and counts the rest', () => {
|
|
const known = ['side-tab:3', 'gradient-text:4', 'ai-color-palette:8', 'overused-font:12'];
|
|
const text = renderPendingAck('/x/src/SlopCard.jsx', known, { cwd: '/x' });
|
|
assert.match(text, /^\[impeccable@1\] Design hook scanned src\/SlopCard\.jsx\./);
|
|
assert.match(text, /Still has 4 finding\(s\) flagged earlier this session/);
|
|
assert.match(text, /side-tab:3, gradient-text:4, ai-color-palette:8/);
|
|
assert.match(text, /\+1 more/); // 4 total, 3 shown
|
|
assert.match(text, /Handle them before finalizing/);
|
|
});
|
|
|
|
it('renderPendingAck omits the "+N more" suffix when ≤3 known findings', () => {
|
|
const text = renderPendingAck('/x/src/A.tsx', ['side-tab:1', 'gradient-text:2'], { cwd: '/x' });
|
|
assert.ok(!text.includes('+'), 'no overflow suffix expected');
|
|
});
|
|
});
|
|
|
|
describe('parseApplyPatchPaths()', () => {
|
|
it('extracts absolute and relative paths from patch bodies', () => {
|
|
const cwd = '/proj';
|
|
const rel = parseApplyPatchPaths('*** Update File: src/App.jsx\n', cwd);
|
|
assert.deepEqual(rel, ['/proj/src/App.jsx']);
|
|
const abs = parseApplyPatchPaths('*** Add File: /tmp/x.css\n*** Update File: src/y.html\n', cwd);
|
|
assert.deepEqual(abs, ['/tmp/x.css', '/proj/src/y.html']);
|
|
});
|
|
});
|
|
|
|
describe('resolveTargetFiles()', () => {
|
|
it('uses file_path when present and falls back to apply_patch command', () => {
|
|
assert.deepEqual(resolveTargetFiles({ tool_input: { file_path: '/a/b.tsx' } }, '/proj'), ['/a/b.tsx']);
|
|
assert.deepEqual(
|
|
resolveTargetFiles({ tool_name: 'apply_patch', tool_input: { command: '*** Update File: src/x.css\n' } }, '/proj'),
|
|
['/proj/src/x.css'],
|
|
);
|
|
assert.deepEqual(resolveTargetFiles({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }, '/proj'), []);
|
|
});
|
|
|
|
it('includes every apply_patch file even when file_path is also present', () => {
|
|
assert.deepEqual(
|
|
resolveTargetFiles({
|
|
tool_name: 'apply_patch',
|
|
tool_input: {
|
|
file_path: '/proj/src/App.jsx',
|
|
command: '*** Update File: src/App.jsx\n*** Update File: src/styles.css\n',
|
|
},
|
|
}, '/proj'),
|
|
['/proj/src/App.jsx', '/proj/src/styles.css'],
|
|
);
|
|
});
|
|
|
|
it('accepts Cursor Write/StrReplace path field and top-level file_path', () => {
|
|
assert.deepEqual(resolveTargetFiles({ tool_input: { path: '/a/b.tsx' } }, '/proj'), ['/a/b.tsx']);
|
|
assert.deepEqual(resolveTargetFiles({ file_path: '/a/c.css' }, '/proj'), ['/a/c.css']);
|
|
});
|
|
});
|
|
|
|
describe('resolveHarness() / normalizeHookEvent()', () => {
|
|
it('routes explicit env and Cursor conversation_id to cursor harness', () => {
|
|
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'cursor' }), 'cursor');
|
|
assert.equal(resolveHarness({}, { conversation_id: 'c1' }), 'cursor');
|
|
assert.equal(resolveHarness({}), 'claude');
|
|
});
|
|
|
|
it('maps Cursor postToolUse Write path into file_path + cwd', () => {
|
|
const normalized = normalizeHookEvent({
|
|
conversation_id: 'c1',
|
|
workspace_roots: ['/proj'],
|
|
tool_name: 'Write',
|
|
tool_input: { path: 'src/App.jsx' },
|
|
}, '/fallback', 'cursor');
|
|
assert.equal(normalized.session_id, 'c1');
|
|
assert.equal(normalized.cwd, '/proj');
|
|
assert.equal(normalized.tool_input.file_path, 'src/App.jsx');
|
|
});
|
|
|
|
it('routes a GitHub Copilot postToolUse event (toolName/toolArgs) to the github harness', () => {
|
|
const event = { sessionId: 's1', cwd: '/proj', toolName: 'edit', toolArgs: '{"path":"src/App.tsx"}' };
|
|
assert.equal(resolveHarness({}, event), 'github');
|
|
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'github' }), 'github');
|
|
// A Claude/Codex event (tool_name/tool_input) must not be mistaken for github.
|
|
assert.equal(resolveHarness({}, { tool_name: 'Edit', tool_input: { file_path: 'a.tsx' } }), 'claude');
|
|
});
|
|
|
|
it('normalizes a GitHub edit event: JSON-string toolArgs.path -> tool_input.file_path', () => {
|
|
const normalized = normalizeHookEvent({
|
|
sessionId: 's1',
|
|
cwd: '/proj',
|
|
toolName: 'edit',
|
|
toolArgs: '{"path":"/proj/src/App.tsx","old_str":"a","new_str":"b"}',
|
|
}, '/fallback', 'github');
|
|
assert.equal(normalized.session_id, 's1');
|
|
assert.equal(normalized.cwd, '/proj');
|
|
assert.equal(normalized.tool_name, 'edit');
|
|
assert.equal(normalized.tool_input.file_path, '/proj/src/App.tsx');
|
|
});
|
|
|
|
it('normalizes a GitHub apply_patch event: raw patch string -> tool_input.command', () => {
|
|
// Interactive Copilot and the cloud agent edit via apply_patch, whose
|
|
// toolArgs is a raw OpenAI-format patch string, not JSON.
|
|
const patch = [
|
|
'*** Begin Patch',
|
|
'*** Add File: /proj/src/Card.css',
|
|
"+body { font-family: 'Inter'; }",
|
|
'*** End Patch',
|
|
].join('\n');
|
|
const normalized = normalizeHookEvent({
|
|
sessionId: 's-ap', cwd: '/proj', toolName: 'apply_patch', toolArgs: patch,
|
|
}, '/fallback', 'github');
|
|
assert.equal(normalized.tool_name, 'apply_patch');
|
|
assert.equal(normalized.tool_input.command, patch);
|
|
// resolveTargetFiles understands apply_patch via tool_input.command.
|
|
assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/src/Card.css']);
|
|
});
|
|
|
|
it('does not misroute an edit whose content contains apply_patch markers', () => {
|
|
// An edit/create payload is JSON; its edited content may legitimately
|
|
// contain "*** Begin Patch" text (e.g. editing docs about apply_patch).
|
|
// That must still take the JSON path so `path` is extracted, not be
|
|
// mistaken for a raw apply_patch payload.
|
|
const normalized = normalizeHookEvent({
|
|
sessionId: 's-edit', cwd: '/proj', toolName: 'edit',
|
|
toolArgs: JSON.stringify({
|
|
path: '/proj/docs/patches.md',
|
|
old_str: 'old',
|
|
new_str: '*** Begin Patch\n*** Add File: x\n*** End Patch',
|
|
}),
|
|
}, '/fallback', 'github');
|
|
assert.equal(normalized.tool_name, 'edit');
|
|
assert.equal(normalized.tool_input.file_path, '/proj/docs/patches.md');
|
|
assert.equal(normalized.tool_input.command, undefined);
|
|
assert.deepEqual(resolveTargetFiles(normalized, '/proj'), ['/proj/docs/patches.md']);
|
|
});
|
|
|
|
it('normalizes a GitHub create event and tolerates malformed toolArgs', () => {
|
|
const created = normalizeHookEvent({
|
|
sessionId: 's2', cwd: '/proj', toolName: 'create',
|
|
toolArgs: '{"path":"/proj/styles.css","file_text":"body{}"}',
|
|
}, '/fallback', 'github');
|
|
assert.equal(created.tool_name, 'create');
|
|
assert.equal(created.tool_input.file_path, '/proj/styles.css');
|
|
|
|
const broken = normalizeHookEvent({
|
|
sessionId: 's3', cwd: '/proj', toolName: 'edit', toolArgs: 'not json{',
|
|
}, '/fallback', 'github');
|
|
assert.equal(broken.session_id, 's3');
|
|
assert.equal(broken.tool_input.file_path, undefined);
|
|
});
|
|
});
|
|
|
|
describe('expandScanTargets()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function write(rel, body) {
|
|
const abs = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
it('includes co-located styles.css when the primary edit is App.jsx', () => {
|
|
const app = write('src/App.jsx', 'export default function App() { return <main className="x" />; }');
|
|
write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
const expanded = expandScanTargets([app], cwd);
|
|
assert.deepEqual(expanded, [app, path.join(cwd, 'src/styles.css')]);
|
|
});
|
|
|
|
it('includes common co-located Sass, SCSS, and Less stylesheet names', () => {
|
|
for (const name of ['index.scss', 'index.sass', 'index.less', 'global.scss', 'global.less', 'globals.scss', 'globals.less']) {
|
|
const dir = `src/${name.replaceAll('.', '-')}`;
|
|
const app = write(`${dir}/App.jsx`, 'export default function App() { return <main className="x" />; }');
|
|
const stylesheet = write(`${dir}/${name}`, ".card\n border-left: 4px solid #3b82f6");
|
|
const expanded = expandScanTargets([app], cwd);
|
|
assert.ok(expanded.includes(stylesheet), `missing ${name}`);
|
|
}
|
|
});
|
|
|
|
it('follows static stylesheet imports from the edited component', () => {
|
|
const card = write('src/Card.jsx', "import './Card.module.css';\nexport default function Card() { return null; }");
|
|
const mod = write('src/Card.module.css', '.card { border-left: 4px solid #3b82f6; }');
|
|
const expanded = expandScanTargets([card], cwd);
|
|
assert.ok(expanded.includes(mod));
|
|
});
|
|
|
|
it('includes co-located module Sass and Less stylesheets', () => {
|
|
for (const name of ['Card.module.sass', 'Card.module.less']) {
|
|
const dir = `src/${name.replaceAll('.', '-')}`;
|
|
const card = write(`${dir}/Card.jsx`, 'export default function Card() { return <main className="x" />; }');
|
|
const stylesheet = write(`${dir}/${name}`, '.card { border-left: 4px solid #3b82f6; }');
|
|
const expanded = expandScanTargets([card], cwd);
|
|
assert.ok(expanded.includes(stylesheet), `missing ${name}`);
|
|
}
|
|
});
|
|
|
|
it('resolves relative primary targets against the project cwd', () => {
|
|
write('src/Card.jsx', "import './Card.module.css';\nexport default function Card() { return null; }");
|
|
const mod = write('src/Card.module.css', '.card { border-left: 4px solid #3b82f6; }');
|
|
const expanded = expandScanTargets(['src/Card.jsx'], cwd);
|
|
assert.deepEqual(expanded, [path.join(cwd, 'src/Card.jsx'), mod]);
|
|
});
|
|
|
|
it('does not follow imports from traversal-looking primary targets', () => {
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-outside-'));
|
|
try {
|
|
fs.writeFileSync(path.join(outside, 'App.jsx'), "import './styles.css';\nexport default function App() { return null; }");
|
|
fs.writeFileSync(path.join(outside, 'styles.css'), "body { font-family: 'Inter', sans-serif; }");
|
|
const traversalPrimary = `${cwd}/../${path.basename(outside)}/App.jsx`;
|
|
const expanded = expandScanTargets([traversalPrimary], cwd);
|
|
assert.deepEqual(expanded, [traversalPrimary]);
|
|
} finally {
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not expand when the primary target is already a stylesheet', () => {
|
|
const css = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
assert.deepEqual(expandScanTargets([css], cwd), [css]);
|
|
});
|
|
});
|
|
|
|
describe('runHook() — co-located stylesheet scan', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function write(rel, body) {
|
|
const abs = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
it('flags slop in styles.css when only App.jsx was edited', async () => {
|
|
const app = write('src/App.jsx', 'export default function App() { return <main className="x" />; }');
|
|
write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
const det = {
|
|
detectText: (content, filePath) => (
|
|
filePath.endsWith('.css') ? [finding('overused-font', 8)] : []
|
|
),
|
|
detectHtml: () => [],
|
|
};
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'co-scan',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'apply_patch',
|
|
tool_input: { command: `*** Update File: ${app}\n` },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.match(r.stdout, /styles\.css/);
|
|
});
|
|
|
|
it('flags slop in co-located .sass when only App.jsx was edited', async () => {
|
|
const app = write('src/App.jsx', 'export default function App() { return <main className="x" />; }');
|
|
write('src/styles.sass', ".card\n border-left: 4px solid #3b82f6");
|
|
const det = {
|
|
detectText: (content, filePath) => (
|
|
filePath.endsWith('.sass') ? [finding('side-tab', 2)] : []
|
|
),
|
|
detectHtml: () => [],
|
|
};
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'co-scan-sass',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'apply_patch',
|
|
tool_input: { command: `*** Update File: ${app}\n` },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.match(r.stdout, /styles\.sass/);
|
|
});
|
|
|
|
it('emits fresh findings for every file scanned in the same hook run', async () => {
|
|
const app = write('src/App.jsx', 'export default function App() { return <main className="border-l-4 border-blue-500" />; }');
|
|
const styles = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
const seen = [];
|
|
const det = {
|
|
detectText: (content, filePath) => {
|
|
seen.push(filePath);
|
|
if (filePath.endsWith('App.jsx')) return [finding('side-tab', 1)];
|
|
if (filePath.endsWith('styles.css')) return [finding('overused-font', 1)];
|
|
return [];
|
|
},
|
|
detectHtml: () => [],
|
|
};
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'co-scan-fresh-primary',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'apply_patch',
|
|
tool_input: { command: `*** Update File: ${app}\n` },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.match(r.stdout, /App\.jsx/);
|
|
assert.match(r.stdout, /styles\.css/);
|
|
assert.match(r.stdout, /side-tab/);
|
|
assert.match(r.stdout, /overused-font/);
|
|
assert.ok(seen.includes(app), 'primary file should be scanned');
|
|
assert.ok(seen.includes(styles), 'co-located stylesheet should still be scanned');
|
|
assert.equal(r.emission.groups.length, 2);
|
|
const cache = readCache(cwd);
|
|
const files = cache.sessions['co-scan-fresh-primary'].files;
|
|
assert.deepEqual(files[app].findings, ['side-tab:1']);
|
|
assert.deepEqual(files[styles].findings, ['overused-font:1']);
|
|
});
|
|
|
|
it('does not bump edit count for passively co-scanned stylesheets', async () => {
|
|
const app = write('src/App.jsx', 'export default function App() { return <main className="x" />; }');
|
|
const styles = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
const det = {
|
|
detectText: (content, filePath) => (
|
|
filePath.endsWith('styles.css') ? [finding('overused-font', 1)] : []
|
|
),
|
|
detectHtml: () => [],
|
|
};
|
|
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'co-scan-edit-count',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'apply_patch',
|
|
tool_input: { command: `*** Update File: ${app}\n` },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
|
|
assert.match(r.stdout, /styles\.css/);
|
|
const cache = readCache(cwd);
|
|
const files = cache.sessions['co-scan-edit-count'].files;
|
|
assert.equal(files[app].editCount, 1);
|
|
assert.equal(files[styles].editCount || 0, 0);
|
|
});
|
|
|
|
it('does not scan imported styles from a traversal-looking primary path', async () => {
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-outside-'));
|
|
try {
|
|
fs.writeFileSync(path.join(outside, 'App.jsx'), "import './styles.css';\nexport default function App() { return null; }");
|
|
fs.writeFileSync(path.join(outside, 'styles.css'), "body { font-family: 'Inter', sans-serif; }");
|
|
const traversalPrimary = `${cwd}/../${path.basename(outside)}/App.jsx`;
|
|
const det = fakeDetector([finding('overused-font', 1, { name: 'Overused font' })]);
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'co-scan-traversal',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: traversalPrimary },
|
|
}),
|
|
env: {},
|
|
cwd,
|
|
detector: det,
|
|
});
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'sensitive');
|
|
} finally {
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('runHook() — events without file_path', () => {
|
|
// The sweep fallback was removed in v5 (single-hook simplification).
|
|
// Code-execution tools that don't carry a `file_path` now hit a clean
|
|
// silent skip instead of running a git-status sweep. This keeps the
|
|
// single PostToolUse matcher (Edit/Write/MultiEdit/apply_patch) honest:
|
|
// anything else is a no-op.
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('returns silent skip with reason no-file-path', async () => {
|
|
const event = JSON.stringify({
|
|
session_id: 'sid-x',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'mcp__node_repl__js',
|
|
tool_input: { title: 'do work', code: 'console.log(1)' },
|
|
});
|
|
const det = fakeDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: event, env: {}, cwd, detector: det });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'no-file-path');
|
|
});
|
|
});
|
|
|
|
describe('runHook() — configured template extensions (issue #316)', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function eventFor(file) {
|
|
return {
|
|
session_id: 'sid-ext',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: file },
|
|
};
|
|
}
|
|
|
|
function writeFixture(rel, body) {
|
|
const abs = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
function writeExtensionsConfig(extensions) {
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ detector: { extensions } }));
|
|
}
|
|
|
|
function recordingDetector(findings = []) {
|
|
const calls = { html: [], text: [] };
|
|
return {
|
|
calls,
|
|
detectHtml: (filePath) => { calls.html.push(filePath); return findings; },
|
|
detectText: (_content, filePath) => { calls.text.push(filePath); return findings; },
|
|
};
|
|
}
|
|
|
|
it('skips .blade.php with no config — the issue #316 repro', async () => {
|
|
const file = writeFixture('resources/views/card.blade.php', '<div class="bg-gradient-to-r">Hi</div>');
|
|
const det = recordingDetector([finding('gradient-text', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.equal(r.stdout, '');
|
|
assert.equal(r.audit.skipped, 'extension');
|
|
assert.equal(det.calls.html.length + det.calls.text.length, 0);
|
|
});
|
|
|
|
it('scans a configured .blade.php through the html engine and emits findings', async () => {
|
|
writeExtensionsConfig([{ ext: '.blade.php' }]);
|
|
const file = writeFixture('resources/views/card.blade.php', '<div class="bg-gradient-to-r">Hi</div>');
|
|
const det = recordingDetector([finding('gradient-text', 1, { name: 'Gradient text' })]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.match(r.stdout, /Design hook findings requiring review/);
|
|
assert.match(r.stdout, /gradient-text/);
|
|
assert.equal(r.audit.emitted, true);
|
|
assert.equal(r.audit.ext, '.blade.php');
|
|
assert.deepEqual(det.calls.html, [file]);
|
|
assert.deepEqual(det.calls.text, []);
|
|
});
|
|
|
|
it('routes an engine:text entry through detectText instead', async () => {
|
|
writeExtensionsConfig([{ ext: '.blade.php', engine: 'text' }]);
|
|
const file = writeFixture('resources/views/card.blade.php', '<div>Hi</div>');
|
|
const det = recordingDetector([finding('side-tab', 1)]);
|
|
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
|
assert.match(r.stdout, /side-tab/);
|
|
assert.deepEqual(det.calls.text, [file]);
|
|
assert.deepEqual(det.calls.html, []);
|
|
});
|
|
|
|
it('emits a clean ack for configured html-engine files, stays quiet for text-engine ones', async () => {
|
|
writeExtensionsConfig([
|
|
{ ext: '.blade.php' },
|
|
{ ext: '.d.ts.hbs', engine: 'text' },
|
|
]);
|
|
const blade = writeFixture('resources/views/clean.blade.php', '<div>Hi</div>');
|
|
const rBlade = await runHook({ stdinJson: JSON.stringify(eventFor(blade)), env: {}, cwd, detector: recordingDetector([]) });
|
|
assert.match(rBlade.stdout, /No deterministic design-quality issues found/);
|
|
assert.equal(rBlade.audit.kind, 'clean');
|
|
|
|
const hbs = writeFixture('templates/types.d.ts.hbs', 'export type X = {{name}};');
|
|
const rHbs = await runHook({ stdinJson: JSON.stringify(eventFor(hbs)), env: {}, cwd, detector: recordingDetector([]) });
|
|
assert.equal(rHbs.stdout, '');
|
|
assert.equal(rHbs.audit.skipped, 'non-ui-ack');
|
|
});
|
|
});
|
|
|
|
describe('resolveProjectPlatform() / isNativePlatform()', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('reads the platform from PRODUCT.md via the same resolution the skill uses', () => {
|
|
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n');
|
|
assert.equal(resolveProjectPlatform(cwd), 'ios');
|
|
});
|
|
|
|
it('returns null when PRODUCT.md is absent or platform-less', () => {
|
|
assert.equal(resolveProjectPlatform(cwd), null);
|
|
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\nno platform field\n');
|
|
assert.equal(resolveProjectPlatform(cwd), null);
|
|
});
|
|
|
|
it('isNativePlatform is true only for ios / android / adaptive', () => {
|
|
assert.equal(isNativePlatform('ios'), true);
|
|
assert.equal(isNativePlatform('android'), true);
|
|
assert.equal(isNativePlatform('adaptive'), true);
|
|
assert.equal(isNativePlatform('web'), false);
|
|
assert.equal(isNativePlatform(null), false);
|
|
});
|
|
});
|
|
|
|
describe('Cursor hook scripts', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
it('preToolUse denies proposed writes with detector findings before they land', () => {
|
|
const logPath = path.join(cwd, 'hook.ndjson');
|
|
const filePath = path.join(cwd, 'src/Card.html');
|
|
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: filePath,
|
|
content: `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: logPath },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /blocked this write/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
assert.match(payload.agent_message, /Handle these before finalizing/);
|
|
|
|
const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(entries[0].event, 'preToolUse');
|
|
assert.equal(entries[0].blocked, true);
|
|
assert.equal(entries[0].blockedFindings, 1);
|
|
});
|
|
|
|
it('preToolUse allows writes with findings when the project platform is native', () => {
|
|
// Same slop content the deny test blocks, but the project declares a
|
|
// native platform, so the web rule engine must stand aside.
|
|
fs.writeFileSync(path.join(cwd, 'PRODUCT.md'), '# App\n\n## Platform\n\nios\n');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: path.join(cwd, 'src/Card.html'),
|
|
content: `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(out), { permission: 'allow' });
|
|
});
|
|
|
|
it('preToolUse allows clean proposed writes', () => {
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
path: 'src/Card.jsx',
|
|
streamContent: 'export default function Card() { return <section className="card">Hello</section>; }',
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(out), { permission: 'allow' });
|
|
});
|
|
|
|
it('preToolUse gates configured template extensions (issue #316)', () => {
|
|
const filePath = path.join(cwd, 'resources/views/card.blade.php');
|
|
const content = `
|
|
<style>
|
|
.card { border-left: 4px solid #7c3aed; border-radius: 16px; }
|
|
</style>
|
|
<div class="card">Hello</div>
|
|
`;
|
|
const run = () => execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Write',
|
|
tool_input: { file_path: filePath, content },
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
// Without config the file is invisible to the gate: allowed untouched.
|
|
assert.equal(JSON.parse(run()).permission, 'allow');
|
|
|
|
// With a detector.extensions entry the same proposed write is scanned and denied.
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({
|
|
detector: { extensions: [{ ext: '.blade.php' }] },
|
|
}));
|
|
const payload = JSON.parse(run());
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /card\.blade\.php/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse routes configured html-engine templates through the HTML engine (issue #316)', () => {
|
|
// oversized-h1 is only detectable by the static HTML engine (detectText has
|
|
// no such rule), so a denial here proves the proposed content went through
|
|
// detectHtml rather than the old always-detectText path.
|
|
const filePath = path.join(cwd, 'resources/views/hero.blade.php');
|
|
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, '.impeccable', 'config.json'), JSON.stringify({
|
|
detector: { extensions: [{ ext: '.blade.php' }] },
|
|
}));
|
|
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: filePath,
|
|
content: '<style>h1 { font-size: 84px; }</style>\n<h1>This is a very long headline that keeps going on and on for a while</h1>',
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /oversized-h1/);
|
|
});
|
|
|
|
it('preToolUse denies shell heredoc writes that bypass the Write tool', () => {
|
|
const filePath = path.join(cwd, 'src/ShellCard.html');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Shell',
|
|
tool_input: {
|
|
command: `cat > "${filePath}" <<'EOF'\n<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\nEOF\n`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /ShellCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse denies Python heredoc file writes that bypass the Write tool', () => {
|
|
const filePath = path.join(cwd, 'src/PythonCard.html');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Shell',
|
|
tool_input: {
|
|
command: `python3 - <<'PY'\nfrom pathlib import Path\npath = Path('${filePath}')\npath.write_text('''<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\n''', encoding='utf-8')\nPY\n`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /PythonCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse denies shell append redirects that bypass the Write tool', () => {
|
|
const filePath = path.join(cwd, 'src/AppendedCard.html');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Shell',
|
|
tool_input: {
|
|
command: `cat >> "${filePath}" <<'EOF'\n<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\nEOF\n`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /AppendedCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse denies shell tee writes that bypass the Write tool', () => {
|
|
const filePath = path.join(cwd, 'src/TeeCard.html');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Shell',
|
|
tool_input: {
|
|
command: `cat <<'EOF' | tee -a "${filePath}"\n<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>\nEOF\n`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /TeeCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse denies shell copy writes when copied content has detector findings', () => {
|
|
const sourcePath = path.join(cwd, 'src/SourceCard.html');
|
|
const destPath = path.join(cwd, 'src/CopiedCard.html');
|
|
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
|
fs.writeFileSync(sourcePath, `
|
|
<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>
|
|
<div class="card">Hello</div>
|
|
`);
|
|
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Shell',
|
|
tool_input: {
|
|
command: `cp "${sourcePath}" "${destPath}"`,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /CopiedCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse reconstructs Edit old_string/new_string into a full proposed file before scanning', () => {
|
|
const filePath = path.join(cwd, 'src/EditCard.html');
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const oldString = '<div class="card">Hello</div>';
|
|
fs.writeFileSync(filePath, oldString);
|
|
const newString = '<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style>\n<div class="card">Hello</div>';
|
|
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Edit',
|
|
tool_input: {
|
|
file_path: filePath,
|
|
old_string: oldString,
|
|
new_string: newString,
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
const payload = JSON.parse(out);
|
|
assert.equal(payload.permission, 'deny');
|
|
assert.match(payload.user_message, /EditCard\.html/);
|
|
assert.match(payload.user_message, /side-tab/);
|
|
});
|
|
|
|
it('preToolUse allows fragment-only edits instead of denying on partial context', () => {
|
|
const filePath = path.join(cwd, 'src/MissingEditCard.html');
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
tool_name: 'Edit',
|
|
tool_input: {
|
|
file_path: filePath,
|
|
new_string: '<div style="border-left: 4px solid #7c3aed; border-radius: 16px;">Hello</div>',
|
|
},
|
|
}),
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(out), { permission: 'allow' });
|
|
});
|
|
|
|
it('preToolUse downgrades repeated identical denials to allow-with-warning after the edit threshold', () => {
|
|
const filePath = path.join(cwd, 'src/LoopCard.html');
|
|
const input = JSON.stringify({
|
|
hook_event_name: 'preToolUse',
|
|
cwd,
|
|
session_id: 'cursor-loop',
|
|
tool_name: 'Write',
|
|
tool_input: {
|
|
file_path: filePath,
|
|
content: '<style>.card { border-left: 4px solid #7c3aed; border-radius: 16px; padding: 16px; }</style><div class="card">Hello</div>',
|
|
},
|
|
});
|
|
|
|
let payload;
|
|
for (let i = 0; i < 7; i++) {
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input,
|
|
env: { ...process.env, IMPECCABLE_HOOK_LOG: '' },
|
|
encoding: 'utf-8',
|
|
});
|
|
payload = JSON.parse(out);
|
|
}
|
|
|
|
assert.equal(payload.permission, 'allow');
|
|
assert.match(payload.agent_message, /allowing this write to avoid a loop/);
|
|
const cache = readCache(cwd);
|
|
const denials = cache.sessions['cursor-loop'].files[filePath].cursorDenials;
|
|
assert.equal(Object.values(denials)[0], 7);
|
|
});
|
|
|
|
it('preToolUse honors truthy IMPECCABLE_HOOK_DISABLED values before stdin parsing', () => {
|
|
const logPath = path.join(cwd, 'hook.ndjson');
|
|
|
|
const out = execFileSync(process.execPath, [path.join('skill', 'scripts', 'hook-before-edit.mjs')], {
|
|
cwd: path.resolve('.'),
|
|
input: '{not-json',
|
|
env: {
|
|
...process.env,
|
|
IMPECCABLE_HOOK_DISABLED: 'true',
|
|
IMPECCABLE_HOOK_LOG: logPath,
|
|
},
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
assert.deepEqual(JSON.parse(out), { permission: 'allow' });
|
|
const entries = fs.readFileSync(logPath, 'utf-8').trim().split('\n').map((line) => JSON.parse(line));
|
|
assert.equal(entries[0].event, 'preToolUse');
|
|
assert.equal(entries[0].skipped, 'env-disabled');
|
|
});
|
|
|
|
});
|
|
|
|
describe('runHook() — emission enrichment', () => {
|
|
let cwd;
|
|
beforeEach(() => { cwd = mkTmp(); });
|
|
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
|
|
function write(rel, content) {
|
|
const abs = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, content);
|
|
return abs;
|
|
}
|
|
|
|
it('returns emission.kind fresh with findings on new hits', async () => {
|
|
write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
|
|
const r = await runHook({
|
|
stdinJson: JSON.stringify({
|
|
session_id: 'emit-fresh',
|
|
cwd,
|
|
hook_event_name: 'PostToolUse',
|
|
file_path: path.join(cwd, 'src/styles.css'),
|
|
}),
|
|
env: { IMPECCABLE_HOOK_HARNESS: 'claude' },
|
|
cwd,
|
|
detector: fakeDetector([finding('overused-font', 8)]),
|
|
});
|
|
assert.equal(r.emission?.kind, 'fresh');
|
|
assert.ok(Array.isArray(r.emission?.findings));
|
|
assert.equal(r.emission.findings.length, 1);
|
|
});
|
|
});
|