mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Eval evidence showed the per-edit PostToolUse stream fires overwhelmingly
on copy-level rules (em-dash-overuse ~97x/session) and measurably makes
models more conservative, while a full-detector pass at completion is what
actually fixes contrast/padding/glow. Split the hook accordingly:
- Per-edit (PostToolUse) now surfaces only IMMEDIATE_TIER_RULES: broken
output (broken-image, text-overflow, clipped-overflow-container,
body-text-viewport-edge), objective contrast/legibility failures
(low-contrast, gray-on-color, tiny-text), single-property mechanical
slop (gradient-text, dark-glow), and design-system drift (the four
design-system-* rules, which compound if left uncorrected). Everything
else defers. Override with hook.perEditRules: "all" in
.impeccable/config.json. Tiering is off for Cursor/Copilot harnesses,
which have no Stop pass wired, so nothing gets silently dropped there.
- Stop deep pass (runStopHook): runs the FULL rule set over every UI file
touched this session (tracked via the existing hook.cache.json session
state; deferred-only edits now mark the file touched), dedupes against
everything already surfaced per-edit, honors ignore-rule/file/value and
inline disables, reuses the [impeccable@1] envelope, and no-ops fast
when no UI files were touched. Emits hookSpecificOutput
{ hookEventName: "Stop", additionalContext } per the Claude Code SDK
Stop contract (conversation continues so the model can act on it).
Second Stop fire is silent - deep-pass findings are remembered.
- Wiring: Stop entries (timeout 30) in plugin/hooks/hooks.json, the
.claude settings + .codex hooks manifests (transformers + hook-admin
repair path). Claude Code and Codex both dispatch a native Stop event;
Cursor's stop hook is inconsistently dispatched (pre-write gate stays)
and Copilot's agentStop/sessionEnd don't inject model context, so
neither gets a Stop entry - documented in reference/hooks.md.
- Tests: tiering split/override/harness gating, Stop dedupe + silent
no-touched-files + ignore machinery + kill switches; existing per-edit
tests moved to immediate-tier rule ids. 181 tests green; smoke-tested
the built dist skill end to end (glow surfaced per-edit, em-dash only
at Stop, second Stop silent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
12 KiB
JavaScript
254 lines
12 KiB
JavaScript
/**
|
|
* Integration tests for the design-hook build pipeline.
|
|
* Run: node --test tests/hook-build.test.mjs
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import {
|
|
buildClaudeSettingsManifest,
|
|
buildClaudePluginHooksManifest,
|
|
buildCodexHooksManifest,
|
|
buildCursorHooksManifest,
|
|
buildGitHubHooksManifest,
|
|
hooksJsonFor,
|
|
} from '../scripts/lib/transformers/hooks.js';
|
|
|
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
function readJson(rel) {
|
|
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'));
|
|
}
|
|
|
|
function expectCommand(command, expectedPath) {
|
|
assert.equal(typeof command, 'string');
|
|
assert.match(command, /^node "/);
|
|
assert.ok(command.includes(expectedPath), `missing ${expectedPath} in ${command}`);
|
|
assert.ok(!command.includes('hook-probe.mjs'), `probe hook still referenced in ${command}`);
|
|
}
|
|
|
|
describe('hook manifest builders', () => {
|
|
it('builds Claude project settings for the real detector hook', () => {
|
|
const manifest = buildClaudeSettingsManifest();
|
|
const group = manifest.hooks.PostToolUse[0];
|
|
const handler = group.hooks[0];
|
|
|
|
assert.equal(group.matcher, 'Edit|Write|MultiEdit');
|
|
assert.equal(handler.type, 'command');
|
|
assert.equal(handler.timeout, 5);
|
|
assert.equal(handler.statusMessage, 'Checking UI changes');
|
|
expectCommand(handler.command, '.claude/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(handler.command.includes('${CLAUDE_PROJECT_DIR}'));
|
|
assert.equal(handler.args, undefined);
|
|
assert.equal(manifest.hooks.SessionStart, undefined);
|
|
|
|
// Stop deep pass: same script, no matcher, longer budget.
|
|
const stop = manifest.hooks.Stop[0].hooks[0];
|
|
assert.equal(manifest.hooks.Stop[0].matcher, undefined);
|
|
assert.equal(stop.timeout, 30);
|
|
assert.equal(stop.statusMessage, 'Design deep pass');
|
|
expectCommand(stop.command, '.claude/skills/impeccable/scripts/hook.mjs');
|
|
});
|
|
|
|
it('builds Codex project-local hooks for the real detector hook', () => {
|
|
const manifest = buildCodexHooksManifest();
|
|
assert.equal(manifest.description, undefined);
|
|
const group = manifest.hooks.PostToolUse[0];
|
|
const handler = group.hooks[0];
|
|
|
|
assert.equal(group.matcher, 'Edit|Write|apply_patch');
|
|
assert.equal(handler.type, 'command');
|
|
assert.equal(handler.timeout, 5);
|
|
assert.equal(handler.statusMessage, 'Checking UI changes');
|
|
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(!handler.command.includes('git rev-parse --show-toplevel'));
|
|
assert.ok(!handler.command.includes('${PLUGIN_ROOT}'));
|
|
assert.equal(manifest.hooks.SessionStart, undefined);
|
|
|
|
// Codex dispatches a native Stop event (turn scope), so it gets the deep
|
|
// pass too.
|
|
const stop = manifest.hooks.Stop[0].hooks[0];
|
|
assert.equal(stop.timeout, 30);
|
|
expectCommand(stop.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
|
});
|
|
|
|
it('builds one Cursor pre-write blocking hook', () => {
|
|
const manifest = buildCursorHooksManifest();
|
|
const beforeEdit = manifest.hooks.preToolUse[0];
|
|
|
|
assert.equal(manifest.version, 1);
|
|
assert.ok(Array.isArray(manifest.hooks.preToolUse));
|
|
assert.equal(Object.keys(manifest.hooks).length, 1);
|
|
assert.equal(manifest.hooks.afterFileEdit, undefined);
|
|
assert.equal(manifest.hooks.stop, undefined);
|
|
assert.equal(manifest.hooks.sessionStart, undefined);
|
|
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs');
|
|
assert.equal(beforeEdit.timeout, 5);
|
|
});
|
|
|
|
it('builds GitHub Copilot repo-level hooks for the real detector hook', () => {
|
|
const manifest = buildGitHubHooksManifest();
|
|
const entry = manifest.hooks.postToolUse[0];
|
|
|
|
// GitHub's schema: flat entries (no nested `hooks`), lowercase event key,
|
|
// `bash`/`timeoutSec`, and a full-match `matcher` against the tool name.
|
|
assert.equal(manifest.version, 1);
|
|
assert.equal(Object.keys(manifest.hooks).length, 1);
|
|
assert.equal(entry.type, 'command');
|
|
assert.equal(entry.matcher, 'edit|create|apply_patch');
|
|
assert.equal(entry.timeoutSec, 5);
|
|
assert.equal(entry.timeout, undefined);
|
|
assert.equal(entry.command, undefined);
|
|
expectCommand(entry.bash, '.github/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(entry.bash.includes('git rev-parse --show-toplevel'));
|
|
assert.equal(manifest.hooks.PostToolUse, undefined);
|
|
assert.equal(manifest.hooks.preToolUse, undefined);
|
|
});
|
|
|
|
it('routes supported hook builders and leaves other providers alone', () => {
|
|
assert.ok(hooksJsonFor('claude'));
|
|
assert.ok(hooksJsonFor('codex'));
|
|
assert.ok(hooksJsonFor('cursor'));
|
|
assert.ok(hooksJsonFor('github'));
|
|
assert.equal(hooksJsonFor('gemini'), null);
|
|
});
|
|
});
|
|
|
|
describe('generated hook artifacts in repo', () => {
|
|
for (const rel of [
|
|
'.claude/settings.json',
|
|
'.cursor/hooks.json',
|
|
'.codex/hooks.json',
|
|
'.github/hooks/impeccable.json',
|
|
]) {
|
|
it(`${rel} exists and is valid JSON`, () => {
|
|
const abs = path.join(REPO_ROOT, rel);
|
|
assert.ok(fs.existsSync(abs), `${rel} missing - did you forget bun run build?`);
|
|
assert.doesNotThrow(() => JSON.parse(fs.readFileSync(abs, 'utf8')));
|
|
});
|
|
}
|
|
|
|
it('root hook manifests exactly match the hook builders', () => {
|
|
assert.deepEqual(readJson('.claude/settings.json'), buildClaudeSettingsManifest());
|
|
assert.deepEqual(readJson('.cursor/hooks.json'), buildCursorHooksManifest());
|
|
assert.deepEqual(readJson('.codex/hooks.json'), buildCodexHooksManifest());
|
|
assert.deepEqual(readJson('.github/hooks/impeccable.json'), buildGitHubHooksManifest());
|
|
});
|
|
|
|
it('Claude project settings reference hook.mjs in .claude/skills', () => {
|
|
const manifest = readJson('.claude/settings.json');
|
|
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
|
|
|
expectCommand(handler.command, '.claude/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/hook.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/hook-lib.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.claude/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
|
|
});
|
|
|
|
it('Cursor project hooks reference only the pre-write runtime in .cursor/skills', () => {
|
|
const manifest = readJson('.cursor/hooks.json');
|
|
const beforeEdit = manifest.hooks.preToolUse[0];
|
|
|
|
assert.equal(Object.keys(manifest.hooks).length, 1);
|
|
expectCommand(beforeEdit.command, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs');
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-before-edit.mjs')));
|
|
assert.equal(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-after-edit.mjs')), false);
|
|
assert.equal(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-stop.mjs')), false);
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/hook-lib.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
|
|
});
|
|
|
|
it('Codex project hooks reference hook.mjs in the .agents skill payload', () => {
|
|
const manifest = readJson('.codex/hooks.json');
|
|
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
|
|
|
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/SKILL.md')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook-lib.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
|
|
});
|
|
|
|
it('GitHub Copilot repo hooks reference hook.mjs in the .github skill payload', () => {
|
|
const manifest = readJson('.github/hooks/impeccable.json');
|
|
const entry = manifest.hooks.postToolUse[0];
|
|
|
|
assert.equal(entry.matcher, 'edit|create|apply_patch');
|
|
expectCommand(entry.bash, '.github/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/SKILL.md')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/hook.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/hook-lib.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.github/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
|
|
});
|
|
|
|
it('does not generate probe scripts into provider skill payloads', () => {
|
|
for (const providerDir of ['.claude', '.cursor', '.agents', 'plugin']) {
|
|
const probe = path.join(REPO_ROOT, providerDir, 'skills', 'impeccable', 'scripts', 'hook-probe.mjs');
|
|
assert.equal(fs.existsSync(probe), false, `${providerDir} still has hook-probe.mjs`);
|
|
}
|
|
});
|
|
|
|
it('does not generate stale Codex hook packaging artifacts', () => {
|
|
for (const rel of [
|
|
'.claude/hooks/hooks.json',
|
|
'.agents/hooks',
|
|
'.agents/plugins/marketplace.json',
|
|
'plugin/.codex-plugin',
|
|
'plugin/assets',
|
|
'plugin-codex',
|
|
]) {
|
|
assert.equal(fs.existsSync(path.join(REPO_ROOT, rel)), false, `${rel} should not exist`);
|
|
}
|
|
});
|
|
|
|
it('packages the Claude design hook in the plugin via plugin-root paths', () => {
|
|
const abs = path.join(REPO_ROOT, 'plugin/hooks/hooks.json');
|
|
assert.ok(fs.existsSync(abs), 'plugin/hooks/hooks.json missing - did you forget bun run build:release?');
|
|
|
|
const manifest = readJson('plugin/hooks/hooks.json');
|
|
assert.deepEqual(manifest, buildClaudePluginHooksManifest());
|
|
// Codex loads bundled plugin hooks from this same file and rejects any
|
|
// top-level field other than `hooks` (issue #330).
|
|
assert.equal(manifest.description, undefined);
|
|
|
|
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
|
assert.equal(manifest.hooks.PostToolUse[0].matcher, 'Edit|Write|MultiEdit');
|
|
expectCommand(handler.command, 'skills/impeccable/scripts/hook.mjs');
|
|
// Resolves relative to the installed plugin, not a `.claude/skills/` layout.
|
|
assert.ok(handler.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
|
`plugin hook command must use $\{CLAUDE_PLUGIN_ROOT}: ${handler.command}`);
|
|
assert.ok(!handler.command.includes('${CLAUDE_PROJECT_DIR}'),
|
|
`plugin hook command must not use $\{CLAUDE_PROJECT_DIR}: ${handler.command}`);
|
|
|
|
// Stop deep pass ships in the plugin manifest too, plugin-root-relative.
|
|
const stop = manifest.hooks.Stop[0].hooks[0];
|
|
assert.equal(stop.timeout, 30);
|
|
expectCommand(stop.command, 'skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(stop.command.includes('${CLAUDE_PLUGIN_ROOT}'));
|
|
|
|
// The script the plugin hook points at must ship inside the plugin payload.
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-lib.mjs')));
|
|
});
|
|
|
|
it('generated hook runtime can import the bundled detector', async () => {
|
|
for (const scriptDir of [
|
|
'.claude/skills/impeccable/scripts',
|
|
'.cursor/skills/impeccable/scripts',
|
|
'.agents/skills/impeccable/scripts',
|
|
'plugin/skills/impeccable/scripts',
|
|
]) {
|
|
const abs = path.join(REPO_ROOT, scriptDir);
|
|
assert.ok(fs.existsSync(path.join(abs, 'detector', 'detect-antipatterns.mjs')),
|
|
`detector bundle missing in ${scriptDir}`);
|
|
const hookLib = await import(pathToFileURL(path.join(abs, 'hook-lib.mjs')));
|
|
const detector = await hookLib.loadDetector();
|
|
assert.equal(typeof detector.detectText, 'function');
|
|
}
|
|
});
|
|
});
|