Fix: stop the design hook from creating .impeccable/ in unrelated projects (#346)

The PostToolUse hook was writing hook.cache.json after every edit, even
when nothing was scanned or recorded. Gate the persist to earned writes
only, and key the cache to the edited file's project root when the
session starts from an umbrella directory.

Fixes #344, #305

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-07-05 17:11:39 -07:00
committed by GitHub
co-authored by Abdul Wahab Cursor
parent 582f23eae3
commit 751ec31dd6
3 changed files with 184 additions and 7 deletions
+51 -5
View File
@@ -10,7 +10,7 @@
* truthy(value)
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
* normalizeIgnoreValue(value)
* readCache(cwd) / persistCache(cwd, cache)
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
* bumpEditCount(cache, sessionId, filePath) -> number
* suppressionNotice(filePath)
* filterFindings(findings, content, ext, config)
@@ -35,6 +35,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
@@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|| fallback;
}
function looksLikeProjectRoot(dir) {
return ['.git', 'package.json', '.impeccable'].some((marker) => {
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
});
}
// Where `.impeccable/` (cache + config) lives for this event. Normally the
// session cwd, untouched. But when the agent was launched from an umbrella
// directory that is not itself a project (no .git, package.json, or
// .impeccable), key to the edited file's nearest project root instead, so a
// multi-project launch dir doesn't accumulate a shared cross-project cache
// (issue #305). Climbing stops at the home dir, falling back to the session
// cwd when no marker is found.
export function resolveCacheCwd(primaryFile, sessionCwd) {
const base = path.resolve(sessionCwd || process.cwd());
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
if (looksLikeProjectRoot(base)) return base;
let dir;
try {
dir = path.dirname(path.resolve(primaryFile));
} catch {
return base;
}
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return base;
if (looksLikeProjectRoot(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return base;
dir = parent;
}
}
export function readConfig(cwd) {
const config = cloneDefaultConfig();
// Hook runtime settings live under `hook`; detector filters live under
@@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
event = normalizeHookEvent(event, cwd, harness);
audit.harness = harness;
const projectCwd = event.cwd || cwd;
const sessionCwd = event.cwd || cwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
audit.session = event.session_id || null;
@@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
const sessionId = event.session_id || 'unknown';
const det = detector || await loadDetector();
if (!det || typeof det.detectText !== 'function') {
persistCache(projectCwd, cache);
// Cache is not mutated yet at this point; nothing to persist.
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, det, projectCwd);
@@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
let detectorThrewAny = false;
let lastSkip = 'no-scannable-file';
let suppressedHit = false;
let cacheDirty = false;
for (const filePath of targetFiles) {
audit.file = filePath;
@@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (primaryFileSet.has(filePath)) {
const editCount = bumpEditCount(cache, sessionId, filePath);
cacheDirty = true;
audit.editCount = editCount;
if (editCount > EDIT_COUNT_THRESHOLD) {
@@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
cacheDirty = true;
freshGroups.push({ filePath, findings: fresh });
continue;
}
@@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
persistCache(projectCwd, cache);
// Persist only when the write is earned: fresh findings justify creating
// `.impeccable/` (dedup and suppression need it), 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 (freshGroups.length > 0
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];