diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index d50aae6eb..efd015c15 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1313,13 +1313,11 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); -// Harness project settings are discovered by walking up from the active -// working directory. Context resolution can intentionally select a nested -// product root even when the harness project (and its hook manifest) lives at -// the enclosing git root, so checking only projectRoot/repoRoot produces a -// false MANUAL_DETECTOR_REQUIRED directive. Mirror the ancestor lookup through -// the nearest git boundary, while retaining exact resolved roots for explicit -// targets outside the invoking directory. +// Harness project settings are discovered by walking up from the resolved +// project root. Its hook manifest can live at an enclosing git root, so +// checking only projectRoot/repoRoot produces a false +// MANUAL_DETECTOR_REQUIRED directive. Starting from projectRoot also prevents +// an explicit target from borrowing an unrelated manifest near the caller. function hookManifestSearchRoots(ctx) { const roots = []; const seen = new Set(); @@ -1331,7 +1329,7 @@ function hookManifestSearchRoots(ctx) { roots.push(resolved); }; - let current = path.resolve(process.cwd()); + let current = path.resolve(ctx.projectRoot || process.cwd()); const home = path.resolve(os.homedir()); while (true) { add(current); diff --git a/skill/scripts/detect-csp.mjs b/skill/scripts/detect-csp.mjs index 0b5a19772..88ae27cb3 100644 --- a/skill/scripts/detect-csp.mjs +++ b/skill/scripts/detect-csp.mjs @@ -78,10 +78,12 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; -const NEXT_REQUEST_HOOK_FILES = new Set([ +const NEXT_MIDDLEWARE_FILES = new Set([ 'middleware.ts', 'middleware.js', 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ 'proxy.ts', 'proxy.js', 'proxy.mjs', @@ -89,6 +91,16 @@ const NEXT_REQUEST_HOOK_FILES = new Set([ const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function isNextRequestHookFile(relPath, base) { + if (NEXT_MIDDLEWARE_FILES.has(base)) return true; + if (!NEXT_PROXY_FILES.has(base)) return false; + const normalized = relPath.split(path.sep).join('/').toLowerCase(); + // Next.js 16 recognizes proxy at the project root or in the optional src/ + // directory, alongside app/ or pages/. A same-named helper deeper in the + // tree is not the framework request hook. + return normalized === base || normalized === `src/${base}`; +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -142,7 +154,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if (NEXT_REQUEST_HOOK_FILES.has(base) && MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 95b0a22ef..a698bf369 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1221,6 +1221,39 @@ describe('context.mjs CLI', () => { assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); }); + it('does not borrow a hook manifest from the invoking workspace when targeting a sibling', () => { + const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); + stageContextBundle(scripts, { providerId: 'claude-code' }); + + const repo = path.join(scratch, 'repo'); + const caller = path.join(repo, 'apps', 'marketing'); + const target = path.join(repo, 'apps', 'dashboard'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + fs.mkdirSync(path.join(caller, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(target, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['apps/*'] })); + fs.writeFileSync(path.join(repo, 'turbo.json'), JSON.stringify({ tasks: {} })); + fs.writeFileSync(path.join(caller, 'package.json'), JSON.stringify({ name: 'marketing' })); + fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'dashboard' })); + fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Dashboard\n'); + fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "dashboard"; }\n'); + fs.writeFileSync(path.join(caller, '.claude', 'settings.local.json'), JSON.stringify({ + hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] }, + })); + + 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, /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' }); diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs index 112739427..5d4e8825d 100644 --- a/tests/framework-fixtures.test.mjs +++ b/tests/framework-fixtures.test.mjs @@ -284,3 +284,27 @@ for (const name of listFixtures()) { }); }); } + +describe('detectCsp — Next.js proxy placement', () => { + it('accepts root and src proxy files but ignores same-named nested helpers', () => { + const source = `export function proxy() { + const response = new Response(); + response.headers.set('Content-Security-Policy', "script-src 'self'"); + return response; +}\n`; + for (const [relPath, expectedShape] of [ + ['proxy.ts', 'middleware'], + ['src/proxy.ts', 'middleware'], + ['lib/network/proxy.ts', null], + ]) { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-proxy-placement-')); + try { + mkdirSync(dirname(join(tmp, relPath)), { recursive: true }); + writeFileSync(join(tmp, relPath), source); + assert.equal(detectCsp(tmp).shape, expectedShape, relPath); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + } + }); +});