mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
feat(cli): interactive hook consent + unified .impeccable/config.json (#245)
* feat(cli): interactive hook consent + unified .impeccable/config.json Make the design-hook install a conscious choice and unify scattered config into one file. Interactive consent - On an interactive `skills install`/`update`, the CLI explains what the hook does and offers to install it (default yes), then records the per-developer decision in the gitignored `.impeccable/config.local.json`, so it never re-asks. A recorded decision or an already-installed hook short-circuits; `-y`/non-TTY keeps the historical install-by-default behavior; `--no-hooks` is a one-off skip that records nothing. The trigger keys on "is the hook installed?" + "is there a recorded decision?", not a brittle version check. Unified config - `.impeccable/config.json` (shared) and `.impeccable/config.local.json` (gitignored) now hold all Impeccable settings: hook settings under a `hook` key, plus top-level `updateCheck`. `/impeccable hooks` writes the `hook` subtree, preserving siblings. The hook runtime reads `hook.quiet` and `hook.auditLog`; context boot reads `updateCheck`. The legacy `IMPECCABLE_HOOK_DISABLED|QUIET|LOG` and `IMPECCABLE_NO_UPDATE_CHECK` env vars still work and override config; docs now lead with config and treat env vars as a legacy note. - No backward compat for the pre-unification `hook.json`/`hook.local.json` (the hook shipped an hour ago; nothing in the wild uses it). This repo's own hook config is migrated to `.impeccable/config.json`. The CLI and skill scripts are separate trees, so a small CLI-side config module (cli/lib/impeccable-config.mjs) duplicates the config-path and .git/info/exclude handling; comments flag the duplication. Tests: new cli config unit test; skills-cli consent tests (declined skips, accepted installs, --no-hooks records nothing); hook.test.mjs back-compat removed and quiet/auditLog-from-config + gitexclude coverage added. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): preserve sibling config fields + resolve audit log from event cwd (Bugbot) Two Bugbot findings: - High: `/impeccable hooks` edits replaced the whole `hook` object with the merge-helper output, dropping fields those helpers don't manage — so an `ignore-value --local` could wipe the recorded install consent and make the CLI re-prompt. writeConfig now merges over the existing hook object, keeping consent/quiet/auditLog. - Medium: config-based audit logging resolved hook.auditLog from process.cwd(), which can differ from the hook event's project root (and Cursor's pre-edit hook passed no cwd). The hook now stamps the resolved project root on the audit entry, and writeAuditLog reads config from entry.cwd when present. Tests: a /impeccable hooks edit preserves consent + quiet; writeAuditLog resolves config auditLog from entry.cwd, not the fallback cwd. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): resolve a relative auditLog path against the project root (Bugbot) A relative hook.auditLog was read from the project root but written relative to the hook process cwd, so when those differ the log went to the wrong place. writeAuditLog now resolves a relative target (from env or config) against the same project root it reads config from. Absolute and ~/ paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix hook consent recovery and smoke config * Fix hook consent explainer for Cursor * Fix empty hook target consent --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c0012d4e1
commit
8cf2be110d
+1
-1
@@ -51,7 +51,7 @@ Thumbs.db
|
||||
.impeccable/live/pending-manual-edits.json
|
||||
.impeccable/live/deferred-svelte-component-accepts.json
|
||||
.impeccable/history/
|
||||
.impeccable/hook.local.json
|
||||
.impeccable/config.local.json
|
||||
.impeccable/hook.pending.json
|
||||
.impeccable/provider-smoke/
|
||||
src/__impeccable_provider_smoke_*.html
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hook": {
|
||||
"enabled": true,
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [
|
||||
"tests/fixtures/**",
|
||||
"site/pages/slop/**"
|
||||
],
|
||||
"ignoreValues": [],
|
||||
"limits": {
|
||||
"maxFindings": 5,
|
||||
"maxChars": 8000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [
|
||||
"tests/fixtures/**",
|
||||
"site/pages/slop/**"
|
||||
],
|
||||
"ignoreValues": [],
|
||||
"limits": {
|
||||
"maxFindings": 5,
|
||||
"maxChars": 8000
|
||||
}
|
||||
}
|
||||
@@ -270,9 +270,9 @@ Installed hook surfaces:
|
||||
|
||||
The installer preserves unrelated hook entries and settings. If a hook manifest is malformed, install/update aborts by default; rerun with `--force` to back up the malformed file as `.bak` and replace it.
|
||||
|
||||
If you want skills without hook manifests, pass `--no-hooks` to `npx impeccable skills install` or `npx impeccable skills update`.
|
||||
On an interactive `install`/`update`, Impeccable explains the hook and offers to install it (default yes). Your choice is remembered per-developer in the gitignored `.impeccable/config.local.json`, so you are not asked again; `--no-hooks` skips it for that run without recording anything. Hook settings (enable/ignore rules, etc.) live under the `hook` key of `.impeccable/config.json`, managed with `/impeccable hooks`.
|
||||
|
||||
For debugging, set `IMPECCABLE_HOOK_LOG=/path/to/hook.ndjson` to write one NDJSON line per hook invocation. Leave it unset for normal use.
|
||||
For debugging, set `hook.auditLog` in `.impeccable/config.json` to a path (or the legacy `IMPECCABLE_HOOK_LOG` env var) to write one NDJSON line per hook invocation. Leave it unset for normal use.
|
||||
|
||||
Codex requires one platform step that Impeccable cannot safely skip: open `/hooks` after install or update and approve the project hook. There is no Codex marketplace/plugin install flow for this hook.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { get } from 'node:https';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import extract from 'extract-zip';
|
||||
import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const API_BASE = 'https://impeccable.style';
|
||||
@@ -650,6 +651,40 @@ function copyProviderHooks(bundleDir, root, providers, { force = false } = {}) {
|
||||
return [...new Set(written)];
|
||||
}
|
||||
|
||||
const HOOK_EXPLAINER = [
|
||||
'',
|
||||
'Impeccable can install a design hook for this project. In Claude/Codex it',
|
||||
'checks UI files after edits; in Cursor it checks proposed writes before they',
|
||||
'land and can block writes with detector findings. It feeds results back to',
|
||||
'your agent so design slop gets caught as you build. Change it later with',
|
||||
'/impeccable hooks on|off.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Decide whether to install the design hook. Prompts once (default yes) the
|
||||
// first time, records the answer in .impeccable/config.local.json, and never
|
||||
// re-asks: a recorded decision or an already-installed hook short-circuits, and
|
||||
// non-interactive runs keep the historical install-by-default behavior.
|
||||
async function decideHookInstall(root, targets, { yes } = {}) {
|
||||
if (targets.length === 0) return false;
|
||||
const consent = getHookConsent(root);
|
||||
if (consent === 'declined') return false;
|
||||
if (consent === 'accepted') return true;
|
||||
// Existing hook users (hook already wired up) are never nagged.
|
||||
if (targets.length > 0 && targets.every(provider => hookInstalledForProvider(root, provider))) {
|
||||
return true;
|
||||
}
|
||||
// Undecided and not yet installed. Non-interactive (-y or no TTY) keeps the
|
||||
// historical default-on behavior without recording a (re-promptable) decision.
|
||||
if (yes || !process.stdin.isTTY) return true;
|
||||
|
||||
process.stdout.write(HOOK_EXPLAINER);
|
||||
const ans = await ask('Install the design hook? (Y/n) ');
|
||||
const accepted = !(ans === 'n' || ans === 'no');
|
||||
setHookConsent(root, accepted ? 'accepted' : 'declined');
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function resolveLinkSource(sourceValue, root) {
|
||||
const sourcePath = sourceValue || '.impeccable';
|
||||
const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath);
|
||||
@@ -795,7 +830,8 @@ async function install(flags) {
|
||||
if (existing && !force) {
|
||||
console.log(`Impeccable skills are already installed (found in ${existing}/).`);
|
||||
const targets = providersValue ? resolveInstallTargets(root, providersValue) : findInstalledProviders(root);
|
||||
const missingHookTargets = installHooks
|
||||
const wantHooks = installHooks && await decideHookInstall(root, targets, { yes });
|
||||
const missingHookTargets = wantHooks
|
||||
? targets.filter(provider => !hookInstalledForProvider(root, provider))
|
||||
: [];
|
||||
if (missingHookTargets.length > 0) {
|
||||
@@ -836,6 +872,8 @@ async function install(flags) {
|
||||
}
|
||||
}
|
||||
|
||||
const wantHooks = installHooks && await decideHookInstall(root, targets, { yes });
|
||||
|
||||
console.log('\nDownloading impeccable skills...');
|
||||
let bundleDir;
|
||||
try {
|
||||
@@ -853,7 +891,7 @@ async function install(flags) {
|
||||
let hookTargets = [];
|
||||
try {
|
||||
written = copyProviderSkills(bundleDir, root, targets);
|
||||
hookTargets = installHooks ? copyProviderHooks(bundleDir, root, targets, { force }) : [];
|
||||
hookTargets = wantHooks ? copyProviderHooks(bundleDir, root, targets, { force }) : [];
|
||||
} catch (e) {
|
||||
rmSync(bundleDir, { recursive: true, force: true });
|
||||
console.error(`Install failed: ${e.message}`);
|
||||
@@ -988,7 +1026,8 @@ async function update(flags = []) {
|
||||
// Compare local vs remote -- skip if already up to date
|
||||
if (isUpToDate(root, copyProviders, tmpDir)) {
|
||||
try {
|
||||
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
|
||||
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const v = getSkillsVersion(root);
|
||||
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
|
||||
@@ -1039,7 +1078,8 @@ async function update(flags = []) {
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -1073,6 +1113,7 @@ function copyDirSync(src, dest) {
|
||||
export {
|
||||
copyProviderHooks,
|
||||
copyProviderSkills,
|
||||
decideHookInstall,
|
||||
expectedHookDests,
|
||||
linkProviderSkills,
|
||||
mergeHookManifests,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout and the
|
||||
* `.git/info/exclude` handling. Keep the schema and exclude marker in sync if
|
||||
* either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* { "hook": { "consent": "accepted" | "declined", ... }, "updateCheck": bool }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -54,8 +54,11 @@ const smokeFiles = {
|
||||
};
|
||||
|
||||
const results = [];
|
||||
const hookConfigFiles = ['.impeccable/config.json', '.impeccable/config.local.json'];
|
||||
const originalHookConfigFiles = new Map();
|
||||
|
||||
main().catch((error) => {
|
||||
restoreHookConfigFiles();
|
||||
if (!results.some((result) => !result.pass)) {
|
||||
record('fatal', false, String(error?.message || error), 'fatal');
|
||||
}
|
||||
@@ -67,6 +70,7 @@ main().catch((error) => {
|
||||
async function main() {
|
||||
assertPath(targetRepo, 'target repo');
|
||||
assertPath(bundlePath, 'universal bundle');
|
||||
snapshotHookConfigFiles();
|
||||
mkdirSync(smokeDir, { recursive: true });
|
||||
ensureTargetGitExclude();
|
||||
|
||||
@@ -85,6 +89,7 @@ async function main() {
|
||||
|
||||
cleanSmokeFiles();
|
||||
clearRuntimeState();
|
||||
restoreHookConfigFiles();
|
||||
writeSummary();
|
||||
|
||||
const failed = results.filter((result) => !result.pass);
|
||||
@@ -238,7 +243,7 @@ function cleanInstalledImpeccable() {
|
||||
rmSync(join(targetRepo, rel), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const rel of ['.claude/settings.json', '.cursor/hooks.json', '.codex/hooks.json']) {
|
||||
for (const rel of ['.claude/settings.json', '.claude/settings.local.json', '.cursor/hooks.json', '.codex/hooks.json']) {
|
||||
stripManifest(rel);
|
||||
}
|
||||
|
||||
@@ -319,6 +324,47 @@ function stripManifest(rel) {
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotHookConfigFiles() {
|
||||
for (const rel of hookConfigFiles) {
|
||||
const file = join(targetRepo, rel);
|
||||
originalHookConfigFiles.set(rel, existsSync(file) ? readFileSync(file, 'utf8') : null);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreHookConfigFiles() {
|
||||
if (originalHookConfigFiles.size === 0) return;
|
||||
for (const [rel, content] of originalHookConfigFiles.entries()) {
|
||||
const file = join(targetRepo, rel);
|
||||
if (content === null) {
|
||||
rmSync(file, { force: true });
|
||||
} else {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetHookConfigForSmoke() {
|
||||
for (const rel of hookConfigFiles) {
|
||||
const file = join(targetRepo, rel);
|
||||
if (!existsSync(file)) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
rmSync(file, { force: true });
|
||||
continue;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
|
||||
const { hook, ...rest } = raw;
|
||||
if (Object.keys(rest).length === 0) {
|
||||
rmSync(file, { force: true });
|
||||
} else {
|
||||
writeFileSync(file, `${JSON.stringify(rest, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (containsImpeccableHook(entry)) return null;
|
||||
@@ -338,7 +384,7 @@ function containsImpeccableHook(value) {
|
||||
}
|
||||
|
||||
function verifyInstallShape() {
|
||||
const claude = readText('.claude/settings.json');
|
||||
const claude = readText('.claude/settings.local.json');
|
||||
const codex = readText('.codex/hooks.json');
|
||||
const cursor = readText('.cursor/hooks.json');
|
||||
assertCount(claude, '.claude/skills/impeccable/scripts/hook.mjs', 1, 'Claude hook.mjs');
|
||||
@@ -464,14 +510,13 @@ function runConfirmedExceptionPersistenceChecks() {
|
||||
for (const provider of providers) {
|
||||
runConfirmedExceptionForProvider(provider);
|
||||
}
|
||||
record('confirmed exception persistence', true, `${providers.join(', ')} ignored confirmed overused-font values through shared hook.json, not source comments`);
|
||||
record('confirmed exception persistence', true, `${providers.join(', ')} ignored confirmed overused-font values through shared config.json, not source comments`);
|
||||
}
|
||||
|
||||
function runConfirmedExceptionForProvider(provider) {
|
||||
clearRuntimeState();
|
||||
const rel = confirmedSmokeFile(provider);
|
||||
const file = writeConfirmedFixture(rel);
|
||||
const configPath = join(targetRepo, '.impeccable', 'hook.json');
|
||||
const beforeLog = `${provider}-confirmed-before.ndjson`;
|
||||
const afterLog = `${provider}-confirmed-after.ndjson`;
|
||||
|
||||
@@ -480,9 +525,7 @@ function runConfirmedExceptionForProvider(provider) {
|
||||
|
||||
const first = runInstalledProviderHook(provider, file, beforeLog);
|
||||
requireRuleFinding(`${provider} confirmed exception first hook`, `${first.stdout}\n${first.stderr}\n${readMaybe(join(smokeDir, beforeLog))}`, 'overused-font');
|
||||
if (existsSync(configPath)) {
|
||||
throw new Error(`${provider} hook wrote .impeccable/hook.json before explicit confirmation`);
|
||||
}
|
||||
assertNoSpecificFontIgnoreConfig(provider);
|
||||
|
||||
run('node', [
|
||||
providerAdminScript(provider),
|
||||
@@ -498,7 +541,7 @@ function runConfirmedExceptionForProvider(provider) {
|
||||
timeoutMs: 60 * 1000,
|
||||
});
|
||||
|
||||
const config = readJson(configPath);
|
||||
const config = readSharedHookConfig();
|
||||
assertSpecificFontIgnoreConfig(provider, config);
|
||||
|
||||
clearTransientHookState();
|
||||
@@ -539,7 +582,6 @@ function runAgentChosenFontExceptionForProvider(provider) {
|
||||
clearRuntimeState();
|
||||
const rel = agentChoiceSmokeFile(provider);
|
||||
const file = writeConfirmedFixture(rel);
|
||||
const configPath = join(targetRepo, '.impeccable', 'hook.json');
|
||||
const beforeLog = `${provider}-agent-choice-before.ndjson`;
|
||||
const afterLog = `${provider}-agent-choice-after.ndjson`;
|
||||
|
||||
@@ -548,13 +590,11 @@ function runAgentChosenFontExceptionForProvider(provider) {
|
||||
|
||||
const first = runInstalledProviderHook(provider, file, beforeLog);
|
||||
requireRuleFinding(`${provider} agent-choice first hook`, `${first.stdout}\n${first.stderr}\n${readMaybe(join(smokeDir, beforeLog))}`, 'overused-font');
|
||||
if (existsSync(configPath)) {
|
||||
throw new Error(`${provider} hook wrote .impeccable/hook.json before explicit confirmation`);
|
||||
}
|
||||
assertNoSpecificFontIgnoreConfig(provider);
|
||||
|
||||
runProviderAgentFontException(provider, rel);
|
||||
|
||||
const config = readJson(configPath);
|
||||
const config = readSharedHookConfig();
|
||||
assertSpecificFontIgnoreConfig(provider, config);
|
||||
|
||||
clearTransientHookState();
|
||||
@@ -648,6 +688,30 @@ function assertSpecificFontIgnoreConfig(provider, config) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoSpecificFontIgnoreConfig(provider) {
|
||||
const file = join(targetRepo, '.impeccable', 'config.json');
|
||||
if (!existsSync(file)) return;
|
||||
const raw = readJson(file);
|
||||
const config = raw && typeof raw === 'object' && !Array.isArray(raw) && raw.hook && typeof raw.hook === 'object'
|
||||
? raw.hook
|
||||
: null;
|
||||
if (!config) return;
|
||||
const broad = Array.isArray(config.ignoreRules) && config.ignoreRules.includes('overused-font');
|
||||
const specific = Array.isArray(config.ignoreValues)
|
||||
&& config.ignoreValues.some((entry) => entry.rule === 'overused-font' && entry.value === 'roboto');
|
||||
if (broad || specific) {
|
||||
throw new Error(`${provider} hook config already suppressed overused-font=roboto before explicit confirmation`);
|
||||
}
|
||||
}
|
||||
|
||||
function readSharedHookConfig() {
|
||||
const raw = readJson(join(targetRepo, '.impeccable', 'config.json'));
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !raw.hook || typeof raw.hook !== 'object') {
|
||||
throw new Error('Missing .impeccable/config.json hook config');
|
||||
}
|
||||
return raw.hook;
|
||||
}
|
||||
|
||||
function runInstalledProviderHook(provider, file, logName) {
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
|
||||
if (provider === 'claude') {
|
||||
@@ -891,7 +955,7 @@ function fontExceptionPrompt(provider, rel) {
|
||||
return [
|
||||
`Read the installed Impeccable hooks reference for ${provider}, then persist a confirmed hook exception for Roboto specifically in ${rel}.`,
|
||||
'The user confirms Roboto is intentional for this fixture, but did not ask to ignore overused fonts generally.',
|
||||
'Use the /impeccable hooks / hook-admin flow; do not edit .impeccable/hook.json by hand and do not edit the source fixture.',
|
||||
'Use the /impeccable hooks / hook-admin flow; do not edit .impeccable/config.json by hand and do not edit the source fixture.',
|
||||
'The final config must use ignoreValues for overused-font=roboto and must not add overused-font to ignoreRules.',
|
||||
'After updating the config, stop.',
|
||||
].join(' ');
|
||||
@@ -959,6 +1023,7 @@ function cleanSmokeFiles() {
|
||||
}
|
||||
|
||||
function clearRuntimeState() {
|
||||
resetHookConfigForSmoke();
|
||||
for (const rel of [
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
|
||||
@@ -41,6 +41,7 @@ export const SUITES = {
|
||||
'tests/lib/provider-blocks.test.js',
|
||||
'tests/lib/transformers/provider-blocks.test.js',
|
||||
'tests/lib/utils.test.js',
|
||||
'tests/lib/impeccable-config.test.js',
|
||||
'tests/lib/transformers/factory.test.js',
|
||||
'tests/lib/transformers/providers.test.js',
|
||||
'tests/docs-integrity.test.js',
|
||||
|
||||
+11
-11
@@ -2,9 +2,9 @@
|
||||
|
||||
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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
|
||||
| Action | What it does |
|
||||
|---|---|
|
||||
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
|
||||
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
|
||||
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
|
||||
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
|
||||
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
|
||||
## Flow
|
||||
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
|
||||
|
||||
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `{{command_prefix}}impeccable hooks on`."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
@@ -71,12 +71,12 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
|
||||
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `{{command_prefix}}impeccable hooks off` (persistent for this project, committable).
|
||||
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
|
||||
- If the user asks to "disable the hook" globally, lead with `{{command_prefix}}impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
|
||||
|
||||
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
|
||||
* the user's home dir) and re-surfaces a given version at most once per week so
|
||||
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
|
||||
*/
|
||||
// Read the unified config's top-level `updateCheck` (local overrides shared).
|
||||
// Inlined rather than importing hook-lib so the boot path stays lightweight.
|
||||
function updateCheckDisabledByConfig(cwd = process.cwd()) {
|
||||
let value;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
|
||||
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
|
||||
} catch { /* missing or malformed: ignore */ }
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
async function computeUpdateDirective(now = Date.now()) {
|
||||
try {
|
||||
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
|
||||
if (updateCheckDisabledByConfig()) return null;
|
||||
const localVersion = readLocalSkillVersion();
|
||||
if (!localVersion) return null;
|
||||
|
||||
|
||||
+284
-10
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
|
||||
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
|
||||
* project.
|
||||
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
|
||||
* in the current project.
|
||||
*
|
||||
* Usage:
|
||||
* node hook-admin.mjs status # print current state
|
||||
@@ -35,6 +35,81 @@ import {
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
|
||||
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
|
||||
'skills/impeccable/scripts/hook-probe.mjs',
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
'skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-after-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-stop.mjs',
|
||||
];
|
||||
const TIMEOUT_SECONDS = 5;
|
||||
const STATUS_MESSAGE = 'Checking UI changes';
|
||||
|
||||
const HOOK_MANIFEST_TARGETS = [
|
||||
{
|
||||
provider: '.claude',
|
||||
skillRel: '.claude/skills/impeccable',
|
||||
destRel: '.claude/settings.local.json',
|
||||
sharedDestRel: '.claude/settings.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.agents',
|
||||
skillRel: '.agents/skills/impeccable',
|
||||
destRel: '.codex/hooks.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|apply_patch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.cursor',
|
||||
skillRel: '.cursor/skills/impeccable',
|
||||
destRel: '.cursor/hooks.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
preToolUse: [
|
||||
{
|
||||
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
|
||||
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// The hook settings to edit: the unified file's `hook` subtree.
|
||||
function readRawConfig(cwd, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
|
||||
return readRawConfigFile(filePath).raw;
|
||||
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
|
||||
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
|
||||
return unified.hook;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeConfig(cwd, config, opts = {}) {
|
||||
// Write the hook config back under the `hook` key of the unified file, leaving
|
||||
// any sibling keys (e.g. updateCheck) untouched.
|
||||
function writeConfig(cwd, hookConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
|
||||
if (opts.local) ensureHookGitExcludes(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -102,8 +189,8 @@ function statusReport(cwd) {
|
||||
const cfg = readConfig(cwd);
|
||||
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
|
||||
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
|
||||
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
|
||||
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
|
||||
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
|
||||
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
|
||||
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
|
||||
const fileState = (info, relPath, absent) => {
|
||||
if (info.malformed) return `${relPath} (malformed; ignored)`;
|
||||
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
config.enabled = value;
|
||||
const target = writeConfig(cwd, config);
|
||||
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
|
||||
if (!value) {
|
||||
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
|
||||
const repaired = repairHookManifests(cwd);
|
||||
const parts = [
|
||||
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
|
||||
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
|
||||
];
|
||||
if (repaired.written.length > 0) {
|
||||
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
|
||||
} else if (repaired.already.length > 0) {
|
||||
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
|
||||
} else {
|
||||
parts.push('No installed provider skill folders found to repair.');
|
||||
}
|
||||
if (repaired.backups.length > 0) {
|
||||
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function repairHookManifests(cwd) {
|
||||
const result = { written: [], already: [], backups: [] };
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
|
||||
const dest = path.join(cwd, target.destRel);
|
||||
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
|
||||
|
||||
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
|
||||
pruneImpeccableHookFromManifest(dest);
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fresh = target.manifest();
|
||||
let next = fresh;
|
||||
if (fs.existsSync(dest)) {
|
||||
try {
|
||||
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
|
||||
} catch {
|
||||
const backup = `${dest}.bak`;
|
||||
fs.copyFileSync(dest, backup);
|
||||
result.backups.push(backup);
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
||||
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
|
||||
if (current === serialized) {
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, serialized);
|
||||
result.written.push(target.provider);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeReadText(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeHookManifests(existing, fresh) {
|
||||
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
||||
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
|
||||
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
|
||||
? existingObject.hooks
|
||||
: {};
|
||||
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
|
||||
? freshObject.hooks
|
||||
: {};
|
||||
|
||||
const merged = { ...existingObject, hooks: {} };
|
||||
if (freshObject.version !== undefined) merged.version = freshObject.version;
|
||||
if (freshObject.description !== undefined) merged.description = freshObject.description;
|
||||
|
||||
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
|
||||
for (const event of hookEvents) {
|
||||
const preserved = stripImpeccableHookEntries(existingHooks[event]);
|
||||
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
|
||||
const mergedEntries = [...preserved, ...added];
|
||||
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fileHasImpeccableHookMarker(filePath) {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
|
||||
return valueHasImpeccableHookMarker(parsed.hooks);
|
||||
}
|
||||
|
||||
function valueHasImpeccableHookMarker(value) {
|
||||
if (typeof value === 'string') {
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
|
||||
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
const strippedHooks = entry.hooks
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
|
||||
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, hooks: strippedHooks };
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function pruneImpeccableHookFromManifest(manifestPath) {
|
||||
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
|
||||
? parsed.hooks
|
||||
: {};
|
||||
const cleanedHooks = {};
|
||||
for (const [event, entries] of Object.entries(existingHooks)) {
|
||||
const kept = stripImpeccableHookEntries(entries);
|
||||
if (kept.length > 0) cleanedHooks[event] = kept;
|
||||
}
|
||||
|
||||
const next = { ...parsed };
|
||||
if (Object.keys(cleanedHooks).length > 0) {
|
||||
next.hooks = cleanedHooks;
|
||||
} else {
|
||||
delete next.hooks;
|
||||
delete next.description;
|
||||
delete next.version;
|
||||
}
|
||||
|
||||
if (Object.keys(next).length === 0) {
|
||||
fs.rmSync(manifestPath, { force: true });
|
||||
} else {
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeRuleId(rule) {
|
||||
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
|
||||
|
||||
function reset(cwd) {
|
||||
const removed = [];
|
||||
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
|
||||
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
|
||||
// hook subtree and keep the rest, deleting the file only if nothing remains.
|
||||
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
|
||||
try {
|
||||
const raw = readRawConfigFile(filePath).raw;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
|
||||
const { hook, ...rest } = raw;
|
||||
if (Object.keys(rest).length === 0) {
|
||||
fs.unlinkSync(filePath);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
|
||||
}
|
||||
removed.push(path.relative(cwd, filePath) || filePath);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// State files are wholly ours; delete outright.
|
||||
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
@@ -382,6 +382,7 @@ async function main() {
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
tool: event.tool_name || null,
|
||||
file: filePath || null,
|
||||
};
|
||||
|
||||
+41
-11
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
|
||||
|
||||
export const DEFAULT_CONFIG = Object.freeze({
|
||||
enabled: true,
|
||||
quiet: false,
|
||||
auditLog: null,
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/hook.local.json',
|
||||
'.impeccable/config.local.json',
|
||||
]);
|
||||
|
||||
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
|
||||
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
|
||||
}
|
||||
|
||||
export function getConfigPath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'hook.json');
|
||||
return path.join(cwd, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(cwd) {
|
||||
return path.join(cwd, '.impeccable', 'hook.local.json');
|
||||
return path.join(cwd, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
export function getCachePath(cwd) {
|
||||
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
|
||||
export function readConfig(cwd) {
|
||||
const config = cloneDefaultConfig();
|
||||
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
|
||||
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
|
||||
// Hook settings live under the `hook` key of config.json (shared) and
|
||||
// config.local.json (per-developer, gitignored); local wins.
|
||||
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
|
||||
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
|
||||
return config;
|
||||
}
|
||||
|
||||
// The hook settings subtree of a unified config.json / config.local.json.
|
||||
function hookSection(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function numberOr(value, fallback) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
||||
config.enabled = raw.enabled === false ? false : true;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
|
||||
config.quiet = raw.quiet === true;
|
||||
}
|
||||
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
|
||||
config.auditLog = raw.auditLog.trim();
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function writeAuditLog(env, entry) {
|
||||
const target = env?.IMPECCABLE_HOOK_LOG;
|
||||
export function writeAuditLog(env, entry, cwd = process.cwd()) {
|
||||
// The event's project root (entry.cwd) when present, else the passed cwd. Both
|
||||
// config reads and relative log paths resolve against this, since the hook
|
||||
// process cwd can differ from the project being edited.
|
||||
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
|
||||
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
|
||||
let target = env?.IMPECCABLE_HOOK_LOG;
|
||||
if (!target || typeof target !== 'string') {
|
||||
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
|
||||
}
|
||||
if (!target || typeof target !== 'string') return false;
|
||||
try {
|
||||
const expanded = target.startsWith('~/')
|
||||
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
|
||||
: target;
|
||||
let expanded;
|
||||
if (target.startsWith('~/')) {
|
||||
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
|
||||
} else if (path.isAbsolute(target)) {
|
||||
expanded = target;
|
||||
} else {
|
||||
expanded = path.resolve(baseCwd, target);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(expanded), { recursive: true });
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
|
||||
fs.appendFileSync(expanded, line);
|
||||
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
audit.harness = harness;
|
||||
|
||||
const projectCwd = event.cwd || cwd;
|
||||
audit.cwd = projectCwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
|
||||
const primaryFileSet = new Set(primaryFiles);
|
||||
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
||||
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
|
||||
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ async function main() {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
writeAuditLog(process.env, result.audit);
|
||||
writeAuditLog(process.env, result.audit, process.cwd());
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
process.exit(result.exitCode || 0);
|
||||
|
||||
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/hook.local.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
|
||||
+162
-39
@@ -135,12 +135,14 @@ describe('readConfig()', () => {
|
||||
|
||||
it('parses enabled, ignoreRules, ignoreFiles, limits', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({
|
||||
enabled: false,
|
||||
ignoreRules: ['side-tab'],
|
||||
ignoreFiles: ['src/legacy/**'],
|
||||
minSeverity: 'error',
|
||||
limits: { maxFindings: 2, maxChars: 1000 },
|
||||
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);
|
||||
@@ -154,25 +156,29 @@ describe('readConfig()', () => {
|
||||
it('merges shared config first and local config second', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
enabled: false,
|
||||
ignoreRules: ['side-tab'],
|
||||
ignoreFiles: ['src/legacy/**'],
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'inter', reason: 'team default' },
|
||||
],
|
||||
minSeverity: 'error',
|
||||
limits: { maxFindings: 2, maxChars: 1000 },
|
||||
hook: {
|
||||
enabled: false,
|
||||
ignoreRules: ['side-tab'],
|
||||
ignoreFiles: ['src/legacy/**'],
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'inter', reason: 'team default' },
|
||||
],
|
||||
minSeverity: 'error',
|
||||
limits: { maxFindings: 2, maxChars: 1000 },
|
||||
},
|
||||
}));
|
||||
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({
|
||||
enabled: true,
|
||||
ignoreRules: ['gradient-text', 'side-tab'],
|
||||
ignoreFiles: ['src/local/**'],
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'Roboto' },
|
||||
{ rule: 'overused-font', value: 'Inter', reason: 'local override' },
|
||||
],
|
||||
minSeverity: 'warning',
|
||||
limits: { maxFindings: 4 },
|
||||
hook: {
|
||||
enabled: true,
|
||||
ignoreRules: ['gradient-text', 'side-tab'],
|
||||
ignoreFiles: ['src/local/**'],
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'Roboto' },
|
||||
{ rule: 'overused-font', value: 'Inter', reason: 'local override' },
|
||||
],
|
||||
minSeverity: 'warning',
|
||||
limits: { maxFindings: 4 },
|
||||
},
|
||||
}));
|
||||
|
||||
const cfg = readConfig(cwd);
|
||||
@@ -189,7 +195,7 @@ describe('readConfig()', () => {
|
||||
|
||||
it('tolerates malformed JSON and falls back to defaults', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), '{ not json');
|
||||
fs.writeFileSync(getConfigPath(cwd), '{ not json');
|
||||
const cfg = readConfig(cwd);
|
||||
assert.equal(cfg.enabled, true);
|
||||
});
|
||||
@@ -197,9 +203,11 @@ describe('readConfig()', () => {
|
||||
it('ignores malformed local config while preserving valid shared config', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
enabled: false,
|
||||
ignoreRules: ['side-tab'],
|
||||
limits: { maxFindings: 3 },
|
||||
hook: {
|
||||
enabled: false,
|
||||
ignoreRules: ['side-tab'],
|
||||
limits: { maxFindings: 3 },
|
||||
},
|
||||
}));
|
||||
fs.writeFileSync(getLocalConfigPath(cwd), '{ not json');
|
||||
const cfg = readConfig(cwd);
|
||||
@@ -207,6 +215,16 @@ describe('readConfig()', () => {
|
||||
assert.deepEqual(cfg.ignoreRules, ['side-tab']);
|
||||
assert.equal(cfg.limits.maxFindings, 3);
|
||||
});
|
||||
|
||||
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' },
|
||||
}));
|
||||
const cfg = readConfig(cwd);
|
||||
assert.equal(cfg.quiet, true);
|
||||
assert.equal(cfg.auditLog, '~/hook.ndjson');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCache / persistCache / bumpEditCount', () => {
|
||||
@@ -258,7 +276,7 @@ describe('ensureHookGitExcludes()', () => {
|
||||
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\/hook\.local\.json/);
|
||||
assert.match(exclude, /\.impeccable\/config\.local\.json/);
|
||||
|
||||
const second = ensureHookGitExcludes(cwd);
|
||||
assert.equal(second.changed, false);
|
||||
@@ -365,7 +383,7 @@ describe('hook-admin.mjs', () => {
|
||||
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 shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.equal(shared.enabled, true);
|
||||
assert.deepEqual(shared.ignoreRules, []);
|
||||
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
|
||||
@@ -377,7 +395,7 @@ describe('hook-admin.mjs', () => {
|
||||
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'));
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
|
||||
{ rule: 'overused-font', value: 'open sans', reason: 'Brand font' },
|
||||
]);
|
||||
@@ -387,16 +405,69 @@ describe('hook-admin.mjs', () => {
|
||||
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 local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8'));
|
||||
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.equal(local.enabled, undefined, 'local ignore should not override shared enabled 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\/hook\.local\.json/);
|
||||
assert.match(status, /local file:\s+\.impeccable\/config\.local\.json/);
|
||||
assert.match(status, /ignoreValues:\s+overused-font=inter/);
|
||||
});
|
||||
|
||||
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 local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.equal(local.consent, 'declined', 'consent must survive a local ignore-value edit');
|
||||
assert.equal(local.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']) {
|
||||
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/);
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
it('ignore-rule overused-font requires explicit broad suppression', () => {
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-rule', 'overused-font']),
|
||||
@@ -408,14 +479,14 @@ describe('hook-admin.mjs', () => {
|
||||
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 ignoreRules/);
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
||||
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'));
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.deepEqual(shared.ignoreRules, ['side-tab']);
|
||||
});
|
||||
|
||||
@@ -433,7 +504,7 @@ describe('hook-admin.mjs', () => {
|
||||
|
||||
runAdmin(['ignore-file', 'src/ConfirmedCard.html']);
|
||||
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
|
||||
assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']);
|
||||
|
||||
const r = await runHook({
|
||||
@@ -543,7 +614,47 @@ describe('writeAuditLog()', () => {
|
||||
});
|
||||
|
||||
it('is a no-op when IMPECCABLE_HOOK_LOG is unset', () => {
|
||||
assert.equal(writeAuditLog({}, { event: 'x' }), false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +797,18 @@ describe('runHook()', () => {
|
||||
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)]);
|
||||
@@ -730,7 +853,7 @@ describe('runHook()', () => {
|
||||
it('config-disabled silences cleanly', async () => {
|
||||
const file = writeFixture('src/Card.tsx', 'noop');
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({ enabled: false }));
|
||||
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, '');
|
||||
@@ -771,8 +894,8 @@ describe('runHook()', () => {
|
||||
it('config ignoreFiles glob suppresses', async () => {
|
||||
const file = writeFixture('src/legacy/Foo.tsx', 'noop');
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({
|
||||
ignoreFiles: ['src/legacy/**'],
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
hook: { ignoreFiles: ['src/legacy/**'] },
|
||||
}));
|
||||
const det = fakeDetector([finding('side-tab', 1)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
getHookConsent,
|
||||
setHookConsent,
|
||||
getLocalConfigPath,
|
||||
getConfigPath,
|
||||
ensureConfigGitExclude,
|
||||
} from '../../cli/lib/impeccable-config.mjs';
|
||||
|
||||
describe('cli/lib/impeccable-config', () => {
|
||||
let root;
|
||||
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); });
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
test('getHookConsent is undefined until a decision is recorded, then round-trips', () => {
|
||||
expect(getHookConsent(root)).toBeUndefined();
|
||||
setHookConsent(root, 'declined');
|
||||
expect(getHookConsent(root)).toBe('declined');
|
||||
setHookConsent(root, 'accepted');
|
||||
expect(getHookConsent(root)).toBe('accepted');
|
||||
});
|
||||
|
||||
test('setHookConsent preserves unrelated keys in config.local.json', () => {
|
||||
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
||||
writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } }));
|
||||
setHookConsent(root, 'declined');
|
||||
const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
|
||||
expect(raw.updateCheck).toBe(false);
|
||||
expect(raw.hook.quiet).toBe(true);
|
||||
expect(raw.hook.consent).toBe('declined');
|
||||
});
|
||||
|
||||
test('config.local.json (per-developer) overrides config.json for consent', () => {
|
||||
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
||||
writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } }));
|
||||
writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } }));
|
||||
expect(getHookConsent(root)).toBe('declined');
|
||||
});
|
||||
|
||||
test('malformed config is tolerated (no throw, undefined consent)', () => {
|
||||
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
||||
writeFileSync(getLocalConfigPath(root), '{ not json');
|
||||
expect(getHookConsent(root)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('writing consent gitignores config.local.json via .git/info/exclude', () => {
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
setHookConsent(root, 'declined');
|
||||
const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
|
||||
expect(exclude).toContain('.impeccable/config.local.json');
|
||||
// Idempotent: a second write does not duplicate the marker block.
|
||||
ensureConfigGitExclude(root);
|
||||
const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
|
||||
expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1);
|
||||
// It uses .git/info/exclude, not a tracked .gitignore.
|
||||
expect(existsSync(join(root, '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
test('ensureConfigGitExclude is a no-op outside a git repo', () => {
|
||||
expect(ensureConfigGitExclude(root)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -100,14 +100,14 @@ it('gitignores local Impeccable runtime artifacts', () => {
|
||||
'.impeccable/live/manual-edit-evidence/example.json',
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/hook.local.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
], { cwd: REPO_ROOT, encoding: 'utf-8' });
|
||||
assert.match(ignored, /\.impeccable\/live\/manual-edit-apply-transaction\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/manual-edit-evidence\/example\.json/);
|
||||
assert.match(ignored, /\.impeccable\/hook\.cache\.json/);
|
||||
assert.match(ignored, /\.impeccable\/hook\.pending\.json/);
|
||||
assert.match(ignored, /\.impeccable\/hook\.local\.json/);
|
||||
assert.match(ignored, /\.impeccable\/config\.local\.json/);
|
||||
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tmpdir } from 'os';
|
||||
import {
|
||||
copyProviderHooks,
|
||||
copyProviderSkills,
|
||||
decideHookInstall,
|
||||
expectedHookDests,
|
||||
mergeHookManifests,
|
||||
migrateUnprefixImpeccable,
|
||||
@@ -529,6 +530,69 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('recorded consent "declined" skips the hook (no prompt, no --no-hooks needed)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-declined-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
|
||||
JSON.stringify({ hook: { consent: 'declined' } }));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(output).toContain('Installed impeccable into: .claude');
|
||||
expect(output).not.toContain('Installed hooks into');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('recorded consent "accepted" installs the hook even non-interactively', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-accepted-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
|
||||
JSON.stringify({ hook: { consent: 'accepted' } }));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(output).toContain('Installed hooks into: .claude');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('--no-hooks records no consent decision (one-off skip)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-nohooks-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
|
||||
run('skills install -y --providers=claude --no-hooks', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('does not opt into hooks when no provider targets are installed', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-no-targets-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
|
||||
const wantHooks = await decideHookInstall(tmp, [], { yes: true });
|
||||
|
||||
expect(wantHooks).toBe(false);
|
||||
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('--no-hooks installs skills without hook manifests', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-no-hooks-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
|
||||
Reference in New Issue
Block a user