mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5712d78255 | ||
|
|
f54f7ce3e5 | ||
|
|
61092e1c58 | ||
|
|
f9d28b57bd | ||
|
|
c2404197e4 | ||
|
|
0e1f94acae | ||
|
|
be43532192 |
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
|||||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||||
const absCwd = path.resolve(cwd);
|
const absCwd = path.resolve(cwd);
|
||||||
const targetDir = resolveTargetDir(absCwd, options);
|
const targetDir = resolveTargetDir(absCwd, options);
|
||||||
|
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
|
||||||
|
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
|
||||||
let repoRoot = findMonorepoRoot(targetDir);
|
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) {
|
if (!repoRoot && targetDir !== absCwd) {
|
||||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||||
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!repoRoot) {
|
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 {
|
return {
|
||||||
targetDir,
|
targetDir,
|
||||||
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
|
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) {
|
function isPathInside(candidate, root) {
|
||||||
const rel = path.relative(root, candidate);
|
const rel = path.relative(root, candidate);
|
||||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
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']);
|
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) {
|
function automaticHookMode(ctx) {
|
||||||
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
|
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
|
||||||
return 'none';
|
return 'none';
|
||||||
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
|
|||||||
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
|
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
|
||||||
if (!hookEnabledAt(activeRoot)) return 'none';
|
if (!hookEnabledAt(activeRoot)) return 'none';
|
||||||
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
|
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 hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// 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) {
|
for (const rel of manifests) {
|
||||||
const raw = readJson(path.join(root, rel));
|
const raw = readJson(path.join(root, rel));
|
||||||
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
|
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
* Covers:
|
* Covers:
|
||||||
* - Inline Next.js headers() with CSP string
|
* - Inline Next.js headers() with CSP string
|
||||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* 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
|
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
|
||||||
* layout files. Detected but not auto-patched in v1.
|
* layout files. Detected but not auto-patched in v1.
|
||||||
* - null: no CSP signals found; no patch needed.
|
* - null: no CSP signals found; no patch needed.
|
||||||
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
|
|||||||
/\bscript-src\b/,
|
/\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||||
const META_TAG_HINT = /http-equiv\s*=\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.
|
* @param {string} cwd Project root.
|
||||||
* @returns {{ shape: string|null, signals: string[] }}
|
* @returns {{ shape: string|null, signals: string[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+211
-5
@@ -408,7 +408,7 @@ describe('loadContext (monorepo project context)', () => {
|
|||||||
assert.equal(ctx.designPath, null);
|
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({
|
write('package.json', JSON.stringify({
|
||||||
private: true,
|
private: true,
|
||||||
workspaces: ['repos/*'],
|
workspaces: ['repos/*'],
|
||||||
@@ -421,13 +421,13 @@ describe('loadContext (monorepo project context)', () => {
|
|||||||
|
|
||||||
const project = path.join(scratch, 'repos', 'standalone');
|
const project = path.join(scratch, 'repos', 'standalone');
|
||||||
const ctx = loadContext(scratch, { targetPath: 'repos/standalone/src/App.jsx' });
|
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.projectRoot, project);
|
||||||
assert.equal(ctx.repoRoot, scratch);
|
assert.equal(ctx.repoRoot, project);
|
||||||
assert.match(ctx.product, /Standalone product/);
|
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.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', () => {
|
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>/);
|
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', () => {
|
it('adds no detector directive when a per-edit-only hook is active', () => {
|
||||||
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
|
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
|
||||||
stageContextBundle(scripts, { providerId: 'cursor' });
|
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 `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:
|
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