Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-09-02 18:11:19 +00:00
parent 672ca29642
commit 54f0e641c6
32 changed files with 2016 additions and 96 deletions
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
+52 -4
View File
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
+52 -4
View File
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
+52 -4
View File
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
+52 -4
View File
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
+52 -4
View File
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}
+74 -2
View File
@@ -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)) {
@@ -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": <meta http-equiv="Content-Security-Policy"> 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);
}