From a26419917716b16623cc830429f3cc1a4f7cd630 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 2 Sep 2026 11:38:23 -0400 Subject: [PATCH 1/5] Fix skill subcommand help handling (#708) Ensure install, link, update, and check render static help before entering any operational path. Covers top-level and legacy routing for both -h and --help. AI-assisted implementation under maintainer direction. --- cli/bin/commands/skills.mjs | 53 +++++++++++++++++++++++++++++++++++++ tests/skills-cli.test.js | 34 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index e15904104..db76d0c4f 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -525,6 +525,54 @@ async function promptCheckbox(message, options, { selectedValues = [] } = {}) { // ─── skills help ────────────────────────────────────────────────────────────── +const SUBCOMMAND_HELP = { + install: `Usage: impeccable install [options] + +Install compiled Impeccable skills into project or user-level harness folders. + +Options: + -y, --yes Accept detected defaults without prompting + --providers= Comma-separated harnesses to install + --scope= Install scope: project or global + --project Install into the current project + --user, --global Install at the user level + --no-hooks Install skills without provider hook manifests + --force Replace an existing installation + -h, --help Show this help message`, + link: `Usage: impeccable link [options] + +Link Impeccable skills from a local checkout or submodule. + +Options: + --source= Source checkout (default: .impeccable) + --providers= Comma-separated harnesses to link + -y, --yes Accept detected defaults without prompting + --force Replace existing skill folders with links + -h, --help Show this help message`, + update: `Usage: impeccable update [options] + +Update an existing Impeccable skill installation. + +Options: + -y, --yes Accept detected defaults without prompting + --scope= Update scope: project or global + --project Update the current project installation + --user, --global Update the user-level installation + --no-hooks Update skills without changing hook manifests + --force Replace installed skill files + -h, --help Show this help message`, + check: `Usage: impeccable check [options] + +Check whether installed Impeccable skills are up to date. + +Options: + -h, --help Show this help message`, +}; + +function showSubcommandHelp(subcommand) { + console.log(SUBCOMMAND_HELP[subcommand]); +} + async function showHelp() { let commands; try { @@ -2533,6 +2581,11 @@ export { export async function run(args) { const sub = args[0]; + if (SUBCOMMAND_HELP[sub] && args.slice(1).some(arg => arg === '--help' || arg === '-h')) { + showSubcommandHelp(sub); + return; + } + if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { await showHelp(); } else if (sub === 'install') { diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 6524cea5a..5ddb94d23 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -864,6 +864,40 @@ describe('skills install/update: local universal bundle e2e', () => { expect(output).not.toContain('skills install Install impeccable skills'); }); + test('skill-management subcommand help exits before downloads, prompts, or writes (#699)', () => { + const commands = ['install', 'link', 'update', 'check']; + const prefixes = ['', 'skills ']; + const helpFlags = ['--help', '-h']; + + for (const command of commands) { + for (const prefix of prefixes) { + for (const helpFlag of helpFlags) { + const tmp = mkdtempSync(join(tmpdir(), `imp-test-${command}-help-`)); + const home = mkdtempSync(join(tmpdir(), `imp-home-${command}-help-`)); + execSync('git init', { cwd: tmp }); + + const output = run(`${prefix}${command} ${helpFlag}`, { + cwd: tmp, + env: { + ...process.env, + HOME: home, + IMPECCABLE_BUNDLE_PATH: join(tmp, 'must-not-be-read'), + }, + }); + + expect(output).toContain(`Usage: impeccable ${command}`); + for (const provider of ['.agents', '.claude', '.cursor', '.impeccable']) { + expect(existsSync(join(tmp, provider))).toBe(false); + expect(existsSync(join(home, provider))).toBe(false); + } + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + } + } + }, 30000); + test('top-level install aliases the legacy skills install command', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-top-level-install-')); const home = mkdtempSync(join(tmpdir(), 'imp-home-top-level-install-')); From 672ca29642b513bc3365afb0e309fff3d6dfa752 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 2 Sep 2026 14:10:46 -0400 Subject: [PATCH 2/5] 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. --- skill/scripts/context.mjs | 76 +++++- skill/scripts/detect-csp.mjs | 56 ++++- tests/context.test.mjs | 216 +++++++++++++++++- tests/framework-fixtures.test.mjs | 36 +++ tests/framework-fixtures/README.md | 3 + .../nextjs-proxy-csp/files/app/layout.tsx | 9 + .../nextjs-proxy-csp/files/proxy.ts | 10 + .../nextjs-proxy-csp/fixture.json | 15 ++ .../nextjs-proxy-csp/gitignore.txt | 3 + 9 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx create mode 100644 tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts create mode 100644 tests/framework-fixtures/nextjs-proxy-csp/fixture.json create mode 100644 tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt 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/ From 54f0e641c600f4dd6c199323ae64996ead8cb5ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:11:19 +0000 Subject: [PATCH 3/5] Sync generated provider output --- .agents/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .claude/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .cursor/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .gemini/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .github/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .grok/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .hermes/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .kiro/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .pi/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .pi/skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .qoder/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .../skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .trae/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- .vibe/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- plugin/skills/impeccable/scripts/context.mjs | 76 ++++++++++++++++++- .../skills/impeccable/scripts/detect-csp.mjs | 56 +++++++++++++- 32 files changed, 2016 insertions(+), 96 deletions(-) diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/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/.agents/skills/impeccable/scripts/detect-csp.mjs b/.agents/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.agents/skills/impeccable/scripts/detect-csp.mjs +++ b/.agents/skills/impeccable/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/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/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/.claude/skills/impeccable/scripts/detect-csp.mjs b/.claude/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.claude/skills/impeccable/scripts/detect-csp.mjs +++ b/.claude/skills/impeccable/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/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/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/.cursor/skills/impeccable/scripts/detect-csp.mjs b/.cursor/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.cursor/skills/impeccable/scripts/detect-csp.mjs +++ b/.cursor/skills/impeccable/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/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/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/.gemini/skills/impeccable/scripts/detect-csp.mjs b/.gemini/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.gemini/skills/impeccable/scripts/detect-csp.mjs +++ b/.gemini/skills/impeccable/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/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/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/.github/skills/impeccable/scripts/detect-csp.mjs b/.github/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.github/skills/impeccable/scripts/detect-csp.mjs +++ b/.github/skills/impeccable/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/.grok/skills/impeccable/scripts/context.mjs b/.grok/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.grok/skills/impeccable/scripts/context.mjs +++ b/.grok/skills/impeccable/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/.grok/skills/impeccable/scripts/detect-csp.mjs b/.grok/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.grok/skills/impeccable/scripts/detect-csp.mjs +++ b/.grok/skills/impeccable/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/.hermes/skills/impeccable/scripts/context.mjs b/.hermes/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.hermes/skills/impeccable/scripts/context.mjs +++ b/.hermes/skills/impeccable/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/.hermes/skills/impeccable/scripts/detect-csp.mjs b/.hermes/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.hermes/skills/impeccable/scripts/detect-csp.mjs +++ b/.hermes/skills/impeccable/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/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/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/.kiro/skills/impeccable/scripts/detect-csp.mjs b/.kiro/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.kiro/skills/impeccable/scripts/detect-csp.mjs +++ b/.kiro/skills/impeccable/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/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/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/.opencode/skills/impeccable/scripts/detect-csp.mjs b/.opencode/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.opencode/skills/impeccable/scripts/detect-csp.mjs +++ b/.opencode/skills/impeccable/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/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/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/.pi/skills/impeccable/scripts/detect-csp.mjs b/.pi/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.pi/skills/impeccable/scripts/detect-csp.mjs +++ b/.pi/skills/impeccable/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/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/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/.qoder/skills/impeccable/scripts/detect-csp.mjs b/.qoder/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.qoder/skills/impeccable/scripts/detect-csp.mjs +++ b/.qoder/skills/impeccable/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/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/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/.rovodev/skills/impeccable/scripts/detect-csp.mjs b/.rovodev/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.rovodev/skills/impeccable/scripts/detect-csp.mjs +++ b/.rovodev/skills/impeccable/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/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/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/.trae-cn/skills/impeccable/scripts/detect-csp.mjs b/.trae-cn/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.trae-cn/skills/impeccable/scripts/detect-csp.mjs +++ b/.trae-cn/skills/impeccable/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/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/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/.trae/skills/impeccable/scripts/detect-csp.mjs b/.trae/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.trae/skills/impeccable/scripts/detect-csp.mjs +++ b/.trae/skills/impeccable/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/.vibe/skills/impeccable/scripts/context.mjs b/.vibe/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/.vibe/skills/impeccable/scripts/context.mjs +++ b/.vibe/skills/impeccable/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/.vibe/skills/impeccable/scripts/detect-csp.mjs b/.vibe/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/.vibe/skills/impeccable/scripts/detect-csp.mjs +++ b/.vibe/skills/impeccable/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/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index cb16553c2..3afb81b99 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/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/plugin/skills/impeccable/scripts/detect-csp.mjs b/plugin/skills/impeccable/scripts/detect-csp.mjs index a13505d23..1a5664b4d 100644 --- a/plugin/skills/impeccable/scripts/detect-csp.mjs +++ b/plugin/skills/impeccable/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); } From fa44839f7289fced3f51946684656a28775638cc Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 2 Sep 2026 15:45:34 -0400 Subject: [PATCH 4/5] Fix detector URL scans and advisory handling (#709) * Fix detector URL and advisory handling Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Filter linked CSS to rendered selectors Flatten linked stylesheet grouping rules and collect only selector rules that target the live DOM, preventing unused grouped and selector-less patterns from leaking into URL findings. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Fix detector review edge cases AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Preserve unresolved linked CSS selectors AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Fix linked CSS selector filtering Resolve pseudo-element selectors to live hosts, reject unresolvable linked CSS findings, and make the regression assertions independent. Also ignore comment delimiters when recovering CSS rule selectors. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Skip unresolved container query CSS Exclude linked container-query groups when their current applicability cannot be resolved, with a browser regression proving inactive styles do not leak. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect active container query CSS Use a temporary custom-property probe so the browser decides whether a nested style rule actually applies in the current container layout. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Filter inactive linked CSS states Keep valid empty pseudo-class matches authoritative and omit selector-less linked at-rules that cannot be tied to rendered nodes. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Parse pseudo-elements without rewriting literals Preserve quoted attribute values and escaped identifiers while resolving real pseudo-elements to live hosts. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Restore live linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Handle grouped linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Respect keyframe definition order AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Resolve effective linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Fix keyframe easing detection Serialize effective per-keyframe easing back into the linked stylesheet corpus so overshoot motion is detected. Add a browser regression with a neutral animation name.\n\nAI assistance disclosure: Codex helped implement and test this fix under maintainer direction. --- README.md | 2 + cli/engine/browser/injected/index.mjs | 400 ++++++++++++++++- cli/engine/cli/main.mjs | 32 +- cli/engine/detect-antipatterns-browser.js | 424 +++++++++++++++++- cli/engine/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- cli/engine/findings.mjs | 11 +- cli/engine/registry/antipatterns.mjs | 7 +- cli/engine/rules/checks.mjs | 22 +- skill/scripts/hook-lib.mjs | 13 +- tests/detect-antipatterns-browser.test.mjs | 183 ++++++++ tests/detect-antipatterns.test.js | 35 +- tests/detect-cli-stdin-dispatch.test.mjs | 10 +- .../antipatterns/linked-url-patterns.css | 192 ++++++++ .../antipatterns/linked-url-patterns.html | 32 ++ tests/hook.test.mjs | 3 + 16 files changed, 1302 insertions(+), 72 deletions(-) create mode 100644 tests/fixtures/antipatterns/linked-url-patterns.css create mode 100644 tests/fixtures/antipatterns/linked-url-patterns.html diff --git a/README.md b/README.md index b957b7e5b..69cb68639 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,8 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font" The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more). +Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports. + By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution. For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: ``. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`. diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index febf7f297..76fc558e5 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1228,14 +1228,17 @@ if (IS_BROWSER) { isHidden: isElementHidden(el), findings: findings.map(f => { const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + const severity = f.severity || ap?.severity || 'warning'; return { type: f.type || f.id, category: ap ? ap.category : 'quality', - severity: f.severity || ap?.severity || 'warning', + severity, // Advisory findings (em-dash overuse, etc.) are surfaced but never // treated as failures; carry the flag so the overlay/extension can // render them with the mildest affordance and consumers can filter. - advisory: (ap && ap.advisory === true) || f.advisory === true, + // Per-finding promotions override the registry default, so derive + // this strictly from the effective severity. + advisory: severity === 'advisory', detail: f.detail || f.snippet, ignoreValue: f.ignoreValue || f.value || '', name: ap ? ap.name : (f.type || f.id), @@ -1277,6 +1280,381 @@ if (IS_BROWSER) { else groupMap.set(el, [...kept]); } + function pseudoElementHostSelector(selector) { + const raw = String(selector || ''); + const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']); + const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || ''); + const consumeFunction = (start) => { + let depth = 0; + let quote = ''; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === '\\') { + i += 1; + continue; + } + if (quote) { + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '(') depth += 1; + if (char === ')' && --depth === 0) return i + 1; + } + return raw.length; + }; + + let output = ''; + let found = false; + for (let i = 0; i < raw.length;) { + const char = raw[i]; + if (char === '\\') { + output += raw.slice(i, Math.min(raw.length, i + 2)); + i += 2; + continue; + } + if (char === '"' || char === "'") { + const quote = char; + const start = i; + i += 1; + while (i < raw.length) { + if (raw[i] === '\\') { + i += 2; + continue; + } + const value = raw[i]; + i += 1; + if (value === quote) break; + } + output += raw.slice(start, i); + continue; + } + if (char !== ':') { + output += char; + i += 1; + continue; + } + + let end = i + 1; + let isPseudoElement = false; + if (raw[end] === ':') { + end += 1; + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = end > nameStart; + } else { + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase()); + } + if (!isPseudoElement) { + output += char; + i += 1; + continue; + } + if (raw[end] === '(') end = consumeFunction(end); + found = true; + if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*'; + i = end; + } + if (!found) return null; + return output.trim().replace(/,\s*(?=,|$)/g, ''); + } + + function selectorNodesForLiveDom(root, selector) { + const raw = String(selector || '').trim(); + if (!raw) return null; + const fallback = pseudoElementHostSelector(raw); + if (fallback == null) { + // An empty result from a valid full selector is authoritative. In + // particular, do not broaden inactive :hover/:focus/:not() rules to + // their host element by stripping pseudo-classes. + try { return Array.from(root.querySelectorAll(raw)); } + catch { return null; } + } + + // Resolve pseudo-elements to their originating live elements. An attached + // pseudo-element (`.card::before`) belongs to the element before it, while + // a hostless pseudo-element after a combinator (`main > ::before`) belongs + // to a matching element at that position (`main > *`). Replacing every + // pseudo indiscriminately with an empty string leaves the latter as the + // invalid selector `main >` and makes absent hosts indistinguishable from + // selectors the DOM API cannot parse. + if (!fallback || /^[,\s]*$/.test(fallback)) return null; + try { return Array.from(root.querySelectorAll(fallback)); } + catch { return null; } + } + + let containerProbeSequence = 0; + + function isContainerCssRule(rule) { + return rule?.constructor?.name === 'CSSContainerRule' + || /^\s*@container\b/i.test(rule?.cssText || ''); + } + + function styleRuleAppliesToLiveMatches(rule, matches) { + const style = rule?.style; + if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false; + const sequence = ++containerProbeSequence; + const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`; + const value = `impeccable-container-active-${sequence}`; + const previousValue = style.getPropertyValue(property); + const previousPriority = style.getPropertyPriority(property); + try { + style.setProperty(property, value, 'important'); + } catch { + return false; + } + + const pseudoElements = [...new Set( + String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [], + )]; + try { + return matches.some(el => [null, ...pseudoElements].some(pseudo => { + try { + const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el); + return computed.getPropertyValue(property).trim() === value; + } catch { + return false; + } + })); + } finally { + if (previousValue) style.setProperty(property, previousValue, previousPriority); + else style.removeProperty(property); + } + } + + function conditionalCssRuleIsActive(rule) { + const type = Number(rule?.type); + const constructorName = rule?.constructor?.name || ''; + if (constructorName === 'CSSMediaRule' || type === 4) { + const condition = rule.conditionText || rule.media?.mediaText || ''; + if (!condition || typeof window.matchMedia !== 'function') return true; + try { return window.matchMedia(condition).matches; } + catch { return true; } + } + if (constructorName === 'CSSSupportsRule' || type === 12) { + const condition = rule.conditionText || ''; + if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true; + try { return CSS.supports(condition); } + catch { return true; } + } + return true; + } + + function splitCssCommaList(value) { + const parts = []; + let current = ''; + let quote = ''; + let escaped = false; + for (const char of String(value || '')) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\') { + current += char; + escaped = true; + continue; + } + if (quote) { + current += char; + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === ',') { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + return parts; + } + + function normalizeAnimationName(value) { + const name = String(value || '').trim(); + if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) { + return name.slice(1, -1); + } + return name; + } + + function animationNamesDeclaredByRule(rule) { + const style = rule?.style; + if (!style) return []; + let value = ''; + try { + value = style.animationName + || style.getPropertyValue?.('animation-name') + || style.webkitAnimationName + || style.getPropertyValue?.('-webkit-animation-name') + || ''; + } catch { + return []; + } + return splitCssCommaList(value) + .map(normalizeAnimationName) + .filter(name => name && name.toLowerCase() !== 'none'); + } + + function keyframesRuleName(rule, cssText) { + const constructorName = rule?.constructor?.name || ''; + const type = Number(rule?.type); + const isKeyframes = constructorName === 'CSSKeyframesRule' + || constructorName === 'WebKitCSSKeyframesRule' + || type === 7 + || /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText); + if (!isKeyframes) return ''; + const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i); + return normalizeAnimationName(rule?.name || match?.[1] || ''); + } + + function cssPropertyName(property) { + if (property.startsWith('--')) return property; + return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); + } + + function resolvedAnimationKeyframes(candidateNames) { + if (typeof document.getAnimations !== 'function') return null; + let animations; + try { animations = document.getAnimations(); } + catch { return null; } + + const resolved = new Map(); + const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']); + for (const animation of animations) { + const name = normalizeAnimationName(animation?.animationName || ''); + if (!name || !candidateNames.has(name) || resolved.has(name)) continue; + let frames; + try { frames = animation.effect?.getKeyframes?.() || []; } + catch { continue; } + const blocks = []; + for (const frame of frames) { + const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset; + if (!Number.isFinite(rawOffset)) continue; + const offset = Math.round(rawOffset * 1000000) / 10000; + const declarations = Object.entries(frame) + .filter(([property, value]) => !metadata.has(property) && value != null && value !== '') + .map(([property, value]) => `${cssPropertyName(property)}: ${value};`); + const easing = String(frame.easing || '').trim(); + if (easing && easing.toLowerCase() !== 'linear') { + declarations.push(`animation-timing-function: ${easing};`); + } + if (declarations.length === 0) continue; + blocks.push(`${offset}% { ${declarations.join(' ')} }`); + } + if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`); + } + return resolved; + } + + // Read CSS that is absent from document.outerHTML. Inline
${primary}${secondary}
`); + await fontPage.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; }); + await fontPage.evaluate(browserScript); + const fontFindings = await fontPage.evaluate(() => window.impeccableDetect({ serialize: true }) + .flatMap(group => group.findings || []) + .filter(finding => finding.type === 'overused-font')); + assert.equal(fontFindings.length, 1, JSON.stringify(fontFindings)); + assert.match(fontFindings[0].detail, /Primary font: geist \(82% of text\)/i); + assert.doesNotMatch(fontFindings[0].detail, /geist mono/i); + await fontPage.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + // Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and // color(srgb ...) verbatim in getComputedStyle output, so a detector that // cannot parse those reads every surface as unset, walks out of the page, diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 9ad3a7a47..30c5e8f18 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -2717,23 +2717,30 @@ describe('CLI', () => { expect(code).toBe(0); expect(stdout).toContain('Usage:'); expect(stdout).toContain('--quiet'); + expect(stdout).toContain('Human-readable findings go to stderr'); expect(stdout).not.toContain('--gpt'); expect(stdout).not.toContain('--gemini'); }); - test('generated-UI tells run by default in the CLI', () => { + test('severity advisory is non-blocking, flagged in JSON, and suppressible', () => { const { stdout, code } = run('--json', path.join(FIXTURES, 'gpt-tells.html')); - expect(code).toBe(2); - const ids = JSON.parse(stdout).map(f => f.antipattern); + expect(code).toBe(0); + const findings = JSON.parse(stdout); + const ids = findings.map(f => f.antipattern); expect(ids).toContain('gpt-thin-border-wide-shadow'); expect(ids).toContain('repeating-stripes-gradient'); expect(ids).toContain('codex-grid-background'); expect(ids).toContain('theater-slop-phrase'); + expect(findings.every(f => f.severity === 'advisory' && f.advisory === true)).toBe(true); + + const hidden = run('--json', '--no-advisory', path.join(FIXTURES, 'gpt-tells.html')); + expect(hidden.code).toBe(0); + expect(JSON.parse(hidden.stdout)).toEqual([]); }); test('legacy provider flags are accepted as deprecated no-ops', () => { const { stdout, stderr, code } = run('--gpt', '--json', path.join(FIXTURES, 'gpt-tells.html')); - expect(code).toBe(2); + expect(code).toBe(0); expect(stderr).toContain('--gpt and --gemini are deprecated and ignored'); expect(JSON.parse(stdout).some(f => f.antipattern === 'codex-grid-background')).toBe(true); }); @@ -2744,14 +2751,30 @@ describe('CLI', () => { expect(stderr).not.toContain('cannot access detect'); }); + test('keeps a local path containing spaces as one scan target', () => { + const fixture = writeStaticFixture({ + 'page with spaces.html': '

Plain page

', + }); + const file = path.join(fixture.dir, 'page with spaces.html'); + try { + const { stdout, stderr, code } = run('--json', file); + expect(code).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + expect(stderr).not.toContain('cannot access'); + } finally { + fs.rmSync(fixture.dir, { recursive: true, force: true }); + } + }); + test('should-pass exits 0', () => { const { code } = run(path.join(FIXTURES, 'should-pass.html')); expect(code).toBe(0); }); test('should-flag exits 2 with findings', () => { - const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html')); + const { stdout, code, stderr } = run(path.join(FIXTURES, 'should-flag.html')); expect(code).toBe(2); + expect(stdout).toBe(''); expect(stderr).toContain('side-tab'); }); @@ -2899,7 +2922,7 @@ colors: `); const full = runIn(dir, '--json', 'index.css'); - expect(full.code).toBe(2); + expect(full.code).toBe(0); const fullIds = JSON.parse(full.stdout).map((finding) => finding.antipattern); expect(fullIds).toContain('design-system-font-size'); expect(fullIds).toContain('design-system-color'); diff --git a/tests/detect-cli-stdin-dispatch.test.mjs b/tests/detect-cli-stdin-dispatch.test.mjs index 2481d00f8..a0ee03e34 100644 --- a/tests/detect-cli-stdin-dispatch.test.mjs +++ b/tests/detect-cli-stdin-dispatch.test.mjs @@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const cli = path.join(root, 'cli', 'bin', 'cli.js'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stdin-dispatch-')); -function detectStdinFile(filePath) { +function detectStdinFile(filePath, expectedStatus = 2) { const result = spawnSync( process.execPath, [cli, 'detect', '--json', '--no-config', '--no-design-system'], @@ -19,7 +19,7 @@ function detectStdinFile(filePath) { encoding: 'utf8', }, ); - assert.equal(result.status, 2, result.stderr); + assert.equal(result.status, expectedStatus, result.stderr); return JSON.parse(result.stdout); } @@ -57,9 +57,11 @@ describe('detect CLI stdin file dispatch', () => { } `); - const findings = detectStdinFile(filePath); + const findings = detectStdinFile(filePath, 0); assert.ok(findings.some( - (item) => item.file === filePath && item.antipattern === 'codex-grid-background', + (item) => item.file === filePath + && item.antipattern === 'codex-grid-background' + && item.advisory === true, )); }); }); diff --git a/tests/fixtures/antipatterns/linked-url-patterns.css b/tests/fixtures/antipatterns/linked-url-patterns.css new file mode 100644 index 000000000..c0f750408 --- /dev/null +++ b/tests/fixtures/antipatterns/linked-url-patterns.css @@ -0,0 +1,192 @@ +@media (min-width: 1px) { + [data-token="::before"] { + width: 160px; + height: 80px; + background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px); + background-size: 72px 72px; + } +} + +body { + background: #111; + color: #fff; +} + +/* Literal pseudo-element text inside an attribute value is data, not selector + syntax. This rule has no live match even though an empty-value decoy does. */ +[data-decoy="::before"] { + color: #7c3aed; +} + +/* Escaped colons are identifier data, not a legacy pseudo-element. */ +.\:\:before { + width: 240px; + height: 160px; + clip-path: polygon(2% 4%, 17% 1%, 31% 7%, 47% 3%, 62% 9%, 79% 2%, 96% 13%, 91% 31%, 98% 49%, 89% 68%, 95% 87%, 74% 96%, 51% 91%, 29% 98%, 8% 84%, 3% 61%); +} + +/* A valid hostless pseudo-element selector cannot be queried through the DOM + selector API. It must remain in the corpus rather than count as unused. */ +main > ::before { + content: "Rendered pseudo-element text"; + display: block; + width: 160px; + animation: bounce-linked-pseudo 1s ease-in-out infinite; +} + +@keyframes bounce-linked-pseudo { + 50% { transform: translateY(2px); } +} + +.linked-marquee { + animation: linked-horizontal-loop 8s linear infinite; +} + +@keyframes linked-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +.linked-keyframe-overshoot { + animation: linked-keyframe-curve 2s linear infinite; +} + +@keyframes linked-keyframe-curve { + from { + transform: translateY(0); + animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); + } + to { transform: translateY(2px); } +} + +.overridden-keyframes-animation { + animation: overridden-horizontal-loop 2s linear infinite; +} + +@keyframes overridden-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +/* The later same-name definition is the one Chromium renders. */ +@keyframes overridden-horizontal-loop { + 50% { opacity: 0.4; } +} + +@layer linked-keyframes-low, linked-keyframes-high; + +.layered-keyframes-animation { + animation: layered-horizontal-loop 2s linear infinite; +} + +@layer linked-keyframes-high { + @keyframes layered-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } +} + +/* Lower layer appears later in source, but does not override the high layer. */ +@layer linked-keyframes-low { + @keyframes layered-horizontal-loop { + 50% { opacity: 0.4; } + } +} + +.linked-pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + animation: linked-signal-cycle 1.5s ease-in-out infinite; +} + +@keyframes linked-signal-cycle { + 50% { opacity: 0.35; } +} + +/* Chromium makes nested keyframes globally available even while the enclosing + container condition is false, so this live reference must still scan. */ +.inactive-container-animation-reference { + animation: inactive-container-horizontal-loop 8s linear infinite; +} + +@container (width > 2000px) { + @keyframes inactive-container-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } +} + +.active-container-animation-reference { + animation: active-container-horizontal-loop 8s linear infinite; +} + +/* The declaration is intentionally outside the container group. */ +@container (width > 900px) { + @keyframes active-container-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } +} + +/* A pseudo-element whose originating element is absent must remain outside + live URL findings instead of being retained as an unresolvable selector. */ +.absent > ::before { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); +} + +/* These selectors exist in the live DOM, but their conditions are inactive. + URL scans must not treat their declarations as rendered page styles. */ +@media (max-width: 1px) { + .inactive-media-stripes { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); + } +} + +@supports (display: imaginary-layout) { + .inactive-supports-stripes { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); + } +} + +/* The host exists, but the complete pseudo-class selector is inactive. */ +.inactive-pseudo-stripes:not(.active) { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); +} + +.container-query-host { + container-type: inline-size; + width: 240px; +} + +.container-query-host-active { + width: 960px; +} + +@container (width > 900px) { + .inactive-container-stripes { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); + } + + .active-container-halo { + width: 640px; + height: 400px; + background: radial-gradient(circle, rgba(80, 111, 255, 0.85), transparent 70%); + } +} + +/* Non-selector at-rules in an inactive container must not enter page-level + pattern scans just because their CSSOM text is readable. */ +@container (width > 2000px) { + @keyframes inactive-container-gradient-text { + from { + background: linear-gradient(90deg, #111, #999); + background-clip: text; + } + to { background: none; } + } +} + +.unused-linked-transition { + transition: width 200ms ease; +} diff --git a/tests/fixtures/antipatterns/linked-url-patterns.html b/tests/fixtures/antipatterns/linked-url-patterns.html new file mode 100644 index 000000000..eebb3acc5 --- /dev/null +++ b/tests/fixtures/antipatterns/linked-url-patterns.html @@ -0,0 +1,32 @@ + + + + + Linked URL pattern detection + + + +
+

Linked stylesheet pattern

+
Rendered decorative grid
+
Empty attribute-value decoy
+
Escaped identifier selector
+
Rendered linked marquee animation
+
Rendered linked keyframe easing
+
Overridden linked keyframes
+
Layer-priority linked keyframes
+
+
Inactive media stripes
+
Inactive supports stripes
+
Inactive pseudo-class stripes
+
+
Inactive container-query stripes
+
False-container keyframes reference
+
+
+
Active container-query halo
+
Active container keyframes reference
+
+
+ + diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index ee12a5876..660b43817 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -665,6 +665,7 @@ describe('filterFindings()', () => { const filtered = filterFindings(findings, content, '.ts', { ignoreRules: ['side-tab'], minSeverity: 'error', + advisoryRules: 'include', limits: DEFAULT_CONFIG.limits, }); assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']); @@ -674,6 +675,7 @@ describe('filterFindings()', () => { const findings = [ finding('side-tab', 1), finding('em-dash-overuse', 2), + finding('design-system-radius', 3, { severity: 'advisory' }), finding('gradient-text', 3), ]; const filtered = filterFindings(findings, '', '.html', { @@ -700,6 +702,7 @@ describe('filterFindings()', () => { assert.ok(ADVISORY_RULES.has('em-dash-overuse')); assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true); assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true); + assert.equal(isAdvisoryFinding({ antipattern: 'anything', severity: 'advisory' }), true); assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false); }); From 0330f61cef1c88291755beb373c81bef5f15be70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:46:15 +0000 Subject: [PATCH 5/5] Sync generated provider output --- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .grok/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .kiro/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .pi/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .qoder/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .../skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .trae/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- .vibe/skills/impeccable/scripts/hook-lib.mjs | 13 +- .../detector/browser/injected/index.mjs | 400 ++++++++++++++++- .../impeccable/scripts/detector/cli/main.mjs | 32 +- .../detector/detect-antipatterns-browser.js | 424 +++++++++++++++++- .../detector/engines/browser/detect-url.mjs | 4 +- .../engines/static-html/detect-html.mjs | 4 +- .../impeccable/scripts/detector/findings.mjs | 11 +- .../detector/registry/antipatterns.mjs | 7 +- .../scripts/detector/rules/checks.mjs | 22 +- plugin/skills/impeccable/scripts/hook-lib.mjs | 13 +- 144 files changed, 13680 insertions(+), 992 deletions(-) diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index febf7f297..76fc558e5 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1228,14 +1228,17 @@ if (IS_BROWSER) { isHidden: isElementHidden(el), findings: findings.map(f => { const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + const severity = f.severity || ap?.severity || 'warning'; return { type: f.type || f.id, category: ap ? ap.category : 'quality', - severity: f.severity || ap?.severity || 'warning', + severity, // Advisory findings (em-dash overuse, etc.) are surfaced but never // treated as failures; carry the flag so the overlay/extension can // render them with the mildest affordance and consumers can filter. - advisory: (ap && ap.advisory === true) || f.advisory === true, + // Per-finding promotions override the registry default, so derive + // this strictly from the effective severity. + advisory: severity === 'advisory', detail: f.detail || f.snippet, ignoreValue: f.ignoreValue || f.value || '', name: ap ? ap.name : (f.type || f.id), @@ -1277,6 +1280,381 @@ if (IS_BROWSER) { else groupMap.set(el, [...kept]); } + function pseudoElementHostSelector(selector) { + const raw = String(selector || ''); + const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']); + const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || ''); + const consumeFunction = (start) => { + let depth = 0; + let quote = ''; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === '\\') { + i += 1; + continue; + } + if (quote) { + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '(') depth += 1; + if (char === ')' && --depth === 0) return i + 1; + } + return raw.length; + }; + + let output = ''; + let found = false; + for (let i = 0; i < raw.length;) { + const char = raw[i]; + if (char === '\\') { + output += raw.slice(i, Math.min(raw.length, i + 2)); + i += 2; + continue; + } + if (char === '"' || char === "'") { + const quote = char; + const start = i; + i += 1; + while (i < raw.length) { + if (raw[i] === '\\') { + i += 2; + continue; + } + const value = raw[i]; + i += 1; + if (value === quote) break; + } + output += raw.slice(start, i); + continue; + } + if (char !== ':') { + output += char; + i += 1; + continue; + } + + let end = i + 1; + let isPseudoElement = false; + if (raw[end] === ':') { + end += 1; + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = end > nameStart; + } else { + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase()); + } + if (!isPseudoElement) { + output += char; + i += 1; + continue; + } + if (raw[end] === '(') end = consumeFunction(end); + found = true; + if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*'; + i = end; + } + if (!found) return null; + return output.trim().replace(/,\s*(?=,|$)/g, ''); + } + + function selectorNodesForLiveDom(root, selector) { + const raw = String(selector || '').trim(); + if (!raw) return null; + const fallback = pseudoElementHostSelector(raw); + if (fallback == null) { + // An empty result from a valid full selector is authoritative. In + // particular, do not broaden inactive :hover/:focus/:not() rules to + // their host element by stripping pseudo-classes. + try { return Array.from(root.querySelectorAll(raw)); } + catch { return null; } + } + + // Resolve pseudo-elements to their originating live elements. An attached + // pseudo-element (`.card::before`) belongs to the element before it, while + // a hostless pseudo-element after a combinator (`main > ::before`) belongs + // to a matching element at that position (`main > *`). Replacing every + // pseudo indiscriminately with an empty string leaves the latter as the + // invalid selector `main >` and makes absent hosts indistinguishable from + // selectors the DOM API cannot parse. + if (!fallback || /^[,\s]*$/.test(fallback)) return null; + try { return Array.from(root.querySelectorAll(fallback)); } + catch { return null; } + } + + let containerProbeSequence = 0; + + function isContainerCssRule(rule) { + return rule?.constructor?.name === 'CSSContainerRule' + || /^\s*@container\b/i.test(rule?.cssText || ''); + } + + function styleRuleAppliesToLiveMatches(rule, matches) { + const style = rule?.style; + if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false; + const sequence = ++containerProbeSequence; + const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`; + const value = `impeccable-container-active-${sequence}`; + const previousValue = style.getPropertyValue(property); + const previousPriority = style.getPropertyPriority(property); + try { + style.setProperty(property, value, 'important'); + } catch { + return false; + } + + const pseudoElements = [...new Set( + String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [], + )]; + try { + return matches.some(el => [null, ...pseudoElements].some(pseudo => { + try { + const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el); + return computed.getPropertyValue(property).trim() === value; + } catch { + return false; + } + })); + } finally { + if (previousValue) style.setProperty(property, previousValue, previousPriority); + else style.removeProperty(property); + } + } + + function conditionalCssRuleIsActive(rule) { + const type = Number(rule?.type); + const constructorName = rule?.constructor?.name || ''; + if (constructorName === 'CSSMediaRule' || type === 4) { + const condition = rule.conditionText || rule.media?.mediaText || ''; + if (!condition || typeof window.matchMedia !== 'function') return true; + try { return window.matchMedia(condition).matches; } + catch { return true; } + } + if (constructorName === 'CSSSupportsRule' || type === 12) { + const condition = rule.conditionText || ''; + if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true; + try { return CSS.supports(condition); } + catch { return true; } + } + return true; + } + + function splitCssCommaList(value) { + const parts = []; + let current = ''; + let quote = ''; + let escaped = false; + for (const char of String(value || '')) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === '\\') { + current += char; + escaped = true; + continue; + } + if (quote) { + current += char; + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === ',') { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + return parts; + } + + function normalizeAnimationName(value) { + const name = String(value || '').trim(); + if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) { + return name.slice(1, -1); + } + return name; + } + + function animationNamesDeclaredByRule(rule) { + const style = rule?.style; + if (!style) return []; + let value = ''; + try { + value = style.animationName + || style.getPropertyValue?.('animation-name') + || style.webkitAnimationName + || style.getPropertyValue?.('-webkit-animation-name') + || ''; + } catch { + return []; + } + return splitCssCommaList(value) + .map(normalizeAnimationName) + .filter(name => name && name.toLowerCase() !== 'none'); + } + + function keyframesRuleName(rule, cssText) { + const constructorName = rule?.constructor?.name || ''; + const type = Number(rule?.type); + const isKeyframes = constructorName === 'CSSKeyframesRule' + || constructorName === 'WebKitCSSKeyframesRule' + || type === 7 + || /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText); + if (!isKeyframes) return ''; + const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i); + return normalizeAnimationName(rule?.name || match?.[1] || ''); + } + + function cssPropertyName(property) { + if (property.startsWith('--')) return property; + return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); + } + + function resolvedAnimationKeyframes(candidateNames) { + if (typeof document.getAnimations !== 'function') return null; + let animations; + try { animations = document.getAnimations(); } + catch { return null; } + + const resolved = new Map(); + const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']); + for (const animation of animations) { + const name = normalizeAnimationName(animation?.animationName || ''); + if (!name || !candidateNames.has(name) || resolved.has(name)) continue; + let frames; + try { frames = animation.effect?.getKeyframes?.() || []; } + catch { continue; } + const blocks = []; + for (const frame of frames) { + const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset; + if (!Number.isFinite(rawOffset)) continue; + const offset = Math.round(rawOffset * 1000000) / 10000; + const declarations = Object.entries(frame) + .filter(([property, value]) => !metadata.has(property) && value != null && value !== '') + .map(([property, value]) => `${cssPropertyName(property)}: ${value};`); + const easing = String(frame.easing || '').trim(); + if (easing && easing.toLowerCase() !== 'linear') { + declarations.push(`animation-timing-function: ${easing};`); + } + if (declarations.length === 0) continue; + blocks.push(`${offset}% { ${declarations.join(' ')} }`); + } + if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`); + } + return resolved; + } + + // Read CSS that is absent from document.outerHTML. Inline