diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 1e709b11a..4f9c740f2 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -210,12 +210,29 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +function hookStateDir(cwd) { + const root = process.env.IMPECCABLE_CACHE_ROOT; + if (root && typeof root === 'string' && root.trim()) { + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + return path.join(root, slug); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index e11d3fb62..9de8b6930 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -24,6 +24,8 @@ import { truthy, getConfigPath, getLocalConfigPath, + getCachePath, + getPendingPath, ensureHookGitExcludes, readConfig, readCache, @@ -67,6 +69,12 @@ import { import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs'; import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs'; +// Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a +// developer using the redirect locally) would relocate cache/pending out of +// the tmp projects and break stock-path assertions. Clear it up front; the +// dedicated issue-#422 suite sets and restores it explicitly. +delete process.env.IMPECCABLE_CACHE_ROOT; + function mkTmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-')); } @@ -415,6 +423,68 @@ describe('readCache / persistCache / bumpEditCount', () => { }); }); +describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { + let cwd; + let cacheRoot; + let savedEnv; + beforeEach(() => { + cwd = mkTmp(); + cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-')); + savedEnv = process.env.IMPECCABLE_CACHE_ROOT; + }); + afterEach(() => { + if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT; + else process.env.IMPECCABLE_CACHE_ROOT = savedEnv; + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(cacheRoot, { recursive: true, force: true }); + }); + + it('keeps hook state project-local when the env var is unset', () => { + delete process.env.IMPECCABLE_CACHE_ROOT; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json')); + }); + + it('treats a blank env var as unset', () => { + process.env.IMPECCABLE_CACHE_ROOT = ' '; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + }); + + it('relocates cache and pending under a per-project slug dir', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cacheRoot, slug, 'hook.pending.json')); + }); + + it('slug maps colons, slashes, backslashes, and dots to hyphens', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const cachePath = getCachePath('C:\\work\\my.app/sub'); + const slugDir = path.basename(path.dirname(cachePath)); + assert.equal(slugDir, 'C--work-my-app-sub'); + }); + + it('config paths stay project-local even when the redirect is active', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json')); + assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); + }); + + it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const cache = readCache(cwd); + bumpEditCount(cache, 'sid-1', '/x/a.tsx'); + assert.equal(persistCache(cwd, cache), true); + + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched'); + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + assert.equal(fs.existsSync(path.join(cacheRoot, slug, 'hook.cache.json')), true); + + const reloaded = readCache(cwd); + assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); + }); +}); + describe('ensureHookGitExcludes()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); });