From 54f0e641c600f4dd6c199323ae64996ead8cb5ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:11:19 +0000 Subject: [PATCH] Sync generated provider output --- .agents/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .claude/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .cursor/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .gemini/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .github/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .grok/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .hermes/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .kiro/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .pi/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .pi/skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .qoder/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .trae/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .vibe/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- plugin/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- 32 files changed, 2016 insertions(+), 96 deletions(-) diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.agents/skills/impeccable/scripts/detect-csp.mjs b/.agents/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.agents/skills/impeccable/scripts/detect-csp.mjs +++ b/.agents/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.claude/skills/impeccable/scripts/detect-csp.mjs b/.claude/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.claude/skills/impeccable/scripts/detect-csp.mjs +++ b/.claude/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.cursor/skills/impeccable/scripts/detect-csp.mjs b/.cursor/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.cursor/skills/impeccable/scripts/detect-csp.mjs +++ b/.cursor/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.gemini/skills/impeccable/scripts/detect-csp.mjs b/.gemini/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.gemini/skills/impeccable/scripts/detect-csp.mjs +++ b/.gemini/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.github/skills/impeccable/scripts/detect-csp.mjs b/.github/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.github/skills/impeccable/scripts/detect-csp.mjs +++ b/.github/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.grok/skills/impeccable/scripts/context.mjs b/.grok/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.grok/skills/impeccable/scripts/context.mjs +++ b/.grok/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.grok/skills/impeccable/scripts/detect-csp.mjs b/.grok/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.grok/skills/impeccable/scripts/detect-csp.mjs +++ b/.grok/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.hermes/skills/impeccable/scripts/context.mjs b/.hermes/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.hermes/skills/impeccable/scripts/context.mjs +++ b/.hermes/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.hermes/skills/impeccable/scripts/detect-csp.mjs b/.hermes/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.hermes/skills/impeccable/scripts/detect-csp.mjs +++ b/.hermes/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.kiro/skills/impeccable/scripts/detect-csp.mjs b/.kiro/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.kiro/skills/impeccable/scripts/detect-csp.mjs +++ b/.kiro/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.opencode/skills/impeccable/scripts/detect-csp.mjs b/.opencode/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.opencode/skills/impeccable/scripts/detect-csp.mjs +++ b/.opencode/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.pi/skills/impeccable/scripts/detect-csp.mjs b/.pi/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.pi/skills/impeccable/scripts/detect-csp.mjs +++ b/.pi/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.qoder/skills/impeccable/scripts/detect-csp.mjs b/.qoder/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.qoder/skills/impeccable/scripts/detect-csp.mjs +++ b/.qoder/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.rovodev/skills/impeccable/scripts/detect-csp.mjs b/.rovodev/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.rovodev/skills/impeccable/scripts/detect-csp.mjs +++ b/.rovodev/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.trae-cn/skills/impeccable/scripts/detect-csp.mjs b/.trae-cn/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.trae-cn/skills/impeccable/scripts/detect-csp.mjs +++ b/.trae-cn/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.trae/skills/impeccable/scripts/detect-csp.mjs b/.trae/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.trae/skills/impeccable/scripts/detect-csp.mjs +++ b/.trae/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/.vibe/skills/impeccable/scripts/context.mjs b/.vibe/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.vibe/skills/impeccable/scripts/context.mjs +++ b/.vibe/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/.vibe/skills/impeccable/scripts/detect-csp.mjs b/.vibe/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.vibe/skills/impeccable/scripts/detect-csp.mjs +++ b/.vibe/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/scripts/context.mjs @@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) { const absCwd = path.resolve(cwd); const targetDir = resolveTargetDir(absCwd, options); + const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd; + const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null; let repoRoot = findMonorepoRoot(targetDir); + if (!repoRoot && targetGitRoot) { + const cwdGitRoot = findGitBoundaryRoot(absCwd); + if (targetGitRoot !== cwdGitRoot) { + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot, + repoRoot: targetGitRoot, + isMonorepo: false, + }; + } + } if (!repoRoot && targetDir !== absCwd) { const cwdRepoRoot = findMonorepoRoot(absCwd); if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { @@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { } } if (!repoRoot) { + const targetIsExternal = hasTargetOption(options) + && targetDir !== absCwd + && !isPathInside(targetDir, absCwd); + if (targetIsExternal) { + const targetRepoRoot = targetGitRoot || targetDir; + return { + targetDir, + projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot, + repoRoot: targetRepoRoot, + isMonorepo: false, + }; + } return { targetDir, projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, @@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) { }; } +function findGitBoundaryRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (dir === homeDir) return null; + if (hasGitBoundary(dir)) return dir; + 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); @@ -1313,6 +1350,39 @@ function hookEnabledAt(root) { const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); +// 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 produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. +function hookManifestSearchRoots(ctx) { + const roots = []; + const seen = new Set(); + const add = (root) => { + if (!root) return; + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + let current = path.resolve(ctx.projectRoot || process.cwd()); + const home = path.resolve(os.homedir()); + while (true) { + if (current === home) break; + add(current); + if (hasGitBoundary(current)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return roots; +} + function automaticHookMode(ctx) { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { return 'none'; @@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) { const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); if (!hookEnabledAt(activeRoot)) return 'none'; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; - const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))]; - for (const root of roots) { + for (const root of hookManifestSearchRoots(ctx)) { + // A manifest can live above the resolved product. Honor the hook lifecycle + // config beside that manifest before treating it as active coverage. + if (!hookEnabledAt(root)) continue; for (const rel of manifests) { const raw = readJson(path.join(root, rel)); if (raw?.hooks && valueHasHookMarker(raw.hooks)) { diff --git a/plugin/skills/impeccable/scripts/detect-csp.mjs b/plugin/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/plugin/skills/impeccable/scripts/detect-csp.mjs +++ b/plugin/skills/impeccable/scripts/detect-csp.mjs @@ -18,8 +18,9 @@ * Covers: * - Inline Next.js headers() with CSP string * - Nuxt routeRules / nitro.routeRules CSP headers - * - "middleware": CSP set dynamically in middleware.{ts,js}. - * Detected but not auto-patched in v1. + * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or + * Next.js 16's proxy.{ts,js,mjs} convention. Detected + * but not auto-patched in v1. * - "meta-tag": in * layout files. Detected but not auto-patched in v1. * - null: no CSP signals found; no patch needed. @@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [ /\bscript-src\b/, ]; +const NEXT_MIDDLEWARE_FILES = new Set([ + 'middleware.ts', + 'middleware.js', + 'middleware.mjs', +]); +const NEXT_PROXY_FILES = new Set([ + 'proxy.ts', + 'proxy.js', + 'proxy.mjs', +]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, 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/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); +} + /** * @param {string} cwd Project root. * @returns {{ shape: string|null, signals: string[] }} @@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') && - MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); }