diff --git a/review-enrichment/src/analyzers/caller-impact.ts b/review-enrichment/src/analyzers/caller-impact.ts new file mode 100644 index 0000000000..b60a448f5e --- /dev/null +++ b/review-enrichment/src/analyzers/caller-impact.ts @@ -0,0 +1,525 @@ +// Cross-file caller-impact / dead-symbol analyzer (#1509). Surfaces two cross-file hazards the no-checkout +// `claude --print` reviewer (which only sees the diff) is blind to: +// 1. An exported top-level symbol the PR REMOVES or RENAMES AWAY from a module while it still has importing +// dependents in files the PR did NOT touch — a hidden compile/runtime break. Candidate dependents come from the +// GitHub Code Search API (text-match) on the default branch; each candidate's CONTENT is then fetched and a +// finding is only reported when that file actually DEPENDS ON the symbol FROM the changed module — a named +// import, a namespace import used as `ns.symbol`, an `export { symbol } from`, or an `export * from` barrel — +// where the import specifier RESOLVES (relative to the candidate file) to the changed file's path. So a +// same-named export in an unrelated module, or an import of a different `./lib`, is never falsely flagged. +// Churn is keyed per (file, name) using each file's OLD path (so a rename is seen), and only the PRESENCE of an +// export is considered — an in-place signature/body edit is deliberately NOT flagged (a body change doesn't +// break importers and signature-vs-body can't be told apart reliably from a diff). Default exports are not +// modeled (a consumer imports a default with any local name, which name-based Code Search can't resolve). +// 2. A newly-exported symbol referenced nowhere in the PR — dead-on-arrival. Code Search indexes the DEFAULT branch +// only, so a brand-new symbol is invisible to it; this is judged from the diff, and entrypoint files +// (index.*, *.d.ts) are skipped because public API is intentionally unused internally. +// +// Reports symbol names + unchanged dependent file paths only — never source. The caller path uses the request's +// short-lived githubToken; the diff-only dead-on-arrival path needs neither token nor network. Fail-safe: a failed / +// rate-limited lookup drops that symbol only; a candidate whose content/import can't be verified is dropped. +import type { EnrichRequest, CallerImpactFinding } from "../types.js"; + +const GITHUB_API = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const MAX_SYMBOLS_SEARCHED = 8; // Code Search is rate-limited (~10/min); bound the per-PR symbol fan-out +const MAX_DEAD_REPORTED = 10; // cap dead-on-arrival findings (diff-only, no network) +const MAX_CALLER_FILES = 10; // cap caller files listed per symbol +const MAX_CALLER_CANDIDATES = 20; // candidate code files (per symbol) to import-verify before stopping +const CODE_SEARCH_PER_PAGE = 20; +const MAX_SEARCH_PAGES = 5; // pages one symbol may walk (≤100 hits) to see past filtered noise on page 1 +const MAX_TOTAL_SEARCH_REQUESTS = 10; // global Code Search request budget (respects the ~10/min secondary limit) +const MAX_TOTAL_CONTENT_FETCHES = 30; // global Contents-API budget for import verification across all symbols +const MAX_DECL_LINES = 40; // bound the contiguous multiline export declaration accumulated for name extraction + +const REPO_SEGMENT = /^[A-Za-z0-9._-]+$/; +const ENTRYPOINT_RE = /(^|\/)index\.[cm]?[jt]sx?$|\.d\.ts$/; // public-API files: skip dead-on-arrival here +// Only a code file can be a real dependent; a match in a doc/markdown/text/config file is never a compile/runtime dep. +const CODE_FILE_RE = /\.(?:m?[jt]sx?|cts|mts|vue|svelte)$/i; +const MODULE_EXT_RE = /\.(?:d\.ts|[cm]?[jt]sx?|cts|mts|vue|svelte)$/i; + +interface ScanOptions { + signal?: AbortSignal; +} + +/** Parse `owner/repo`, rejecting anything that isn't exactly two safe segments (no traversal / extra slashes) so a + * hostile `repoFullName` cannot redirect the token-bearing request elsewhere. Returns null when unsafe. */ +export function parseRepo( + repoFullName: string, +): { owner: string; repo: string } | null { + const parts = repoFullName.split("/"); + if (parts.length !== 2) return null; + const [owner, repo] = parts; + for (const seg of [owner, repo]) { + if (!seg || seg === "." || seg === ".." || !REPO_SEGMENT.test(seg)) { + return null; + } + } + return { owner: owner!, repo: repo! }; +} + +function githubHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + "User-Agent": "gittensory-review-enrichment", + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** A whole-identifier matcher for `symbol` — boundaries exclude identifier characters (incl. `$`). */ +function identifier(symbol: string): RegExp { + return new RegExp(`(? a normalized module path. Returns null for + * a non-relative (bare package / tsconfig-alias) specifier that can't be resolved without build config. */ +export function resolveImport(fromFile: string, specifier: string): string | null { + if (!specifier.startsWith(".")) return null; + const stack = fromFile.replace(/\\/g, "/").split("/").slice(0, -1); + for (const part of specifier.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") stack.pop(); + else stack.push(part); + } + return normalizeModulePath(stack.join("/")); +} + +/** Split a comma-separated list at top level only (commas inside (), [], {} are ignored). */ +function splitTopLevelCommas(value: string): string[] { + const out: string[] = []; + let depth = 0; + let current = ""; + for (const ch of value) { + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") depth = Math.max(0, depth - 1); + if (ch === "," && depth === 0) { + out.push(current); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) out.push(current); + return out; +} + +/** Strip line/block comments and string/template-literal CONTENT from a single line, so a symbol that appears only in + * a comment or string is not mistaken for a real code reference. Best-effort single-line scrub (advisory): a + * comment-only line (`//...`, JSDoc `*...`, `/*...`) is dropped entirely. */ +export function stripCommentsAndStrings(line: string): string { + const trimmed = line.trim(); + if ( + trimmed.startsWith("//") || + trimmed.startsWith("*") || + trimmed.startsWith("/*") + ) { + return ""; + } + return line + .replace(/\/\*.*?\*\//g, " ") // inline block comment + .replace(/\/\/.*$/, " ") // trailing line comment + .replace(/"(?:[^"\\]|\\.)*"/g, '""') // double-quoted string content + .replace(/'(?:[^'\\]|\\.)*'/g, "''") // single-quoted string content + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); // template-literal content +} + +/** True when `symbol` appears as a real code reference (a whole identifier in non-comment, non-string code) somewhere + * in `code`. Used as a cheap PRE-FILTER over a Code Search fragment / a diff line — the authoritative caller check is + * import verification (`importsSymbolFromModule`); for the diff-only dead path this is the decision. */ +export function referencesSymbol(code: string, symbol: string): boolean { + const re = identifier(symbol); + for (const rawLine of code.split("\n")) { + if (re.test(stripCommentsAndStrings(rawLine))) return true; + } + return false; +} + +/** True when the candidate file `content` (at `candidatePath`) depends on `symbol` from the changed module at + * `changedPath`. A dependent is: a named import / re-export `{ ... symbol ... } from`, a namespace import + * `* as ns from` used as `ns.symbol`, or an `export * from` barrel — in every case the specifier must RESOLVE + * (relative to the candidate file) to the changed file's path, so a same-named export in an unrelated module, or an + * import of a different `./lib`, is not matched. */ +export function importsSymbolFromModule( + content: string, + symbol: string, + candidatePath: string, + changedPath: string, +): boolean { + const target = normalizeModulePath(changedPath); + const idRe = identifier(symbol); + const clauseRe = + /\b(?:import|export)\s+(?:type\s+)?(\{[^}]*\}|\*(?:\s+as\s+([A-Za-z_$][\w$]*))?)\s+from\s+['"]([^'"]+)['"]/g; + for (const match of content.matchAll(clauseRe)) { + const clause = match[1] ?? ""; + const namespaceAlias = match[2]; + const specifier = match[3] ?? ""; + if (resolveImport(candidatePath, specifier) !== target) continue; + if (clause.startsWith("{")) { + if (idRe.test(clause)) return true; // named import or `export { symbol } from` + } else if (namespaceAlias) { + // `import * as ns from ` is a dependent only if it actually uses `ns.symbol`. + const usage = new RegExp( + `(?` barrel re-exports every symbol, incl. this one + } + } + return false; +} + +/** Exported top-level identifier(s) declared by a single (possibly multiline-joined) export statement. Handles + * `export function|class|interface|type|enum|namespace NAME`, `export const enum NAME`, multi-declarator + * `export const a = 1, b = 2`, and `export { a, b as c }` (the public name is the alias after `as`). Returns [] for + * `export default ...` (defaults aren't modeled), `export * from ...`, and non-export lines. */ +export function parseExportedNames(line: string): string[] { + const s = line.trim(); + if (!s.startsWith("export")) return []; + + const brace = s.match(/^export\s+(?:type\s+)?\{([^}]*)\}/); + if (brace) { + return brace[1]! + .split(",") + .map((part) => { + const seg = part.trim(); + const asMatch = seg.match(/\bas\s+([A-Za-z_$][\w$]*)/); + if (asMatch) return asMatch[1]!; + const id = seg.match(/^([A-Za-z_$][\w$]*)/); + return id ? id[1]! : ""; + }) + .filter((name): name is string => name.length > 0 && name !== "default"); + } + + // Default exports are intentionally NOT modeled: an unchanged consumer imports a default with any local name + // (`import anything from './lib'`), which name-based Code Search can't resolve, so treating the declared name + // (`export default function main`) as the public symbol would be wrong. (#1509) + if (/^export\s+default\b/.test(s)) return []; + + const constEnum = s.match( + /^export\s+(?:declare\s+)?const\s+enum\s+([A-Za-z_$][\w$]*)/, + ); + if (constEnum) return [constEnum[1]!]; + + // `export const a = 1, b = 2` declares every top-level declarator, not only the first. + const varDecl = s.match( + /^export\s+(?:declare\s+)?(?:const|let|var)\s+(?!enum\b)([\s\S]+)$/, + ); + if (varDecl) { + const names: string[] = []; + for (const part of splitTopLevelCommas(varDecl[1]!)) { + const id = part.trim().match(/^([A-Za-z_$][\w$]*)/); + if (id) names.push(id[1]!); + } + return names; + } + + const decl = s.match( + /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/, + ); + if (decl) return [decl[1]!]; + + return []; +} + +const norm = (line: string): string => line.trim().replace(/\s+/g, " "); + +/** Inclusive index where the export declaration starting at `start` ends: walk lines until the bracket depth + * (parens/braces/brackets) returns to 0 and the line is not a continuation. Captures the FULL contiguous multiline + * declaration so a name on a later line is still parsed. Bounded by MAX_DECL_LINES. */ +function declarationEnd(lines: string[], start: number): number { + let depth = 0; + for (let i = start; i < lines.length && i - start < MAX_DECL_LINES; i++) { + for (const ch of lines[i]!) { + if (ch === "(" || ch === "{" || ch === "[") depth++; + else if (ch === ")" || ch === "}" || ch === "]") depth = Math.max(0, depth - 1); + } + if (depth === 0 && !/[=|&,(<]\s*$/.test(lines[i]!)) return i; + } + return Math.min(start + MAX_DECL_LINES - 1, lines.length - 1); +} + +/** Parse exported symbol names from a sequence of source lines. Each multiline export declaration is joined into one + * statement so a name spread across several lines is still found. */ +export function extractExports(lines: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < lines.length; i++) { + if (!lines[i]!.trim().startsWith("export")) continue; + const end = declarationEnd(lines, i); + const joined = norm(lines.slice(i, end + 1).join(" ")); + out.push(...parseExportedNames(joined)); + i = end; + } + return out; +} + +/** A unified-diff patch split into its pre-image (context + removed) and post-image (context + added), plus the + * purely-added lines. Reconstructing each image catches a churn confined to an inner line of a declaration whose + * `export ...` line is unchanged context. */ +function splitPatchImages(patch: string): { + pre: string[]; + post: string[]; + added: string[]; +} { + const pre: string[] = []; + const post: string[] = []; + const added: string[] = []; + for (const line of patch.split("\n")) { + if (line.startsWith("@@") || line.startsWith("+++") || line.startsWith("---")) { + continue; + } + if (line.startsWith("+")) { + const text = line.slice(1); + post.push(text); + added.push(text); + } else if (line.startsWith("-")) { + pre.push(line.slice(1)); + } else { + const ctx = line.startsWith(" ") ? line.slice(1) : line; + pre.push(ctx); + post.push(ctx); + } + } + return { pre, post, added }; +} + +interface DiffExports { + /** set of `${file} ${name}` exported in some file's pre-image (file = the file's OLD path, so a rename is seen) */ + oldExports: Set; + /** set of `${file} ${name}` exported in some file's post-image */ + newExports: Set; + /** old export `${file} ${name}` -> { file (old path), name }, for caller resolution against the old module */ + oldExportInfo: Map; + /** the set of names exported anywhere before the PR (so a genuinely-new name can be told from a move) */ + oldNames: Set; + /** newly-exported name -> the file it was first declared in (for the dead-on-arrival entrypoint check) */ + newExportFile: Map; + /** the set of names exported anywhere in the post-image (dead-on-arrival iterates these) */ + newNames: Set; + /** purely-added source lines across the PR (for the dead-on-arrival reference scan) */ + addedLines: string[]; +} + +/** Collect the PR's exported-symbol churn PER FILE, reconstructing each file's pre- and post-image. */ +export function collectDiffExports( + files: NonNullable, +): DiffExports { + const oldExports = new Set(); + const newExports = new Set(); + const oldExportInfo = new Map(); + const oldNames = new Set(); + const newExportFile = new Map(); + const newNames = new Set(); + const addedLines: string[] = []; + + for (const file of files) { + if (!file.patch) continue; + const { pre, post, added } = splitPatchImages(file.patch); + for (const src of added) addedLines.push(src); + // Pre-image exports belong to the file's OLD path, so a pure rename (src/old.ts -> src/new.ts) makes the export + // "removed" from src/old.ts and importers of `./old` are correctly flagged. (#1509) + const oldPath = file.previousPath ?? file.path; + for (const name of extractExports(pre)) { + const key = `${oldPath} ${name}`; + oldExports.add(key); + oldExportInfo.set(key, { file: oldPath, name }); + oldNames.add(name); + } + for (const name of extractExports(post)) { + newExports.add(`${file.path} ${name}`); + newNames.add(name); + if (!newExportFile.has(name)) newExportFile.set(name, file.path); + } + } + return { oldExports, newExports, oldExportInfo, oldNames, newExportFile, newNames, addedLines }; +} + +/** True when the symbol is used in an added line OTHER than its own export declaration (so it is NOT dead). A mention + * only in a comment or string is not a real reference. */ +export function isReferencedInDiff(symbol: string, addedLines: string[]): boolean { + const re = identifier(symbol); + for (const line of addedLines) { + if (parseExportedNames(line).includes(symbol)) continue; // the export/re-export declaration itself + if (re.test(stripCommentsAndStrings(line))) return true; + } + return false; +} + +/** Fetch a file's raw content from the default branch, or null on a non-OK reply / network error. */ +async function fetchFileContent( + owner: string, + repo: string, + path: string, + token: string, + fetchImpl: typeof fetch, + signal?: AbortSignal, +): Promise { + try { + const encodedPath = path + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); + const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}`; + const res = await fetchImpl(url, { + headers: { ...githubHeaders(token), Accept: "application/vnd.github.raw" }, + signal, + }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } +} + +/** Candidate unchanged CODE files (outside `changed`) whose matched fragment references `symbol`, walking Code Search + * pages past filtered noise. Returns null on a non-OK reply / network error (drops this symbol only). */ +async function searchCallerCandidates( + symbol: string, + owner: string, + repo: string, + changed: Set, + token: string, + fetchImpl: typeof fetch, + budget: { remaining: number }, + signal?: AbortSignal, +): Promise { + const candidates: string[] = []; + const seen = new Set(); + const query = `"${symbol}" repo:${owner}/${repo}`; + for (let page = 1; page <= MAX_SEARCH_PAGES && budget.remaining > 0; page++) { + budget.remaining--; + let json: { + total_count?: number; + items?: Array<{ path?: string; text_matches?: Array<{ fragment?: string }> }>; + }; + try { + const url = `${GITHUB_API}/search/code?q=${encodeURIComponent(query)}&per_page=${CODE_SEARCH_PER_PAGE}&page=${page}`; + const res = await fetchImpl(url, { + headers: { ...githubHeaders(token), Accept: "application/vnd.github.text-match+json" }, + signal, + }); + if (!res.ok) return null; + json = (await res.json()) as typeof json; + } catch { + return null; + } + const items = json.items ?? []; + for (const item of items) { + const path = item.path; + if (typeof path !== "string" || changed.has(path) || !CODE_FILE_RE.test(path) || seen.has(path)) { + continue; + } + if ((item.text_matches ?? []).some((m) => referencesSymbol(m.fragment ?? "", symbol))) { + seen.add(path); + candidates.push(path); + if (candidates.length >= MAX_CALLER_CANDIDATES) return candidates; + } + } + if (items.length < CODE_SEARCH_PER_PAGE) break; // last page + if (typeof json.total_count === "number" && page * CODE_SEARCH_PER_PAGE >= json.total_count) break; + } + return candidates; +} + +/** Unchanged files that DEPEND ON `symbol` from the changed module at `changedPath`. Code Search surfaces candidates + * by text; each candidate's content is then fetched and import-verified (relative-path resolved) so a same-named + * symbol in an unrelated module is never reported. Returns null only when the Code Search itself failed. */ +async function findExternalCallers( + symbol: string, + owner: string, + repo: string, + changed: Set, + changedPath: string, + token: string, + fetchImpl: typeof fetch, + searchBudget: { remaining: number }, + contentBudget: { remaining: number }, + signal?: AbortSignal, +): Promise { + const candidates = await searchCallerCandidates(symbol, owner, repo, changed, token, fetchImpl, searchBudget, signal); + if (candidates === null) return null; + const callers: string[] = []; + for (const path of candidates) { + if (callers.length >= MAX_CALLER_FILES || contentBudget.remaining <= 0) break; + contentBudget.remaining--; + const content = await fetchFileContent(owner, repo, path, token, fetchImpl, signal); + if (content === null) continue; // can't verify the import -> drop (conservative) + if (importsSymbolFromModule(content, symbol, path, changedPath)) callers.push(path); + } + return callers.sort(); +} + +/** Analyzer entrypoint. Flags exports the PR removes / renames away that still have importing dependents in unchanged + * files, plus dead-on-arrival new exports. The caller path needs a token (skipped without one); the diff-only + * dead-on-arrival path runs regardless. Fail-safe: returns [] without a repo or export churn; a failed lookup drops + * that symbol only. */ +export async function scanCallerImpact( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const token = req.githubToken; + const repo = parseRepo(req.repoFullName); + const files = req.files ?? []; + if (!repo || files.length === 0) return []; + + const { oldExports, newExports, oldExportInfo, oldNames, newExportFile, newNames, addedLines } = + collectDiffExports(files); + if (oldExports.size === 0 && newExports.size === 0) return []; + + const changed = new Set(); + for (const file of files) { + changed.add(file.path); + if (file.previousPath) changed.add(file.previousPath); + } + + const findings: CallerImpactFinding[] = []; + + // Removed / renamed-away exports -> importing dependents in unchanged files. An export is "removed from its module" + // when its `${oldPath} ${name}` key is absent from the post-image set; an in-place signature/body change keeps the + // key present and is intentionally NOT flagged. Needs the token; bounded by the shared Code Search + Contents budgets. + if (token) { + const searchBudget = { remaining: MAX_TOTAL_SEARCH_REQUESTS }; + const contentBudget = { remaining: MAX_TOTAL_CONTENT_FETCHES }; + let searched = 0; + for (const key of oldExports) { + if (searched >= MAX_SYMBOLS_SEARCHED || searchBudget.remaining <= 0) break; + if (newExports.has(key)) continue; // still exported from this module + const info = oldExportInfo.get(key); + if (!info) continue; + searched++; + const callerFiles = await findExternalCallers(info.name, repo.owner, repo.repo, changed, info.file, token, fetchImpl, searchBudget, contentBudget, options.signal); + if (!callerFiles || callerFiles.length === 0) continue; + findings.push({ symbol: info.name, kind: "removed-with-callers", callerFiles }); + } + } + + // Dead-on-arrival: genuinely-new exported symbols (not exported anywhere before) referenced nowhere in the diff. + // Diff-only — Code Search can't see a brand-new symbol. Skip public-entrypoint files (exports meant for external use). + let deadReported = 0; + for (const name of newNames) { + if (deadReported >= MAX_DEAD_REPORTED) break; + if (oldNames.has(name)) continue; // existed before — a move, not a new export + const file = newExportFile.get(name) ?? ""; + if (ENTRYPOINT_RE.test(file)) continue; // likely public API + if (isReferencedInDiff(name, addedLines)) continue; + deadReported++; + findings.push({ symbol: name, kind: "dead-on-arrival", callerFiles: [] }); + } + + // Stable order (by kind, then symbol) so the rendered brief is deterministic regardless of Code Search result order. + return findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.symbol.localeCompare(b.symbol)); +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 5adb7fb1ea..2b75e99801 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -25,6 +25,7 @@ import { scanTyposquat } from "./analyzers/typosquat.js"; import { scanCommitSignature } from "./analyzers/commit-signature.js"; import { scanIacMisconfig } from "./analyzers/iac-misconfig.js"; import { scanNativeBuild } from "./analyzers/native-build.js"; +import { scanCallerImpact } from "./analyzers/caller-impact.js"; import { scanHistory } from "./analyzers/history.js"; import { renderBrief } from "./render.js"; import { captureAnalyzerDegradation } from "./sentry.js"; @@ -68,6 +69,7 @@ const ANALYZERS: Record = { commitSignature: (req, { signal }) => scanCommitSignature(req, fetch, { signal }), iacMisconfig: (req, { signal }) => scanIacMisconfig(req, signal), nativeBuild: (req, { signal }) => scanNativeBuild(req, fetch, { signal }), + callerImpact: (req, { signal }) => scanCallerImpact(req, fetch, { signal }), history: (req, context) => scanHistory(req, fetch, { signal: context.signal, diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 88c4cbdf18..4de23c1bd8 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -350,6 +350,26 @@ export function renderBrief( } } + const callerImpact = findings.callerImpact ?? []; + if (callerImpact.length) { + lines.push( + "### Cross-file API impact (callers in unchanged files / dead exports)", + ); + for (const item of callerImpact) { + if (item.kind === "dead-on-arrival") { + lines.push( + `- ${safeCodeSpan(item.symbol)} is exported but referenced nowhere in this PR (dead-on-arrival) — wire it up, or drop the export`, + ); + continue; + } + const files = item.callerFiles.map((f) => safeCodeSpan(f)).join(", "); + const count = item.callerFiles.length; + lines.push( + `- ${safeCodeSpan(item.symbol)} removed/renamed but still imported by ${count} unchanged file${count === 1 ? "" : "s"}: ${files} — update the importers or keep a compatibility shim`, + ); + } + } + const history = findings.history ?? []; for (const item of history) { const entries: string[] = []; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 4d8f382a87..0e0511cf71 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -236,6 +236,16 @@ export interface NativeBuildFinding { reason: string; } +/** An exported top-level symbol the PR removes / renames away from a module while it still has importing dependents + * in files the PR did NOT touch (a hidden cross-file compile/runtime break), or a newly-exported symbol referenced + * nowhere in the PR (dead-on-arrival). Reports the symbol name + the unchanged caller files only — never source. (#1509) */ +export interface CallerImpactFinding { + symbol: string; + kind: "removed-with-callers" | "dead-on-arrival"; + /** Unchanged files that still import the symbol from the changed module. Empty for `dead-on-arrival`. */ + callerFiles: string[]; +} + /** Public-safe historical context the no-checkout reviewer is blind to and the engine deliberately does NOT compute: * the author's track record IN THIS repo, past PRs that already changed the same files (with their outcome), and * whether the diff covers the linked issue's stated requirement. Surfaced as a single block (0-or-1 element array). @@ -286,6 +296,7 @@ export interface BriefFindings { commitSignature?: CommitSignatureFinding[]; iacMisconfig?: IacMisconfigFinding[]; nativeBuild?: NativeBuildFinding[]; + callerImpact?: CallerImpactFinding[]; history?: HistoryFinding[]; } diff --git a/review-enrichment/test/caller-impact.test.ts b/review-enrichment/test/caller-impact.test.ts new file mode 100644 index 0000000000..c977abee49 --- /dev/null +++ b/review-enrichment/test/caller-impact.test.ts @@ -0,0 +1,548 @@ +// Units for the cross-file caller-impact / dead-symbol analyzer (#1509). Kept in its own file (not +// enrichment.test.ts) so concurrent analyzer PRs don't collide on a shared test file. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseRepo, + parseExportedNames, + extractExports, + collectDiffExports, + isReferencedInDiff, + referencesSymbol, + normalizeModulePath, + resolveImport, + importsSymbolFromModule, + scanCallerImpact, +} from "../dist/analyzers/caller-impact.js"; +import { renderBrief } from "../dist/render.js"; + +const res = (body, { ok = true, status = 200 } = {}) => ({ + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), +}); +const raw = (text, { ok = true, status = 200 } = {}) => ({ + ok, + status, + json: async () => ({}), + text: async () => text, +}); +const throwingFetch = async () => { + throw new Error("network down"); +}; + +// A fetch stub routing /search/code (by encoded-symbol substring -> items) and /contents/ (-> raw file content). +function ghStub({ search = {}, contents = {} }) { + return async (url) => { + if (url.includes("/search/code")) { + for (const [enc, items] of Object.entries(search)) { + if (url.includes(enc)) return res({ items }); + } + return res({ items: [] }); + } + if (url.includes("/contents/")) { + for (const [path, content] of Object.entries(contents)) { + if (url.includes("/contents/" + path)) return raw(content); + } + return raw("", { ok: false, status: 404 }); + } + return res({ items: [] }); + }; +} + +// ── pure helpers ──────────────────────────────────────────────────────────────── + +test("parseExportedNames covers the common export forms", () => { + assert.deepEqual(parseExportedNames("export function foo() {}"), ["foo"]); + assert.deepEqual(parseExportedNames("export async function bar() {}"), ["bar"]); + assert.deepEqual(parseExportedNames("export const baz = 1;"), ["baz"]); + assert.deepEqual(parseExportedNames("export class Qux {}"), ["Qux"]); + assert.deepEqual(parseExportedNames("export interface IThing {}"), ["IThing"]); + assert.deepEqual(parseExportedNames("export type TThing = string;"), ["TThing"]); + assert.deepEqual(parseExportedNames("export enum E {}"), ["E"]); + assert.deepEqual(parseExportedNames("export namespace NS {}"), ["NS"]); + assert.deepEqual(parseExportedNames("export declare function dfn(): void;"), ["dfn"]); + assert.deepEqual(parseExportedNames(" export function indented() {}"), ["indented"]); +}); + +test("parseExportedNames enumerates every declarator and handles const enum", () => { + assert.deepEqual(parseExportedNames("export const a = 1, b = 2;"), ["a", "b"]); + assert.deepEqual(parseExportedNames("export let x = f(1, 2), y = 3;"), ["x", "y"]); // comma inside () is not a split + assert.deepEqual(parseExportedNames("export const enum Color { Red }"), ["Color"]); +}); + +test("parseExportedNames handles named export lists with aliases", () => { + assert.deepEqual(parseExportedNames("export { a, b as c, d };"), ["a", "c", "d"]); + assert.deepEqual(parseExportedNames("export type { T1, T2 };"), ["T1", "T2"]); + assert.deepEqual(parseExportedNames('export { x } from "./x";'), ["x"]); +}); + +test("parseExportedNames returns [] for default exports, re-export-all, and non-exports", () => { + assert.deepEqual(parseExportedNames("export default function main() {}"), []); // defaults are not modeled + assert.deepEqual(parseExportedNames("export default class App {}"), []); + assert.deepEqual(parseExportedNames("export default 42;"), []); + assert.deepEqual(parseExportedNames('export * from "./x";'), []); + assert.deepEqual(parseExportedNames("const x = 1;"), []); + assert.deepEqual(parseExportedNames("import { foo } from './a';"), []); +}); + +test("normalizeModulePath strips extension and trailing index", () => { + assert.equal(normalizeModulePath("src/lib.ts"), "src/lib"); + assert.equal(normalizeModulePath("src/lib/index.ts"), "src/lib"); + assert.equal(normalizeModulePath("a/b/c.d.ts"), "a/b/c"); +}); + +test("resolveImport resolves relative specifiers and rejects bare ones", () => { + assert.equal(resolveImport("src/a/c.ts", "../lib"), "src/lib"); + assert.equal(resolveImport("src/c.ts", "./lib"), "src/lib"); + assert.equal(resolveImport("src/c.ts", "./lib/index"), "src/lib"); + assert.equal(resolveImport("src/c.ts", "lodash"), null); // bare package — not resolvable here +}); + +test("importsSymbolFromModule resolves relative imports and covers named/namespace/re-export shapes", () => { + const lib = "src/lib.ts"; + assert.equal(importsSymbolFromModule("import { foo } from './lib';", "foo", "src/c.ts", lib), true); + assert.equal(importsSymbolFromModule("import { foo as bar } from '../lib';", "foo", "src/a/c.ts", lib), true); + assert.equal(importsSymbolFromModule("import { other } from './lib';", "foo", "src/c.ts", lib), false); // different symbol + assert.equal(importsSymbolFromModule("import { foo } from './lib';", "foo", "src/a/c.ts", lib), false); // ./lib here = src/a/lib + assert.equal(importsSymbolFromModule("import * as lib from './lib';\nlib.foo();", "foo", "src/c.ts", lib), true); // namespace use + assert.equal(importsSymbolFromModule("import * as lib from './lib';\nlib.bar();", "foo", "src/c.ts", lib), false); // namespace, not foo + assert.equal(importsSymbolFromModule("export { foo } from './lib';", "foo", "src/c.ts", lib), true); // re-export barrel + assert.equal(importsSymbolFromModule("export * from './lib';", "foo", "src/c.ts", lib), true); // star re-export + assert.equal(importsSymbolFromModule("function foo() {}", "foo", "src/c.ts", lib), false); // own def, no import +}); + +test("collectDiffExports reconstructs pre/post export sets per file", () => { + const out = collectDiffExports([ + { + path: "f.ts", + patch: "@@ -1,1 +1,2 @@\n-export const removed = 1;\n+export const added = 1;\n+useSomething();", + }, + ]); + assert.ok(out.oldExports.has("f.ts removed")); + assert.ok(out.newExports.has("f.ts added")); + assert.equal(out.newExportFile.get("added"), "f.ts"); + assert.ok(out.addedLines.includes("useSomething();")); +}); + +test("isReferencedInDiff ignores the export declaration and escapes regex metachars", () => { + assert.equal(isReferencedInDiff("added", ["export const added = 1;", "const y = added + 1;"]), true); + assert.equal(isReferencedInDiff("lonely", ["export const lonely = 1;"]), false); + assert.equal(isReferencedInDiff("x$", ["foo(x$);"]), true); // `$` must be escaped, not treated as anchor +}); + +test("parseRepo rejects unsafe names", () => { + assert.deepEqual(parseRepo("o/r"), { owner: "o", repo: "r" }); + assert.equal(parseRepo("o"), null); + assert.equal(parseRepo("o/r/x"), null); + assert.equal(parseRepo("../x"), null); +}); + +test("referencesSymbol counts real code references, not comments or strings", () => { + assert.equal(referencesSymbol("import { foo } from './x';", "foo"), true); + assert.equal(referencesSymbol("bar(foo);", "foo"), true); + assert.equal(referencesSymbol("// uses foo here", "foo"), false); // line comment + assert.equal(referencesSymbol(" * @param foo the thing", "foo"), false); // JSDoc continuation + assert.equal(referencesSymbol("const s = 'foo';", "foo"), false); // string literal + assert.equal(referencesSymbol("const t = `foo`;", "foo"), false); // template literal + assert.equal(referencesSymbol("notfoo + foobar", "foo"), false); // substring only +}); + +test("extractExports joins a multiline export { } block and a multiline declaration", () => { + assert.deepEqual(extractExports(["export {", " alpha,", " beta,", "};"]), ["alpha", "beta"]); + assert.deepEqual(extractExports(["export const single = 1;"]), ["single"]); + assert.deepEqual(extractExports(["export function foo(", " a: string,", "): void;"]), ["foo"]); +}); + +// ── scanCallerImpact ────────────────────────────────────────────────────────── + +test("scanCallerImpact: a removed export with an importing caller is flagged; changed files are excluded", async () => { + const fetchImpl = ghStub({ + search: { + "%22foo%22": [ + { path: "src/caller.ts", text_matches: [{ fragment: "import { foo } from './lib';\nfoo();" }] }, + { path: "src/lib.ts", text_matches: [{ fragment: "export function foo() {}" }] }, // changed file → excluded + ], + }, + contents: { "src/caller.ts": "import { foo } from './lib';\nfoo();" }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(a: string): void;" }], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.equal(out[0].symbol, "foo"); + assert.equal(out[0].kind, "removed-with-callers"); + assert.deepEqual(out[0].callerFiles, ["src/caller.ts"]); +}); + +test("scanCallerImpact: an unrelated file defining its OWN same-named symbol is not a caller", async () => { + const fetchImpl = ghStub({ + search: { + "%22foo%22": [ + { path: "src/real.ts", text_matches: [{ fragment: "foo();" }] }, + { path: "src/unrelated.ts", text_matches: [{ fragment: "foo();" }] }, + ], + }, + contents: { + "src/real.ts": "import { foo } from './lib';\nfoo();", // real consumer of the removed module + "src/unrelated.ts": "function foo() {}\nfoo();", // its OWN foo — not the removed one + }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.deepEqual(out[0].callerFiles, ["src/real.ts"]); // unrelated.ts excluded by import binding +}); + +test("scanCallerImpact: an import of the same name from a DIFFERENT ./lib is not a caller", async () => { + const fetchImpl = ghStub({ + search: { "%22foo%22": [{ path: "src/feature/x.ts", text_matches: [{ fragment: "foo();" }] }] }, + contents: { "src/feature/x.ts": "import { foo } from './lib';\nfoo();" }, // ./lib here = src/feature/lib + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.deepEqual(out, []); // imports a different ./lib (src/feature/lib), not the changed src/lib +}); + +test("scanCallerImpact: a namespace import that uses ns.symbol is a caller", async () => { + const fetchImpl = ghStub({ + search: { "%22foo%22": [{ path: "src/ns.ts", text_matches: [{ fragment: "lib.foo();" }] }] }, + contents: { "src/ns.ts": "import * as lib from './lib';\nlib.foo();" }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.deepEqual(out[0].callerFiles, ["src/ns.ts"]); +}); + +test("scanCallerImpact: a re-export barrel forwarding the symbol is a caller", async () => { + const fetchImpl = ghStub({ + search: { "%22foo%22": [{ path: "src/barrel.ts", text_matches: [{ fragment: "export { foo } from './lib';" }] }] }, + contents: { "src/barrel.ts": "export { foo } from './lib';" }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.deepEqual(out[0].callerFiles, ["src/barrel.ts"]); +}); + +test("scanCallerImpact: a body-only change to an exported function is NOT flagged", async () => { + let searched = false; + const fetchImpl = async (url) => { + if (url.includes("/search/code")) searched = true; + return res({ items: [] }); + }; + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [ + { + path: "src/lib.ts", + // `export function foo(): void {` and `}` are unchanged context; only the body line changes. + patch: "@@ -1,3 +1,3 @@\n export function foo(): void {\n- return doOld();\n+ return doNew();\n }", + }, + ], + }, + fetchImpl, + ); + assert.deepEqual(out, []); // foo is still exported from src/lib (present in pre and post) → not a removal + assert.equal(searched, false); +}); + +test("scanCallerImpact: a renamed file's importers of the OLD path are flagged", async () => { + const fetchImpl = ghStub({ + search: { "%22foo%22": [{ path: "src/caller.ts", text_matches: [{ fragment: "foo();" }] }] }, + contents: { "src/caller.ts": "import { foo } from './old';\nfoo();" }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [ + { + path: "src/new.ts", + previousPath: "src/old.ts", + status: "renamed", + patch: "@@ -1,1 +1,1 @@\n-export function foo(): void;\n+export function foo(): void;", + }, + ], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.equal(out[0].kind, "removed-with-callers"); + assert.deepEqual(out[0].callerFiles, ["src/caller.ts"]); // importer of ./old breaks after the rename +}); + +test("scanCallerImpact: same-named exports removed from two changed files are classified per file", async () => { + const fetchImpl = ghStub({ + search: { + "%22foo%22": [ + { path: "src/ca.ts", text_matches: [{ fragment: "foo();" }] }, + { path: "src/cb.ts", text_matches: [{ fragment: "foo();" }] }, + ], + }, + contents: { + "src/ca.ts": "import { foo } from './a';\nfoo();", // depends on a's foo + "src/cb.ts": "import { foo } from './b';\nfoo();", // depends on b's foo + }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [ + { path: "src/a.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }, + { path: "src/b.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }, + ], + }, + fetchImpl, + ); + // foo removed from BOTH modules → two findings, each bound to its own importer. + const callerSets = out.map((f) => f.callerFiles).sort(); + assert.deepEqual(callerSets, [["src/ca.ts"], ["src/cb.ts"]]); +}); + +test("scanCallerImpact: pages past a noisy first page to find an importing caller on page 2", async () => { + const page1 = [ + { path: "src/lib.ts", text_matches: [{ fragment: "export function foo() {}" }] }, // changed file → excluded + { path: "src/note.ts", text_matches: [{ fragment: "// foo is nice" }] }, // comment-only → not a reference + { path: "docs/x.md", text_matches: [{ fragment: "the foo helper" }] }, // markdown → excluded + ]; + while (page1.length < 20) page1.push({ path: "docs/pad.md", text_matches: [{ fragment: "foo" }] }); + const fetchImpl = async (url) => { + if (url.includes("/contents/src/real-caller.ts")) return raw("import { foo } from './lib';\nfoo();"); + if (url.includes("/search/code")) { + if (url.includes("&page=1")) return res({ total_count: 21, items: page1 }); + if (url.includes("&page=2")) { + return res({ + total_count: 21, + items: [{ path: "src/real-caller.ts", text_matches: [{ fragment: "import { foo } from './lib';\nfoo();" }] }], + }); + } + } + return res({ items: [] }); + }; + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.equal(out.length, 1); + assert.equal(out[0].kind, "removed-with-callers"); + assert.deepEqual(out[0].callerFiles, ["src/real-caller.ts"]); +}); + +test("scanCallerImpact: a hit only in a comment, string, or markdown is not a caller", async () => { + const fetchImpl = ghStub({ + search: { + "%22foo%22": [ + { path: "src/comment.ts", text_matches: [{ fragment: "// foo is documented here" }] }, + { path: "docs/readme.md", text_matches: [{ fragment: "the foo helper is great" }] }, + { path: "src/strings.ts", text_matches: [{ fragment: "const label = 'foo';" }] }, + ], + }, + }); + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + fetchImpl, + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: every declarator in a multi-declarator export is tracked (dead-on-arrival)", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/util.ts", patch: "@@ -0,0 +1,1 @@\n+export const a = 1, b = 2;" }], + }, + throwingFetch, + ); + const dead = out.filter((f) => f.kind === "dead-on-arrival").map((f) => f.symbol).sort(); + assert.deepEqual(dead, ["a", "b"]); +}); + +test("scanCallerImpact: a new export referenced nowhere is dead-on-arrival (no network call)", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/util.ts", patch: "@@ -0,0 +1,1 @@\n+export const newThing = 1;" }], + }, + throwingFetch, + ); + assert.equal(out.length, 1); + assert.equal(out[0].symbol, "newThing"); + assert.equal(out[0].kind, "dead-on-arrival"); + assert.deepEqual(out[0].callerFiles, []); +}); + +test("scanCallerImpact: a new export used elsewhere in the diff is not dead", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/util.ts", patch: "@@ -0,0 +1,2 @@\n+export const used = 1;\n+const x = used + 1;" }], + }, + throwingFetch, + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: a comment mention does not suppress dead-on-arrival", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/util.ts", patch: "@@ -0,0 +1,2 @@\n+export const newThing = 1;\n+// TODO wire newThing later" }], + }, + throwingFetch, + ); + assert.equal(out.length, 1); + assert.equal(out[0].kind, "dead-on-arrival"); + assert.equal(out[0].symbol, "newThing"); +}); + +test("scanCallerImpact: a new export from a public entrypoint is not flagged dead", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/index.ts", patch: "@@ -0,0 +1,1 @@\n+export const apiThing = 1;" }], + }, + throwingFetch, + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: a re-export does not suppress dead-on-arrival", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [ + { path: "src/a.ts", patch: "@@ -0,0 +1,1 @@\n+export const orphan = 1;" }, + { path: "src/barrel.ts", patch: "@@ -0,0 +1,1 @@\n+export { orphan } from './a';" }, + ], + }, + throwingFetch, + ); + assert.ok(out.some((f) => f.symbol === "orphan" && f.kind === "dead-on-arrival")); +}); + +test("scanCallerImpact: dead-on-arrival runs without a token (diff-only, no network)", async () => { + const out = await scanCallerImpact( + { repoFullName: "o/r", prNumber: 1, files: [{ path: "src/util.ts", patch: "@@ -0,0 +1,1 @@\n+export const orphan = 1;" }] }, + throwingFetch, // no token ⇒ no network; must not be called + ); + assert.equal(out.length, 1); + assert.equal(out[0].kind, "dead-on-arrival"); + assert.equal(out[0].symbol, "orphan"); +}); + +test("scanCallerImpact: no token returns [] without any caller fetch", async () => { + const out = await scanCallerImpact( + { repoFullName: "o/r", prNumber: 1, files: [{ path: "src/lib.ts", patch: "@@ @@\n-export function foo(): void;" }] }, + throwingFetch, + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: a diff with no export churn returns []", async () => { + const out = await scanCallerImpact( + { repoFullName: "o/r", prNumber: 1, githubToken: "t", files: [{ path: "src/lib.ts", patch: "@@ -0,0 +1,1 @@\n+const local = 1;" }] }, + throwingFetch, + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: a rate-limited Code Search drops that symbol without throwing", async () => { + const out = await scanCallerImpact( + { + repoFullName: "o/r", + prNumber: 1, + githubToken: "t", + files: [{ path: "src/lib.ts", patch: "@@ -1,1 +0,0 @@\n-export function foo(): void;" }], + }, + async (url) => (url.includes("/search/code") ? res({}, { ok: false, status: 403 }) : res({ items: [] })), + ); + assert.deepEqual(out, []); +}); + +test("scanCallerImpact: an unsafe repoFullName is rejected before any fetch", async () => { + const out = await scanCallerImpact( + { repoFullName: "o/r/../x", prNumber: 1, githubToken: "t", files: [{ path: "a.ts", patch: "@@ @@\n-export const z = 1;" }] }, + throwingFetch, + ); + assert.deepEqual(out, []); +}); + +// ── render ────────────────────────────────────────────────────────────────────── + +test("renderBrief emits a public-safe caller-impact block", () => { + const { promptSection } = renderBrief({ + callerImpact: [ + { symbol: "foo", kind: "removed-with-callers", callerFiles: ["src/a.ts", "src/b.ts"] }, + { symbol: "baz", kind: "dead-on-arrival", callerFiles: [] }, + ], + }); + assert.match(promptSection, /Cross-file API impact/); + assert.match(promptSection, /`foo` removed\/renamed but still imported by 2 unchanged files/); + assert.match(promptSection, /`baz` is exported but referenced nowhere in this PR \(dead-on-arrival\)/); + assert.match(promptSection, /`src\/a\.ts`, `src\/b\.ts`/); +});