mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
Fix Next.js 16 CSP and parent hook discovery (#710)
* Fix CSP and hook ancestor discovery Recognize Next.js 16 proxy files when detecting runtime CSP and mirror harness ancestor lookup when locating active hook manifests for nested projects. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Tighten hook and proxy discovery AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Honor ancestor hook disable config AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Keep hook discovery within target repository Stop manifest discovery at the target repository boundary instead of re-adding an outer workspace root, with regression coverage for nested Git targets. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect proxy CSP in nested Next apps Recognize proxy files at root or src placement relative to nested Next project markers while continuing to ignore unrelated proxy helpers. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Resolve external targets from their own repository Scope explicit sibling targets to their own Git root so caller context and hook manifests cannot suppress required detector guidance. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Isolate explicit targets at Git boundaries Keep nested repositories and external targets out of caller and home-level context or hook discovery. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+211
-5
@@ -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 <changed targets>/);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Next.js 16 (proxy CSP)",
|
||||
"config": {
|
||||
"files": ["app/layout.tsx"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "jsx"
|
||||
},
|
||||
"sourceFiles": ["proxy.ts", "app/layout.tsx"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [],
|
||||
"csp": {
|
||||
"shape": "middleware",
|
||||
"signals": ["proxy.ts:Content-Security-Policy"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
Reference in New Issue
Block a user