diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/skill/scripts/context.mjs
+++ b/skill/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
+// Harness project settings are discovered by walking up from the resolved
+// project root. Its hook manifest can live at an enclosing git root, so
+// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
+// directive. Starting from projectRoot also prevents an explicit target from
+// borrowing an unrelated manifest near the caller. The walk itself is the
+// authority: do not append repoRoot afterward, because resolveProject can
+// retain an outer workspace root for a target inside an independent nested
+// Git repository.
+function hookManifestSearchRoots(ctx) {
+ const roots = [];
+ const seen = new Set();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/skill/scripts/detect-csp.mjs b/skill/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/skill/scripts/detect-csp.mjs
+++ b/skill/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/tests/context.test.mjs b/tests/context.test.mjs
index c747164df..7e1d8ca14 100644
--- a/tests/context.test.mjs
+++ b/tests/context.test.mjs
@@ -408,7 +408,7 @@ describe('loadContext (monorepo project context)', () => {
assert.equal(ctx.designPath, null);
});
- it('resolves an explicit root target into a nested-git workspace child', () => {
+ it('keeps an explicit root target inside its nested Git repository', () => {
write('package.json', JSON.stringify({
private: true,
workspaces: ['repos/*'],
@@ -421,13 +421,13 @@ describe('loadContext (monorepo project context)', () => {
const project = path.join(scratch, 'repos', 'standalone');
const ctx = loadContext(scratch, { targetPath: 'repos/standalone/src/App.jsx' });
- assert.equal(ctx.isMonorepo, true);
+ assert.equal(ctx.isMonorepo, false);
assert.equal(ctx.projectRoot, project);
- assert.equal(ctx.repoRoot, scratch);
+ assert.equal(ctx.repoRoot, project);
assert.match(ctx.product, /Standalone product/);
- assert.match(ctx.design, /Outer design/);
+ assert.equal(ctx.design, null);
assert.equal(ctx.productPath, path.join('repos', 'standalone', 'PRODUCT.md'));
- assert.equal(ctx.designPath, 'DESIGN.md');
+ assert.equal(ctx.designPath, null);
});
it('supports double-star workspace patterns by resolving the shallow child project', () => {
@@ -1198,6 +1198,212 @@ describe('context.mjs CLI', () => {
assert.match(disabled.stdout, /detect\.mjs --json /);
});
+ it('finds the active hook manifest at an enclosing harness project root', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const repo = path.join(scratch, 'repo');
+ const project = path.join(repo, 'web');
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
+ fs.mkdirSync(project, { recursive: true });
+ fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Nested web product\n');
+ fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+
+ const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
+ cwd: project,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+
+ fs.mkdirSync(path.join(repo, '.impeccable'), { recursive: true });
+ fs.writeFileSync(path.join(repo, '.impeccable', 'config.local.json'), JSON.stringify({
+ hook: { enabled: false },
+ }));
+ const disabled = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
+ cwd: project,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(disabled.status, 0, disabled.stderr);
+ assert.match(disabled.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
+ it('does not borrow a hook manifest from the invoking workspace when targeting a sibling', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const repo = path.join(scratch, 'repo');
+ const caller = path.join(repo, 'apps', 'marketing');
+ const target = path.join(repo, 'apps', 'dashboard');
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(caller, '.claude'), { recursive: true });
+ fs.mkdirSync(path.join(target, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['apps/*'] }));
+ fs.writeFileSync(path.join(repo, 'turbo.json'), JSON.stringify({ tasks: {} }));
+ fs.writeFileSync(path.join(caller, 'package.json'), JSON.stringify({ name: 'marketing' }));
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'dashboard' }));
+ fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Dashboard\n');
+ fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "dashboard"; }\n');
+ fs.writeFileSync(path.join(caller, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+
+ const res = spawnSync(process.execPath, [
+ path.join(scripts, 'context.mjs'),
+ '--target',
+ path.join(target, 'src', 'App.jsx'),
+ ], {
+ cwd: caller,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
+ it('does not borrow an outer workspace hook for a target in a nested Git repository', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const repo = path.join(scratch, 'repo');
+ const target = path.join(repo, 'repos', 'standalone');
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
+ fs.mkdirSync(path.join(target, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(target, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['repos/*'] }));
+ fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'standalone' }));
+ fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Standalone\n');
+ fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "standalone"; }\n');
+
+ const res = spawnSync(process.execPath, [
+ path.join(scripts, 'context.mjs'),
+ '--target',
+ path.join('repos', 'standalone', 'src', 'App.jsx'),
+ ], {
+ cwd: repo,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /"projectRoot": ".*\/repos\/standalone"/);
+ assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
+ it('treats a markerless nested Git target as an independent repository', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const repo = path.join(scratch, 'repo');
+ const target = path.join(repo, 'repos', 'standalone');
+ fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
+ fs.mkdirSync(path.join(target, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(target, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['repos/*'] }));
+ fs.writeFileSync(path.join(repo, 'PRODUCT.md'), '# Outer product\n');
+ fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+ fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "standalone"; }\n');
+
+ const res = spawnSync(process.execPath, [
+ path.join(scripts, 'context.mjs'),
+ '--target',
+ path.join('repos', 'standalone', 'src', 'App.jsx'),
+ ], {
+ cwd: repo,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /"projectRoot": ".*\/repos\/standalone"/);
+ assert.match(res.stdout, /"repoRoot": ".*\/repos\/standalone"/);
+ assert.doesNotMatch(res.stdout, /# Outer product/);
+ assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
+ it('does not borrow the caller hook for a target in an independent sibling repository', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const caller = path.join(scratch, 'caller');
+ const target = path.join(scratch, 'target');
+ fs.mkdirSync(path.join(caller, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(caller, '.claude'), { recursive: true });
+ fs.mkdirSync(path.join(target, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(target, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(caller, 'PRODUCT.md'), '# Caller\n');
+ fs.writeFileSync(path.join(caller, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+ fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Target\n');
+ fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "target"; }\n');
+
+ const res = spawnSync(process.execPath, [
+ path.join(scripts, 'context.mjs'),
+ '--target',
+ path.join('..', 'target', 'src', 'App.jsx'),
+ ], {
+ cwd: caller,
+ encoding: 'utf8',
+ env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /"projectRoot": ".*\/target"/);
+ assert.match(res.stdout, /"repoRoot": ".*\/target"/);
+ assert.match(res.stdout, /# Target/);
+ assert.doesNotMatch(res.stdout, /# Caller/);
+ assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
+ it('does not treat a home-directory Git checkout as an external target repository', () => {
+ const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
+ stageContextBundle(scripts, { providerId: 'claude-code' });
+
+ const fakeHome = path.join(scratch, 'home');
+ const caller = path.join(fakeHome, 'caller');
+ const target = path.join(fakeHome, 'target');
+ fs.mkdirSync(path.join(fakeHome, '.git'), { recursive: true });
+ fs.mkdirSync(path.join(fakeHome, '.claude'), { recursive: true });
+ fs.mkdirSync(caller, { recursive: true });
+ fs.mkdirSync(target, { recursive: true });
+ fs.writeFileSync(path.join(fakeHome, 'PRODUCT.md'), '# Home product\n');
+ fs.writeFileSync(path.join(fakeHome, '.claude', 'settings.local.json'), JSON.stringify({
+ hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
+ }));
+ fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Target product\n');
+
+ const res = spawnSync(process.execPath, [
+ path.join(scripts, 'context.mjs'),
+ '--target',
+ target,
+ ], {
+ cwd: caller,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ HOME: fakeHome,
+ IMPECCABLE_NO_UPDATE_CHECK: '1',
+ IMPECCABLE_NO_STALENESS_CHECK: '1',
+ },
+ });
+ assert.equal(res.status, 0, res.stderr);
+ assert.match(res.stdout, /"projectRoot": ".*\/target"/);
+ assert.match(res.stdout, /"repoRoot": ".*\/target"/);
+ assert.match(res.stdout, /# Target product/);
+ assert.doesNotMatch(res.stdout, /# Home product/);
+ assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
+ });
+
it('adds no detector directive when a per-edit-only hook is active', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'cursor' });
diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs
index 112739427..ab55a2348 100644
--- a/tests/framework-fixtures.test.mjs
+++ b/tests/framework-fixtures.test.mjs
@@ -284,3 +284,39 @@ for (const name of listFixtures()) {
});
});
}
+
+describe('detectCsp — Next.js proxy placement', () => {
+ it('accepts proxy files at app roots and src roots but ignores same-named helpers', () => {
+ const source = `export function proxy() {
+ const response = new Response();
+ response.headers.set('Content-Security-Policy', "script-src 'self'");
+ return response;
+}\n`;
+ for (const [relPath, expectedShape, markers = []] of [
+ ['proxy.ts', 'middleware'],
+ ['src/proxy.ts', 'middleware'],
+ ['apps/web/proxy.ts', 'middleware', ['apps/web/app']],
+ ['apps/docs/src/proxy.ts', 'middleware', ['apps/docs/src/pages']],
+ ['apps/store/proxy.ts', 'middleware', ['apps/store/package.json']],
+ ['lib/network/proxy.ts', null],
+ ['apps/web/lib/proxy.ts', null, ['apps/web/app']],
+ ]) {
+ const tmp = mkdtempSync(join(tmpdir(), 'impeccable-proxy-placement-'));
+ try {
+ mkdirSync(dirname(join(tmp, relPath)), { recursive: true });
+ for (const marker of markers) {
+ if (marker.endsWith('package.json')) {
+ mkdirSync(dirname(join(tmp, marker)), { recursive: true });
+ writeFileSync(join(tmp, marker), JSON.stringify({ dependencies: { next: '^16.0.0' } }));
+ } else {
+ mkdirSync(join(tmp, marker), { recursive: true });
+ }
+ }
+ writeFileSync(join(tmp, relPath), source);
+ assert.equal(detectCsp(tmp).shape, expectedShape, relPath);
+ } finally {
+ rmSync(tmp, { recursive: true, force: true });
+ }
+ }
+ });
+});
diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md
index a4f871ebb..2251863d6 100644
--- a/tests/framework-fixtures/README.md
+++ b/tests/framework-fixtures/README.md
@@ -94,6 +94,9 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
}
```
+The legacy `middleware` shape name covers CSP set in either Next.js
+`middleware.*` files or the Next.js 16 `proxy.*` convention.
+
The `expectedAfter` file lives alongside `fixture.json` (not inside `files/`) and is a human/agent-review reference — tests don't auto-apply the patch.
The `runtime` block is optional. Fixtures without it only run the static unit checks (is-generated, inject, wrap, csp-detect). Fixtures *with* it additionally run the E2E suite in `tests/live-e2e.test.mjs` (`bun run test:live-e2e`), which:
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx b/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx
new file mode 100644
index 000000000..e53180eeb
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react";
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts b/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts
new file mode 100644
index 000000000..fe03376fb
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts
@@ -0,0 +1,10 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+export function proxy(request: NextRequest) {
+ const response = NextResponse.next({ request });
+ response.headers.set(
+ "Content-Security-Policy",
+ "default-src 'self'; script-src 'self' 'nonce-runtime'; connect-src 'self'",
+ );
+ return response;
+}
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/fixture.json b/tests/framework-fixtures/nextjs-proxy-csp/fixture.json
new file mode 100644
index 000000000..b82c8c5db
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/fixture.json
@@ -0,0 +1,15 @@
+{
+ "name": "Next.js 16 (proxy CSP)",
+ "config": {
+ "files": ["app/layout.tsx"],
+ "insertBefore": "