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": "", + "commentSyntax": "jsx" + }, + "sourceFiles": ["proxy.ts", "app/layout.tsx"], + "generatedFiles": [], + "wrapCases": [], + "csp": { + "shape": "middleware", + "signals": ["proxy.ts:Content-Security-Policy"] + } +} diff --git a/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt b/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt new file mode 100644 index 000000000..7c8ed2342 --- /dev/null +++ b/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +.next/ +out/