From f54f7ce3e57cae3759c43190c7a2254a0f277995 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 2 Sep 2026 10:07:41 -0700 Subject: [PATCH] Resolve external targets from their own repository Scope explicit sibling targets to their own Git root so caller context and hook manifests cannot suppress required detector guidance. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. --- skill/scripts/context.mjs | 24 ++++++++++++++++++++++++ tests/context.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 8146da298..49eff6cab 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -208,6 +208,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = findGitBoundaryRoot(targetDir) || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +235,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (hasGitBoundary(dir)) return dir; + if (dir === homeDir) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + function isPathInside(candidate, root) { const rel = path.relative(root, candidate); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 295c5c178..a46b49f1c 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1298,6 +1298,40 @@ describe('context.mjs CLI', () => { assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); }); + it('does not borrow the caller hook for a target in an independent sibling repository', () => { + const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); + stageContextBundle(scripts, { providerId: 'claude-code' }); + + const caller = path.join(scratch, 'caller'); + const target = path.join(scratch, 'target'); + fs.mkdirSync(path.join(caller, '.git'), { recursive: true }); + fs.mkdirSync(path.join(caller, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(target, '.git'), { recursive: true }); + fs.mkdirSync(path.join(target, 'src'), { recursive: true }); + fs.writeFileSync(path.join(caller, 'PRODUCT.md'), '# Caller\n'); + fs.writeFileSync(path.join(caller, '.claude', 'settings.local.json'), JSON.stringify({ + hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] }, + })); + fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Target\n'); + fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "target"; }\n'); + + const res = spawnSync(process.execPath, [ + path.join(scripts, 'context.mjs'), + '--target', + path.join('..', 'target', 'src', 'App.jsx'), + ], { + cwd: caller, + encoding: 'utf8', + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /"projectRoot": ".*\/target"/); + assert.match(res.stdout, /"repoRoot": ".*\/target"/); + assert.match(res.stdout, /# Target/); + assert.doesNotMatch(res.stdout, /# Caller/); + assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); + }); + it('adds no detector directive when a per-edit-only hook is active', () => { const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); stageContextBundle(scripts, { providerId: 'cursor' });