mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Keep the committed marketplace repair script aligned with Claude Code's supported Edit and Write tools, and strengthen regression coverage after automated review.\n\nThis change was prepared with AI assistance under maintainer authorization.
404 lines
19 KiB
JavaScript
404 lines
19 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,
|
|
buildCodexPluginHooksManifest,
|
|
buildCursorHooksManifest,
|
|
buildGitHubHooksManifest,
|
|
buildGrokHooksManifest,
|
|
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'));
|
|
}
|
|
|
|
// The runtime probe every hook command must carry (issue #410): a node below
|
|
// the engines floor exits the command at 0 instead of dying on ESM parse. The
|
|
// expected floor comes from package.json engines, so probe and contract cannot
|
|
// drift apart.
|
|
const ENGINES_NODE_MAJOR = parseInt(
|
|
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).engines.node.replace(/[^\d.]/g, ''),
|
|
10,
|
|
);
|
|
const NODE_PROBE = `process.exit(Math.min(parseInt(process.versions.node,10),${ENGINES_NODE_MAJOR})===${ENGINES_NODE_MAJOR}?0:1)`;
|
|
|
|
function expectCommand(command, expectedPath) {
|
|
assert.equal(typeof command, 'string');
|
|
// node-command providers carry the missing-file guard (issue #399: exits 0
|
|
// when absent, preserves node's exit code when present) plus the runtime
|
|
// probe. GitHub's portable `$(git rev-parse)` form is guarded too, so it
|
|
// lands in the same branch.
|
|
if (command.startsWith('[ ! -f "')) {
|
|
assert.match(command, /\|\| node "/);
|
|
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
|
|
} else {
|
|
assert.match(command, /^node "|^bash -c|\$\(git rev-parse/);
|
|
}
|
|
assert.ok(command.includes(expectedPath), `missing ${expectedPath} in ${command}`);
|
|
assert.ok(!command.includes('hook-probe.mjs'), `probe hook still referenced in ${command}`);
|
|
}
|
|
|
|
function manifestCommands(manifest) {
|
|
const commands = [];
|
|
const walk = (value) => {
|
|
if (Array.isArray(value)) { value.forEach(walk); return; }
|
|
if (value && typeof value === 'object') {
|
|
if (typeof value.command === 'string') commands.push(value.command);
|
|
if (typeof value.bash === 'string') commands.push(value.bash);
|
|
Object.values(value).forEach(walk);
|
|
}
|
|
};
|
|
walk(manifest.hooks);
|
|
return commands;
|
|
}
|
|
|
|
describe('hook manifest builders', () => {
|
|
it('builds Claude project settings for the real detector hook', () => {
|
|
const manifest = buildClaudeSettingsManifest();
|
|
const group = manifest.hooks.PostToolUse[0];
|
|
const handler = group.hooks[0];
|
|
|
|
assert.equal(group.matcher, 'Edit|Write');
|
|
assert.doesNotMatch(manifest.description, /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', () => {
|
|
// Default install dir is `.codex`: a `.codex`-directory install keeps the
|
|
// skill payload at `.codex/skills/...`, so the hook must point there (not at
|
|
// a hardcoded `.agents`, which no-ops on such installs).
|
|
const manifest = buildCodexHooksManifest();
|
|
assert.equal(manifest.description, undefined);
|
|
const group = manifest.hooks.PostToolUse[0];
|
|
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, '.codex/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(!handler.command.includes('git rev-parse --show-toplevel'));
|
|
assert.ok(!handler.command.includes('${PLUGIN_ROOT}'));
|
|
assert.equal(manifest.hooks.SessionStart, undefined);
|
|
|
|
// 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, '.codex/skills/impeccable/scripts/hook.mjs');
|
|
});
|
|
|
|
it('derives the Codex hook payload path from the install dir', () => {
|
|
// Each install dir gets a manifest pointing at its own skills payload: a
|
|
// `.codex`-directory install at `.codex/skills`, a `.agents` (Codex repo
|
|
// skills) install at `.agents/skills`.
|
|
const codexDir = buildCodexHooksManifest('.codex');
|
|
expectCommand(codexDir.hooks.PostToolUse[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
|
|
expectCommand(codexDir.hooks.Stop[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
|
|
|
|
const agentsDir = buildCodexHooksManifest('.agents');
|
|
expectCommand(agentsDir.hooks.PostToolUse[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
|
|
expectCommand(agentsDir.hooks.Stop[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(!agentsDir.hooks.PostToolUse[0].hooks[0].command.includes('.codex/skills'));
|
|
|
|
// hooksJsonFor threads the provider's configDir through to the builder.
|
|
expectCommand(
|
|
hooksJsonFor('codex', { configDir: '.agents' }).hooks.PostToolUse[0].hooks[0].command,
|
|
'.agents/skills/impeccable/scripts/hook.mjs',
|
|
);
|
|
expectCommand(
|
|
hooksJsonFor('codex').hooks.PostToolUse[0].hooks[0].command,
|
|
'.codex/skills/impeccable/scripts/hook.mjs',
|
|
);
|
|
});
|
|
|
|
it('builds one Cursor pre-write blocking hook', () => {
|
|
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('builds Grok Build project hooks for the real detector hook', () => {
|
|
const manifest = buildGrokHooksManifest();
|
|
const group = manifest.hooks.PostToolUse[0];
|
|
const handler = group.hooks[0];
|
|
|
|
// Claude-compatible schema; Claude tool names alias to Grok tools at runtime.
|
|
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, '.grok/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(!handler.command.includes('${CLAUDE_PROJECT_DIR}'));
|
|
assert.ok(!handler.command.includes('${GROK_PLUGIN_ROOT}'));
|
|
assert.equal(manifest.hooks.SessionStart, undefined);
|
|
|
|
const stop = manifest.hooks.Stop[0].hooks[0];
|
|
assert.equal(stop.timeout, 30);
|
|
assert.equal(stop.statusMessage, 'Design deep pass');
|
|
expectCommand(stop.command, '.grok/skills/impeccable/scripts/hook.mjs');
|
|
});
|
|
|
|
it('probes the node runtime everywhere, and notices only where a channel exists', () => {
|
|
// Claude Code and Codex render a `systemMessage` from hook stdout, so their
|
|
// manifests carry the one-time unsupported-runtime notice. Cursor (output is
|
|
// permission-shaped; a message would block the edit), Grok (stdout ignored),
|
|
// and Copilot (contract unconfirmed) get the silent probe only.
|
|
const withNotice = [
|
|
buildClaudeSettingsManifest(),
|
|
buildClaudePluginHooksManifest(),
|
|
buildCodexHooksManifest(),
|
|
buildCodexPluginHooksManifest(),
|
|
];
|
|
const probeOnly = [
|
|
buildCursorHooksManifest(),
|
|
buildGitHubHooksManifest(),
|
|
buildGrokHooksManifest(),
|
|
];
|
|
for (const manifest of [...withNotice, ...probeOnly]) {
|
|
for (const command of manifestCommands(manifest)) {
|
|
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
|
|
}
|
|
}
|
|
for (const manifest of withNotice) {
|
|
for (const command of manifestCommands(manifest)) {
|
|
assert.ok(command.includes('systemMessage'), `missing notice in ${command}`);
|
|
assert.ok(command.includes('node-unsupported'), `missing once-only marker in ${command}`);
|
|
}
|
|
}
|
|
for (const manifest of probeOnly) {
|
|
for (const command of manifestCommands(manifest)) {
|
|
assert.ok(!command.includes('systemMessage'), `unexpected notice in ${command}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Volta's Windows shims exec through `cmd /C`, which claims `<`, `>`, and
|
|
// newlines from the `node -e` payload, so the probe died before node ran and
|
|
// the guard read that as a missing runtime (volta-cli/volta#1791). Every
|
|
// command is asserted to carry NODE_PROBE above, so this covers them all.
|
|
it('keeps the runtime probe free of characters cmd.exe re-parses', () => {
|
|
assert.ok(!/[<>\n]/.test(NODE_PROBE), `cmd.exe-unsafe character in probe: ${NODE_PROBE}`);
|
|
});
|
|
|
|
it('routes supported hook builders and leaves other providers alone', () => {
|
|
assert.ok(hooksJsonFor('claude'));
|
|
assert.ok(hooksJsonFor('codex'));
|
|
assert.ok(hooksJsonFor('cursor'));
|
|
assert.ok(hooksJsonFor('github'));
|
|
assert.ok(hooksJsonFor('grok'));
|
|
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 .codex skill payload', () => {
|
|
// The committed `.codex/hooks.json` is the distribution artifact for a
|
|
// `.codex`-directory install, whose skill payload lives at `.codex/skills/`
|
|
// (issue: it previously hardcoded `.agents/skills`, so the guarded hook
|
|
// no-opped on `.codex` installs). CLI installs that lay the skill down at
|
|
// `.agents/skills` rewrite the command to that path at install time.
|
|
const manifest = readJson('.codex/hooks.json');
|
|
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
|
|
|
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
|
|
assert.ok(!handler.command.includes('.agents/skills'));
|
|
|
|
// The self-consistent Codex bundle at `dist/codex/.codex/skills/` is a build
|
|
// artifact, not a tracked repo file; `bun run build` emits it and
|
|
// build.test.js verifies it there. This suite runs before the build (CI's
|
|
// `test:core` precedes the Build step), so it asserts only tracked outputs.
|
|
|
|
// The repo ships the Codex skill payload at `.agents/skills` (the
|
|
// layout CLI installs use, and where the rewritten command resolves).
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/SKILL.md')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook.mjs')));
|
|
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook-lib.mjs')));
|
|
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');
|
|
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('keeps the marketplace hook repair matcher aligned with Claude Code', () => {
|
|
const hookAdmin = fs.readFileSync(
|
|
path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-admin.mjs'),
|
|
'utf8',
|
|
);
|
|
assert.match(hookAdmin, /matcher: 'Edit\|Write'/);
|
|
assert.doesNotMatch(hookAdmin, /matcher: 'Edit\|Write\|MultiEdit'/);
|
|
});
|
|
|
|
it('generated hook runtime can import the bundled detector', async () => {
|
|
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');
|
|
}
|
|
});
|
|
});
|