import type { FileDiffMetadata } from "@pierre/diffs"; import { resolveFileDiffPath } from "~/lib/diffRendering"; /** * Which of the three reading passes a file belongs to. * * A reviewer reads the change itself first, then what proves it, and a lockfile or a build output * only ever confirms what the source already said. Ordering by path alone buries the one file the * change is really about under whichever directory happens to sort first. */ export type DiffFileTier = "source" | "test" | "generated"; const GENERATED_FILE_NAMES = new Set([ "pnpm-lock.yaml", "yarn.lock", "package-lock.json", "bun.lockb", "Cargo.lock", "go.sum", "composer.lock", "Gemfile.lock", ]); const GENERATED_DIRECTORIES = new Set([ "__snapshots__", "__generated__", "dist", "build", "vendor", ]); const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; export function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); const name = segments.at(-1) ?? ""; if ( GENERATED_FILE_NAMES.has(name) || name.endsWith(".snap") || name.endsWith(".min.js") || name.endsWith(".min.css") || /\.generated\./.test(name) || segments.slice(0, -1).some((segment) => GENERATED_DIRECTORIES.has(segment)) ) { return "generated"; } if ( /\.(?:test|spec)\./.test(name) || segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment)) ) { return "test"; } return "source"; } function stripExtension(path: string): string { const dot = path.lastIndexOf("."); const slash = path.lastIndexOf("/"); return dot > slash ? path.slice(0, dot) : path; } function baseName(path: string): string { return stripExtension(path.split("/").at(-1) ?? ""); } /** Resolves `../x` against the importer's directory; no package resolution, only what git shows. */ function resolveRelative(fromPath: string, specifier: string): string { const segments = fromPath.split("/").slice(0, -1); for (const part of specifier.split("/")) { if (part === "." || part === "") continue; if (part === "..") segments.pop(); else segments.push(part); } return segments.join("/"); } // `import x from "y"`, `import "y"`, `export … from "y"` and `require("y")` all reduce to a quoted // specifier preceded by one of three words. const IMPORT_SPECIFIER = /(?:\bfrom|\bimport|\brequire\s*\()\s*\(?\s*["']([^"']+)["']/g; /** Which changed files a file's patch lines import, by path. */ function importedPaths( path: string, lines: ReadonlyArray, byModulePath: ReadonlyMap, byBaseName: ReadonlyMap, ): ReadonlySet { const imported = new Set(); for (const line of lines) { IMPORT_SPECIFIER.lastIndex = 0; let match = IMPORT_SPECIFIER.exec(line); while (match !== null) { const specifier = match[1] ?? ""; const withExtension = specifier.startsWith(".") ? resolveRelative(path, specifier) : specifier; // A specifier may name the extension the module map dropped, and it need not be the one on // disk: TypeScript's own imports point at the `.js` beside a `.ts`. const extension = MODULE_EXTENSIONS.find((candidate) => withExtension.endsWith(candidate)); const resolved = extension === undefined ? withExtension : withExtension.slice(0, -extension.length); const target = byModulePath.get(resolved) ?? byModulePath.get(`${resolved}/index`) ?? byBaseName.get(baseName(resolved)); if (target !== undefined && target !== path) imported.add(target); match = IMPORT_SPECIFIER.exec(line); } } return imported; } /** * Source files with what they import ahead of what imports them. * * Kahn's, but every step takes the lowest remaining path rather than any ready node, so the same * change always reads the same way and files of one directory stay together. A cycle leaves nothing * ready: the lowest path still standing is taken anyway, which is the alphabetical order the tier * would have had without a graph. */ function orderByImports( paths: ReadonlyArray, imports: ReadonlyMap>, ): Array { const remaining = new Set(paths); const ordered: Array = []; const sorted = [...paths].sort((left, right) => left.localeCompare(right)); while (remaining.size > 0) { const candidates = sorted.filter((path) => remaining.has(path)); const next = candidates.find((path) => [...(imports.get(path) ?? [])].every((dependency) => !remaining.has(dependency)), ) ?? candidates[0]!; remaining.delete(next); ordered.push(next); } return ordered; } /** The implementation a test names: `foo.test.ts` and `__tests__/foo.ts` both point at `foo`. */ function testedBaseName(path: string): string { return (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, "").replace(/\.[^.]+$/, ""); } /** * Diff files in reading order: source in dependency order, then the tests that cover it, then * whatever a tool wrote. */ export function orderDiffFiles( files: ReadonlyArray, ): ReadonlyArray { const byPath = new Map(); for (const file of files) byPath.set(resolveFileDiffPath(file), file); const tiers = new Map(); for (const path of byPath.keys()) tiers.set(path, diffFileTier(path)); const sourcePaths = [...byPath.keys()].filter((path) => tiers.get(path) === "source"); const byModulePath = new Map(); const ambiguousBaseNames = new Set(); const byBaseName = new Map(); for (const path of sourcePaths) { byModulePath.set(stripExtension(path), path); const base = baseName(path); if (byBaseName.has(base)) ambiguousBaseNames.add(base); byBaseName.set(base, path); } for (const base of ambiguousBaseNames) byBaseName.delete(base); const imports = new Map>(); for (const path of sourcePaths) { const file = byPath.get(path)!; imports.set( path, importedPaths(path, [...file.additionLines, ...file.deletionLines], byModulePath, byBaseName), ); } const orderedSource = orderByImports(sourcePaths, imports); const sourcePositions = new Map(); orderedSource.forEach((path, index) => { const base = baseName(path); if (!sourcePositions.has(base)) sourcePositions.set(base, index); }); const orderedTests = [...byPath.keys()] .filter((path) => tiers.get(path) === "test") .sort((left, right) => { const leftPosition = sourcePositions.get(testedBaseName(left)) ?? Number.MAX_SAFE_INTEGER; const rightPosition = sourcePositions.get(testedBaseName(right)) ?? Number.MAX_SAFE_INTEGER; return leftPosition - rightPosition || left.localeCompare(right); }); const orderedGenerated = [...byPath.keys()] .filter((path) => tiers.get(path) === "generated") .sort((left, right) => left.localeCompare(right)); return [...orderedSource, ...orderedTests, ...orderedGenerated].map((path) => byPath.get(path)!); }