Add IMPECCABLE_CACHE_ROOT to relocate hook state out of project roots (#422)

Honor an optional IMPECCABLE_CACHE_ROOT env var in getCachePath() /
getPendingPath(): when set, hook.cache.json and hook.pending.json land
under $IMPECCABLE_CACHE_ROOT/<project-slug>/ (slug = project path with
[:\/.] mapped to hyphens, mirroring Claude Code's ~/.claude/projects/
convention). Unset or blank env keeps stock project-local behavior.
User-authored config (config.json, config.local.json, design.json)
deliberately stays project-local - only disposable state relocates.

Also clears ambient IMPECCABLE_CACHE_ROOT at the top of hook.test.mjs so
a developer running the suite with the redirect active still gets
deterministic stock-path assertions; the new suite sets and restores the
var explicitly.

Prepared with AI assistance (Claude Code) under direction of
0xDarkMatter, per the maintainer-approved issue #422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
0xDarkMatter
2026-08-28 15:14:49 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent 0c2517884d
commit 77a2eae861
2 changed files with 89 additions and 2 deletions
+19 -2
View File
@@ -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()) {
+70
View File
@@ -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(); });