Add GitHub Copilot hook support (CLI + cloud agent) (#279)

* Add GitHub Copilot hook support (CLI + cloud agent)

Wire the Impeccable design detector into GitHub Copilot's hook system so
direct file edits get the same post-edit design feedback the Claude Code,
Codex, and Cursor harnesses already receive.

GitHub Copilot's contract differs from the existing harnesses (verified
against Copilot CLI 1.0.63):
- Repo-level manifest at `.github/hooks/impeccable.json` (read by both the
  CLI, once committed to the default branch, and the cloud/app agent).
- Flat `postToolUse` entries with `bash`/`timeoutSec` and a full-match
  `matcher` regex; the file-editing tools are `edit` and `create`.
- The stdin event uses camelCase `toolName`/`toolArgs`, where `toolArgs` is
  a JSON *string* carrying the touched file under `path`.
- Context is injected via a top-level `additionalContext` string.

Changes:
- hooks.js: buildGitHubHooksManifest() + route `github` in hooksJsonFor().
- providers.js: emitHooks/hooksManifestRel for the github provider.
- hook-lib.mjs: detect the github harness, normalize the camelCase event
  (parse the JSON-string toolArgs -> tool_input.file_path), and emit the
  `additionalContext` payload shape.
- hook-admin.mjs / skills.mjs: install + idempotent-repair the
  `.github/hooks/impeccable.json` manifest (bash-aware marker stripping).
- hooks.md: document GitHub Copilot as a supported harness.
- Tests for the builder, routing, event normalization, and end-to-end run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover Copilot apply_patch edits in the hook (live-verified)

The first cut matched only `edit|create`, the tool names `copilot -p` uses.
A live trace against Copilot CLI 1.0.63 in an interactive session showed it
edits files via `apply_patch`, whose toolArgs is a raw OpenAI-format patch
string (`*** Begin Patch` / `*** Add File:`), not JSON. With the narrow
matcher the hook command never ran.

- hooks.js / hook-admin.mjs: matcher -> `edit|create|apply_patch`.
- hook-lib.mjs: normalizeGitHubEvent now routes apply_patch's raw patch
  string into tool_input.command (reusing the existing parseApplyPatchPaths /
  resolveTargetFiles plumbing) and only JSON-parses toolArgs for the
  edit/create/view tools. tool_name is normalized to apply_patch so the patch
  path is extracted even if a future build relabels the tool.
- Tests: apply_patch matcher assertions, event normalization, and an
  end-to-end runHook covering the interactive/cloud path.

Verified live: a trusted interactive `apply_patch` edit fires the hook and
returns the expected `additionalContext` design reminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review feedback + add changelog entry

- hook-lib.mjs (Bugbot, low): looksLikeApplyPatch no longer misroutes an
  edit/create event whose edited *content* contains apply_patch markers. A
  real apply_patch payload is a raw string that does not parse as JSON; an
  edit payload is a JSON object, so only non-JSON-object strings are treated
  as apply_patch. Edit events keep extracting `path`. Adds a regression test.
- skills.mjs (Bugbot, medium): document why `.github` is intentionally
  excluded from hookScriptPathForProvider. Its hook manifest is committed and
  shared (read by the Copilot cloud agent and teammates), so the command must
  stay portable via `$(git rev-parse ...)`; rewriting it to a machine-local
  absolute path would break those. GitHub skills are project-scoped, so the
  project-relative path resolves.
