Harden IMPECCABLE_CACHE_ROOT edges: normalization, opt-in gate, failure path

- hookStateDir now trims the env value (stray whitespace in env files)
  and path.resolve()s both the root and the cwd, so trailing separators
  and relative segments slug to the same per-project dir.
- The #344/#305 persist gate also treats an existing (possibly
  redirected) cache file as the opted-in marker. Without this, once
  state relocated, clean-edit editCount bumps stopped persisting because
  the project-local .impeccable/ dir never appears. No-op under stock
  paths, where the cache file lives inside .impeccable/.
- New tests: slug normalization equivalences, whitespace trim, graceful
  persistCache failure on an unusable root, and three runHook
  end-to-end cases (findings persist + dedup through the redirect,
  clean-edit editCount persistence, and the no-footprint no-op gate
  holding under redirect).

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 77a2eae861
commit 5c82d58b7e
2 changed files with 119 additions and 10 deletions
+17 -6
View File
@@ -218,11 +218,17 @@ export function getLocalConfigPath(cwd) {
// artifacts (issue #422). User-authored config (config.json,
// config.local.json, design.json) deliberately stays project-local — only
// disposable state relocates.
// Read from process.env (not runHook's injected env): the cache root is a
// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation
// switch. Trim guards against stray whitespace in env files; resolving both
// sides makes the slug deterministic when callers hand in a trailing
// separator or unnormalized cwd.
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);
const raw = process.env.IMPECCABLE_CACHE_ROOT;
const root = typeof raw === 'string' ? raw.trim() : '';
if (root) {
const slug = path.resolve(String(cwd)).replace(/[:\\/.]/g, '-');
return path.join(path.resolve(root), slug);
}
return path.join(cwd, '.impeccable');
}
@@ -2139,8 +2145,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
// touched-file list for the Stop deep pass, and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
// no-op on disk (issues #344, #305). An existing cache file also counts
// as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives
// outside the project, so the project dir alone can't carry the marker —
// without this, clean-edit editCount bumps would stop persisting the
// moment state relocates. Under stock paths the cache sits inside
// `.impeccable/`, so the extra check changes nothing there.
if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) {
persistCache(projectCwd, cache);
}