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.
This commit is contained in:
Paul Bakaus
2026-09-02 10:07:41 -07:00
parent 61092e1c58
commit f54f7ce3e5
2 changed files with 58 additions and 0 deletions
+24
View File
@@ -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);
+34
View File
@@ -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' });