/** * Path helpers for PRR. Used when building allowedPaths / TARGET FILE(S) so we never * send absolute or internal paths to the fixer (avoids "file outside workdir" and wasted LLM calls). */ import { join } from 'path';import { existsSync } from 'fs'; /** Segments that indicate an internal path not under the repo (e.g. .cursor plans, .prr state). */const INTERNAL_PATH_SEGMENTS = ['.cursor', '.prr', 'root']; /** * Extension variants to try when the review path is not found (pill-output #9). * Maps "given extension" -> [candidate extensions to try]. E.g. tsconfig.js often refers to tsconfig.json. */const EXTENSION_VARIANT_MAP: Record<string, string[]> = { '.js': ['.json', '.ts', '.jsx', '.mjs', '.cjs'], '.ts': ['.tsx', '.js', '.json', '.mts', '.cts'], '.tsx': ['.ts', '.jsx'], '.jsx': ['.tsx', '.js'], '.mjs': ['.js', '.cjs'], '.cjs': ['.js', '.mjs'],}; /** * First path segment after `a/` or `b/` must look like a repo root for us to strip (pill / review diff paths). * WHY: Avoid turning a real top-level folder literally named `a/` into a wrong path. */const GIT_DIFF_PREFIX_STRIP_FIRST_SEGMENTS = new Set([ 'src', 'lib', 'app', 'apps', 'packages', 'plugins', 'scripts', 'test', 'tests', 'docs', 'build', 'tools', 'shared', '.github', 'config', 'public', 'components', 'db', 'migrations', 'api', 'server', 'client', 'examples', 'types', 'typings', 'benchmarks',]); /** * Strip unified-diff `a/` or `b/` prefix from review paths (e.g. `a/packages/foo/bar.ts` → `packages/foo/bar.ts`). * See solvability / path resolution (pill-output audit). */export function stripGitDiffPathPrefix(rawPath: string): string { const t = normalizeRepoPath(rawPath); const m = /^(a|b)\/(.+)$/.exec(t); if (!m) return t; const rest = m[2]!; const first = rest.split('/')[0] ?? ''; if (!first) return t; if (GIT_DIFF_PREFIX_STRIP_FIRST_SEGMENTS.has(first) || first.startsWith('@')) { return rest; } if (first === 'package.json' || first === 'pnpm-lock.yaml' || first === 'bun.lockb') { return rest; } return t;} /** * Try to resolve a path that doesn't exist by checking common extension variants. * WHY: Review comments sometimes reference tsconfig.js when only tsconfig.json exists, or * a .ts file when the repo has .tsx; dismissing as "file not found" wastes the fix loop. * Returns the first path (original or variant) that exists, or the original path. * Mapping rules: see EXTENSION_VARIANT_MAP; .d.ts fragments try same dir and types/ dir. * * @param workdir - **PR clone root** (absolute path where the target repo is checked out — same as `SimpleGit`'s top-level). **Not** `process.cwd()` of the prr process unless they happen to match. * @param path - Repo-relative path from the review comment. */export function tryResolvePathWithExtensionVariants(workdir: string, path: string): string { path = stripGitDiffPathPrefix(path); const full = join(workdir, path); if (existsSync(full)) return path; if (path.endsWith('.d.ts') || path === '.d.ts') { const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.'; const base = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path; const typeDirs = [dir, join(dir, 'types'), 'types', 'typings']; for (const d of typeDirs) { const candidate = d === '.' ? base : `${d}/${base}`; if (existsSync(join(workdir, candidate))) return candidate; } return path; } const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : ''; const variants = EXTENSION_VARIANT_MAP[ext]; if (variants) { const base = path.slice(0, path.length - ext.length); for (const v of variants) { const candidate = base + v; if (existsSync(join(workdir, candidate))) return candidate; } } return path;} /** Top-level dirs that are typical repo source (not node_modules or external package refs from comments). */const REPO_TOP_LEVEL = new Set([ 'src', 'lib', 'app', 'apps', 'packages', 'plugins', 'scripts', 'test', 'tests', 'docs', 'build', 'tools', 'shared', '.github', 'config', 'public', 'components', 'db', 'migrations', 'api', 'server', 'client', 'examples', 'types', 'typings', 'benchmarks', /** Common e2e / integration roots (TestCafe, Playwright, Cypress, etc.) — WHY: otherwise `isPathAllowedForFix` treats first segment as external package-like and strips paths from TARGET FILE(S). */ 'e2e', 'playwright', 'cypress', 'fixtures', 'integration', 'wdio',]); /** * Normalize a path to forward slashes and trim (no leading ./ strip). * Use when comparing or splitting paths (e.g. segment count, prefix match). */export function normalizeRepoPath(path: string): string { return (path ?? '').trim().replace(/\\/g, '/');} /** * Normalize for allowed-path set membership: forward slashes + trim + strip leading ./. * Use when building allowedSets so " ./foo" and "foo" match. */export function normalizePathForAllow(path: string): string { return normalizeRepoPath(path).replace(/^\.\//, '');} /** Resolution kind from `resolveTrackedPathDetailed` (solvability) — used only for dismissal mapping. */export type TrackedPathResolutionKind = | 'exact' | 'suffix' | 'body-hint' | 'ambiguous' | 'missing' | 'fragment'; /** * True when the review path cannot denote a single repo file (extension-only / bot fragments). * WHY: Distinguish from real root files like `.env` — do **not** use "starts with dot, no slash" * alone (that would misclassify `.env` as a fragment; pill-output / audit). */export function isReviewPathFragment(rawPath: string): boolean { const t = normalizeRepoPath(rawPath); if (!t) return true; if (t === '.' || t === '..') return true; if (/^\.(d\.ts|ts|tsx|js|jsx|mjs|cjs)$/i.test(t)) return true; if (!t.includes('/') && /^d\.ts$/i.test(t)) return true; return false;} /** * True when the adversarial **final audit** should not call the LLM for this review path. * WHY: GitHub synthetic paths (`(PR comment)`), empty paths, and path **fragments** (e.g. bare `.d.ts`) * produce only "(file not found or unreadable)"-style snippets — the model cannot verify code and often * returns bogus UNFIXED, wasting tokens (prompts.log / output.log audits). */export function shouldSkipFinalAuditLlmForPath(path: string | undefined | null): boolean { const p = path?.trim() ?? ''; if (!p) return true; if (p === '(PR comment)') return true; return isReviewPathFragment(p);} /** * When a tracked file is not found after resolution, pick a single dismissal category. * WHY: Same logical case must not flip between missing-file and path-unresolved (pill-output). */export function pathDismissCategoryForNotFound( reviewPath: string, resolutionKind: TrackedPathResolutionKind): 'missing-file' | 'path-unresolved' { if (resolutionKind === 'fragment' || resolutionKind === 'ambiguous') return 'path-unresolved'; if (isReviewPathFragment(reviewPath)) return 'path-unresolved'; return 'missing-file';} /** * Fix URL-encoding artifacts in path segments (e.g. from GitHub links in comment bodies). * A segment like "2Fmessage-service.test.ts" comes from "%2Fmessage..." with % stripped; * 2F is hex for '/', so we strip that prefix so the path is valid repo-relative. */export function normalizePathSegmentEncoding(path: string): string { const normalized = normalizeRepoPath(path); const segments = normalized.split('/').map((seg) => { if (/^(2F|2f)[a-zA-Z0-9_.-]/.test(seg)) return seg.slice(2); return seg; }); return segments.filter(Boolean).join('/');} /** * True if the path is safe to use as an allowed path for the fixer (repo-relative, not internal). * WHY: Comment bodies can contain absolute paths (e.g. /root/.cursor/plans/foo.plan.md). Adding * those to allowedPaths causes "file outside workdir" and wasted LLM calls. */export function isPathAllowedForFix(path: string): boolean { if (!path || typeof path !== 'string') return false; const trimmed = path.trim(); if (trimmed.startsWith('/')) return false; const normalized = normalizeRepoPath(path); for (const seg of INTERNAL_PATH_SEGMENTS) { if (normalized.includes(`/${seg}/`) || normalized.startsWith(`${seg}/`)) return false; } if (normalized.includes('node_modules') || normalized.startsWith('dist/')) return false; const first = normalized.split('/')[0]; if (first && !REPO_TOP_LEVEL.has(first) && /^[a-z@][a-z0-9.-]*$/.test(first)) return false; return true;} /** * Filter an array of paths to only those allowed for fix (repo-relative, not internal). * Normalizes path segment encoding (e.g. "2F" prefix from URL-encoded "/") so TARGET FILE(S) * never show artifacts like "packages/.../2Fmessage-service.test.ts". */export function filterAllowedPathsForFix(paths: string[]): string[] { const normalized = paths .map(normalizePathSegmentEncoding) .map(normalizePathForAllow); return [...new Set(normalized)].filter(isPathAllowedForFix);}
/**
* Path helpers for PRR. Used when building allowedPaths / TARGET FILE(S) so we never
* send absolute or internal paths to the fixer (avoids "file outside workdir" and wasted LLM calls).
*/
import { join } from 'path';
import { existsSync } from 'fs';
/** Segments that indicate an internal path not under the repo (e.g. .cursor plans, .prr state). */
const INTERNAL_PATH_SEGMENTS = ['.cursor', '.prr', 'root'];
/**
* Extension variants to try when the review path is not found (pill-output #9).
* Maps "given extension" -> [candidate extensions to try]. E.g. tsconfig.js often refers to tsconfig.json.
*/
const EXTENSION_VARIANT_MAP: Record<string, string[]> = {
'.js': ['.json', '.ts', '.jsx', '.mjs', '.cjs'],
'.ts': ['.tsx', '.js', '.json', '.mts', '.cts'],
'.tsx': ['.ts', '.jsx'],
'.jsx': ['.tsx', '.js'],
'.mjs': ['.js', '.cjs'],
'.cjs': ['.js', '.mjs'],
};
/**
* First path segment after `a/` or `b/` must look like a repo root for us to strip (pill / review diff paths).
* WHY: Avoid turning a real top-level folder literally named `a/` into a wrong path.
*/
const GIT_DIFF_PREFIX_STRIP_FIRST_SEGMENTS = new Set([
'src',
'lib',
'app',
'apps',
'packages',
'plugins',
'scripts',
'test',
'tests',
'docs',
'build',
'tools',
'shared',
'.github',
'config',
'public',
'components',
'db',
'migrations',
'api',
'server',
'client',
'examples',
'types',
'typings',
'benchmarks',
]);
/**
* Strip unified-diff `a/` or `b/` prefix from review paths (e.g. `a/packages/foo/bar.ts` → `packages/foo/bar.ts`).
* See solvability / path resolution (pill-output audit).
*/
export function stripGitDiffPathPrefix(rawPath: string): string {
const t = normalizeRepoPath(rawPath);
const m = /^(a|b)\/(.+)$/.exec(t);
if (!m) return t;
const rest = m[2]!;
const first = rest.split('/')[0] ?? '';
if (!first) return t;
if (GIT_DIFF_PREFIX_STRIP_FIRST_SEGMENTS.has(first) || first.startsWith('@')) {
return rest;
}
if (first === 'package.json' || first === 'pnpm-lock.yaml' || first === 'bun.lockb') {
return rest;
}
return t;
}
/**
* Try to resolve a path that doesn't exist by checking common extension variants.
* WHY: Review comments sometimes reference tsconfig.js when only tsconfig.json exists, or
* a .ts file when the repo has .tsx; dismissing as "file not found" wastes the fix loop.
* Returns the first path (original or variant) that exists, or the original path.
* Mapping rules: see EXTENSION_VARIANT_MAP; .d.ts fragments try same dir and types/ dir.
*
* @param workdir - **PR clone root** (absolute path where the target repo is checked out — same as `SimpleGit`'s top-level). **Not** `process.cwd()` of the prr process unless they happen to match.
* @param path - Repo-relative path from the review comment.
*/
export function tryResolvePathWithExtensionVariants(workdir: string, path: string): string {
path = stripGitDiffPathPrefix(path);
const full = join(workdir, path);
if (existsSync(full)) return path;
if (path.endsWith('.d.ts') || path === '.d.ts') {
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.';
const base = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path;
const typeDirs = [dir, join(dir, 'types'), 'types', 'typings'];
for (const d of typeDirs) {
const candidate = d === '.' ? base : `${d}/${base}`;
if (existsSync(join(workdir, candidate))) return candidate;
}
return path;
}
const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
const variants = EXTENSION_VARIANT_MAP[ext];
if (variants) {
const base = path.slice(0, path.length - ext.length);
for (const v of variants) {
const candidate = base + v;
if (existsSync(join(workdir, candidate))) return candidate;
}
}
return path;
}
/** Top-level dirs that are typical repo source (not node_modules or external package refs from comments). */
const REPO_TOP_LEVEL = new Set([
'src', 'lib', 'app', 'apps', 'packages', 'plugins', 'scripts', 'test', 'tests', 'docs', 'build', 'tools', 'shared',
'.github', 'config', 'public', 'components', 'db', 'migrations', 'api', 'server', 'client', 'examples',
'types', 'typings', 'benchmarks',
/** Common e2e / integration roots (TestCafe, Playwright, Cypress, etc.) — WHY: otherwise `isPathAllowedForFix` treats first segment as external package-like and strips paths from TARGET FILE(S). */
'e2e', 'playwright', 'cypress', 'fixtures', 'integration', 'wdio',
]);
/**
* Normalize a path to forward slashes and trim (no leading ./ strip).
* Use when comparing or splitting paths (e.g. segment count, prefix match).
*/
export function normalizeRepoPath(path: string): string {
return (path ?? '').trim().replace(/\\/g, '/');
}
/**
* Normalize for allowed-path set membership: forward slashes + trim + strip leading ./.
* Use when building allowedSets so " ./foo" and "foo" match.
*/
export function normalizePathForAllow(path: string): string {
return normalizeRepoPath(path).replace(/^\.\//, '');
}
/** Resolution kind from `resolveTrackedPathDetailed` (solvability) — used only for dismissal mapping. */
export type TrackedPathResolutionKind =
| 'exact'
| 'suffix'
| 'body-hint'
| 'ambiguous'
| 'missing'
| 'fragment';
/**
* True when the review path cannot denote a single repo file (extension-only / bot fragments).
* WHY: Distinguish from real root files like `.env` — do **not** use "starts with dot, no slash"
* alone (that would misclassify `.env` as a fragment; pill-output / audit).
*/
export function isReviewPathFragment(rawPath: string): boolean {
const t = normalizeRepoPath(rawPath);
if (!t) return true;
if (t === '.' || t === '..') return true;
if (/^\.(d\.ts|ts|tsx|js|jsx|mjs|cjs)$/i.test(t)) return true;
if (!t.includes('/') && /^d\.ts$/i.test(t)) return true;
return false;
}
/**
* True when the adversarial **final audit** should not call the LLM for this review path.
* WHY: GitHub synthetic paths (`(PR comment)`), empty paths, and path **fragments** (e.g. bare `.d.ts`)
* produce only "(file not found or unreadable)"-style snippets — the model cannot verify code and often
* returns bogus UNFIXED, wasting tokens (prompts.log / output.log audits).
*/
export function shouldSkipFinalAuditLlmForPath(path: string | undefined | null): boolean {
const p = path?.trim() ?? '';
if (!p) return true;
if (p === '(PR comment)') return true;
return isReviewPathFragment(p);
}
/**
* When a tracked file is not found after resolution, pick a single dismissal category.
* WHY: Same logical case must not flip between missing-file and path-unresolved (pill-output).
*/
export function pathDismissCategoryForNotFound(
reviewPath: string,
resolutionKind: TrackedPathResolutionKind
): 'missing-file' | 'path-unresolved' {
if (resolutionKind === 'fragment' || resolutionKind === 'ambiguous') return 'path-unresolved';
if (isReviewPathFragment(reviewPath)) return 'path-unresolved';
return 'missing-file';
}
/**
* Fix URL-encoding artifacts in path segments (e.g. from GitHub links in comment bodies).
* A segment like "2Fmessage-service.test.ts" comes from "%2Fmessage..." with % stripped;
* 2F is hex for '/', so we strip that prefix so the path is valid repo-relative.
*/
export function normalizePathSegmentEncoding(path: string): string {
const normalized = normalizeRepoPath(path);
const segments = normalized.split('/').map((seg) => {
if (/^(2F|2f)[a-zA-Z0-9_.-]/.test(seg)) return seg.slice(2);
return seg;
});
return segments.filter(Boolean).join('/');
}
/**
* True if the path is safe to use as an allowed path for the fixer (repo-relative, not internal).
* WHY: Comment bodies can contain absolute paths (e.g. /root/.cursor/plans/foo.plan.md). Adding
* those to allowedPaths causes "file outside workdir" and wasted LLM calls.
*/
export function isPathAllowedForFix(path: string): boolean {
if (!path || typeof path !== 'string') return false;
const trimmed = path.trim();
if (trimmed.startsWith('/')) return false;
const normalized = normalizeRepoPath(path);
for (const seg of INTERNAL_PATH_SEGMENTS) {
if (normalized.includes(`/${seg}/`) || normalized.startsWith(`${seg}/`)) return false;
}
if (normalized.includes('node_modules') || normalized.startsWith('dist/')) return false;
const first = normalized.split('/')[0];
if (first && !REPO_TOP_LEVEL.has(first) && /^[a-z@][a-z0-9.-]*$/.test(first)) return false;
return true;
}
/**
* Filter an array of paths to only those allowed for fix (repo-relative, not internal).
* Normalizes path segment encoding (e.g. "2F" prefix from URL-encoded "/") so TARGET FILE(S)
* never show artifacts like "packages/.../2Fmessage-service.test.ts".
*/
export function filterAllowedPathsForFix(paths: string[]): string[] {
const normalized = paths
.map(normalizePathSegmentEncoding)
.map(normalizePathForAllow);
return [...new Set(normalized)].filter(isPathAllowedForFix);
}