- changelog: add an Upcoming (v3.x placeholder) entry for the Copilot hook.
  Version is not bumped yet (batching with other changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-20 02:24:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 793feda5a0
commit 41ff946121
10 changed files with 371 additions and 9 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"matcher": "edit|create|apply_patch",
"bash": "node \"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs\"",
"timeoutSec": 5
}
]
}
}
+16 -1
View File
@@ -103,6 +103,12 @@ const PROVIDER_HOOK_ARTIFACTS = {
'.agents': [
{ sourceProvider: '.codex', rel: 'hooks.json', destProvider: '.codex' },
],
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. Unlike
// Claude, this is a team-shared, committed file (not a machine-local override),
// so source and dest are the same path.
'.github': [
{ sourceProvider: '.github', rel: 'hooks/impeccable.json', destProvider: '.github' },
],
};
let pipedAnswers = null;
@@ -1072,6 +1078,12 @@ function hookArtifactsForProvider(bundleDir, root, provider) {
}
function hookScriptPathForProvider(skillRoot, provider) {
// `.github` is intentionally absent: its hook manifest (`.github/hooks/
// impeccable.json`) is a committed, team-shared file that the Copilot cloud
// agent and every teammate read, so the command must stay portable
// (`$(git rev-parse --show-toplevel)/.github/skills/...`). Rewriting it to a
// machine-local absolute skillRoot path would break those. GitHub skills are
// project-scoped (not a home-provider), so the project-relative path resolves.
if (provider === '.cursor') {
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs');
}
@@ -1160,7 +1172,10 @@ function valueHasImpeccableHookMarker(value) {
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
+30
View File
@@ -24,6 +24,7 @@ const CLAUDE_PROJECT_HOOK = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scr
const CLAUDE_PLUGIN_HOOK = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
const CODEX_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs';
const CURSOR_BEFORE_EDIT_SCRIPT = '.cursor/skills/impeccable/scripts/hook-before-edit.mjs';
const GITHUB_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs';
export function buildClaudeSettingsManifest() {
return {
@@ -106,6 +107,33 @@ export function buildCursorHooksManifest() {
};
}
// GitHub Copilot reads project hooks from `.github/hooks/*.json`. Its schema
// differs from Claude/Codex/Cursor: the event key is lowercase `postToolUse`,
// each entry is flat (no nested `hooks` array), the command lives under `bash`
// (with an optional `powershell` sibling), the timeout key is `timeoutSec`, and
// `matcher` is a full-match regex (`^(?:PATTERN)$`) tested against the tool name.
// Copilot's file-editing tool names vary by surface (verified against CLI
// 1.0.63): `copilot -p` runs use `edit` ({path, old_str, new_str}) and `create`
// ({path, file_text}); interactive sessions and the cloud agent use
// `apply_patch` (a raw OpenAI-format patch string). The matcher covers all
// three. The same manifest is honored by both the CLI and the cloud/app agent.
// https://docs.github.com/en/copilot/reference/hooks-reference
export function buildGitHubHooksManifest() {
return {
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: `node "${GITHUB_PROJECT_HOOK}"`,
timeoutSec: TIMEOUT_SECONDS,
},
],
},
};
}
export function hooksJsonFor(provider) {
switch (provider) {
case 'claude':
@@ -114,6 +142,8 @@ export function hooksJsonFor(provider) {
return buildCodexHooksManifest();
case 'cursor':
return buildCursorHooksManifest();
case 'github':
return buildGitHubHooksManifest();
default:
return null;
}
+3
View File
@@ -68,6 +68,9 @@ export const PROVIDERS = {
displayName: 'GitHub Copilot',
placeholderProvider: 'agents',
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata'],
emitHooks: 'github',
// GitHub Copilot discovers repo-level hooks under `.github/hooks/*.json`.
hooksManifestRel: 'hooks/impeccable.json',
},
kiro: {
provider: 'kiro',
+8
View File
@@ -23,6 +23,14 @@ import '../styles/changelog-faq-kinpaku.css';
<button type="button" class="cf-filter-btn" data-cf-filter="all" aria-pressed="false">All</button>
</div>
<article id="next" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">v3.x</span><span class="cf-date">Upcoming</span><span class="cf-current-badge">Upcoming</span></header>
<p class="cf-entry-lead">The design hook now works in GitHub Copilot, so direct edits get the same post-edit design feedback that Claude Code, Codex, and Cursor already provide.</p>
<ul class="cf-items">
<li><strong>Design hooks for GitHub Copilot.</strong> Installing the skill adds a <code>.github/hooks/impeccable.json</code> hook that runs the detector after Copilot edits a UI file and feeds the findings back as a focused design reminder. It covers both the Copilot CLI and the cloud agent, and recognizes every edit path Copilot uses, including <code>apply_patch</code>.</li>
</ul>
</article>
<article id="v3.7.1" class="cf-entry cf-entry--current">
<header class="cf-entry-head"><span class="cf-version">v3.7.1</span><span class="cf-date">June 16, 2026</span><span class="cf-current-badge">Current</span></header>
<p class="cf-entry-lead">A packaging fix so the bundled detector runs everywhere.</p>
+4 -4
View File
@@ -2,13 +2,13 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -81,8 +81,8 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
## Failure modes
+26 -1
View File
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
},
}),
},
{
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
// manifest is honored by the CLI (once committed to the default branch) and
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
provider: '.github',
skillRel: '.github/skills/impeccable',
destRel: '.github/hooks/impeccable.json',
manifest: () => ({
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
timeoutSec: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
+107 -1
View File
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
return 'claude';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
// normalizeGitHubEvent). The detector reads the file from disk after the tool
// ran, so only the path (not the proposed content) is needed here.
export function parseGitHubToolArgs(toolArgs) {
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
if (typeof toolArgs === 'string' && toolArgs.trim()) {
try {
const parsed = JSON.parse(toolArgs);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
}
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
// *** Begin Patch
// *** Add File: /abs/app.css
// +body { ... }
// *** End Patch
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
// JSON string with the path under `path`. Both must map onto the internal shape.
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
function looksLikeApplyPatch(rawArgs) {
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
// Guard against an edit/create payload whose edited *content* happens to
// contain patch markers: that payload is a JSON object string, whereas a real
// apply_patch payload is a raw patch string that does not parse as JSON. Only
// treat non-JSON-object strings as apply_patch so edit events still get their
// `path` extracted.
try {
const parsed = JSON.parse(rawArgs);
if (parsed && typeof parsed === 'object') return false;
} catch { /* not JSON → genuine raw patch */ }
return true;
}
function applyPatchText(rawArgs) {
if (typeof rawArgs === 'string') {
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
// Defensive: a future Copilot build might JSON-wrap the patch.
const parsed = parseGitHubToolArgs(rawArgs);
return parsed.patch || parsed.input || parsed.command || '';
}
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
}
return '';
}
function normalizeGitHubEvent(event, projectCwd) {
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const toolName = event.toolName || event.tool_name || null;
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
const rawArgs = event.toolArgs;
let normalizedToolName = toolName;
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
// resolveTargetFiles() reads the touched paths from tool_input.command when
// tool_name is 'apply_patch', so normalize the name even if a future build
// sends the patch under a different tool label.
const patch = applyPatchText(rawArgs);
if (patch) {
toolInput.command = patch;
normalizedToolName = 'apply_patch';
}
} else {
const args = parseGitHubToolArgs(rawArgs);
const filePath = args.path || args.file_path || args.filePath || args.target_file;
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
}
return {
...event,
cwd,
session_id: sessionId,
tool_name: normalizedToolName,
tool_input: toolInput,
};
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'cursor') {
return JSON.stringify({ additional_context: text });
}
// GitHub Copilot's postToolUse hook injects context via a top-level
// `additionalContext` string (alongside an optional `modifiedResult`).
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});
+35
View File
@@ -14,6 +14,7 @@ import {
buildClaudePluginHooksManifest,
buildCodexHooksManifest,
buildCursorHooksManifest,
buildGitHubHooksManifest,
hooksJsonFor,
} from '../scripts/lib/transformers/hooks.js';
@@ -75,10 +76,30 @@ describe('hook manifest builders', () => {
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);
});
});
@@ -88,6 +109,7 @@ describe('generated hook artifacts in repo', () => {
'.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);
@@ -100,6 +122,7 @@ describe('generated hook artifacts in repo', () => {
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', () => {
@@ -136,6 +159,18 @@ describe('generated hook artifacts in repo', () => {
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');
+129 -2
View File
@@ -537,7 +537,7 @@ describe('hook-admin.mjs', () => {
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']) {
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 });
@@ -552,7 +552,7 @@ describe('hook-admin.mjs', () => {
const out = runAdmin(['on']);
assert.match(out, /Recorded local hook consent/);
assert.match(out, /Installed or repaired hook manifests for: \.claude, \.agents, \.cursor/);
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);
@@ -568,6 +568,9 @@ describe('hook-admin.mjs', () => {
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', () => {
@@ -784,6 +787,13 @@ describe('payload()', () => {
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()', () => {
@@ -874,6 +884,50 @@ rounded:
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
@@ -1366,6 +1420,79 @@ describe('resolveHarness() / normalizeHookEvent()', () => {
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()', () => {