import { globby } from "globby"; import { readFileSync, statSync } from "node:fs"; import path from "node:path"; import { resolveRuleConfigDefaults } from "../config"; import type { AnalyzerConfig, RuleConfig } from "../config"; import { discoverSourceFiles } from "../discovery/walk"; import { countLogicalLines, countPhysicalLines } from "../facts/ts-helpers"; import type { FunctionSummary } from "../facts/types"; import { buildFindingDeltaIdentity } from "../rule-delta"; import { FactStore } from "./fact-store"; import { Registry } from "./registry"; import { orderFactProviders, validateRuleRequirements } from "./scheduler"; import type { AnalysisResult, AnalysisSummary, AnalyzerRuntime, DirectoryRecord, FactProvider, FileRecord, Finding, ProviderContext, RuleFinding, RulePlugin, } from "./types"; /** Test hooks that bracket the point where transient file facts are released. */ export interface AnalyzeRepositoryHooks { onFileAnalyzed?(file: FileRecord, store: FactStore): void; onFileReleased?(file: FileRecord, store: FactStore): void; } export interface AnalyzeRepositoryOptions { hooks?: AnalyzeRepositoryHooks; } /** Pre-expanded override matches, including static-prefix directory hits for patterns like `src/rules/**`. */ interface ResolvedRuleOverride { rules: Record; filePaths: Set; directoryPaths: Set; } interface CachedFilePayload { size: bigint; mtimeNs: bigint; text: string; lineCount: number; logicalLineCount: number; } const MAX_FILE_PAYLOAD_CACHE_ENTRIES = 1000; const filePayloadCache = new Map(); function normalizePath(value: string): string { return value.split(path.sep).join("/").replace(/^\.\//, ""); } /** Pulls out the literal prefix so `src/rules/**` can also match the `src/rules` directory itself. */ function staticGlobPrefix(pattern: string): string { const normalized = normalizePath(pattern).replace(/\/+$/, ""); if (normalized.length === 0) { return "."; } const segments = normalized.split("/"); const staticSegments: string[] = []; for (const segment of segments) { if (/[*?[\]{}()!+@]/.test(segment)) { break; } staticSegments.push(segment); } return staticSegments.length > 0 ? staticSegments.join("/") : "."; } /** Expands override globs once so rule evaluation can use cheap set lookups. */ async function resolveRuleOverrides( rootDir: string, config: AnalyzerConfig, files: FileRecord[], directories: DirectoryRecord[], ): Promise { if (config.overrides.length === 0) { return []; } const scannedFilePaths = new Set(files.map((file) => file.path)); const scannedDirectoryPaths = new Set(directories.map((directory) => directory.path)); return Promise.all( config.overrides.map(async (override) => { const matchedFiles = ( await globby(override.files, { cwd: rootDir, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: config.ignores, ignoreFiles: ".gitignore", }) ) .map(normalizePath) .filter((filePath) => scannedFilePaths.has(filePath)); const matchedDirectories = ( await globby(override.files, { cwd: rootDir, onlyDirectories: true, dot: true, followSymbolicLinks: false, ignore: config.ignores, ignoreFiles: ".gitignore", }) ) .map(normalizePath) .filter((directoryPath) => scannedDirectoryPaths.has(directoryPath)); const filePaths = new Set(matchedFiles); const directoryPaths = new Set(matchedDirectories); if (matchedFiles.length > 0 || matchedDirectories.length > 0) { for (const pattern of override.files) { const prefix = staticGlobPrefix(pattern); if (prefix !== "." && scannedDirectoryPaths.has(prefix)) { directoryPaths.add(prefix); } } } return { rules: override.rules, filePaths, directoryPaths, }; }), ); } /** Applies matching path overrides in declaration order; later overrides win. */ function resolveRuleConfig( context: ProviderContext, ruleId: string, overrides: ResolvedRuleOverride[], ): RuleConfig | undefined { const baseRuleConfig = context.runtime.config.rules[ruleId]; if (overrides.length === 0 || context.scope === "repo") { return baseRuleConfig; } let resolved = baseRuleConfig; const targetPath = context.file?.path ?? context.directory?.path; if (!targetPath) { return resolved; } for (const override of overrides) { const matches = context.scope === "file" ? override.filePaths.has(targetPath) : override.directoryPaths.has(targetPath); if (!matches) { continue; } const ruleOverride = override.rules[ruleId]; if (!ruleOverride) { continue; } resolved = { ...resolved, ...ruleOverride, }; } return resolved; } function createRuntime( rootDir: string, config: AnalyzerConfig, files: FileRecord[], directories: DirectoryRecord[], store: FactStore, ): AnalyzerRuntime { return { rootDir, config, files, directories, store }; } function isPromiseLike(value: T | Promise): value is Promise { return typeof value === "object" && value !== null && "then" in value; } function cacheFilePayload(absolutePath: string, payload: CachedFilePayload): void { if ( !filePayloadCache.has(absolutePath) && filePayloadCache.size >= MAX_FILE_PAYLOAD_CACHE_ENTRIES ) { const oldestKey = filePayloadCache.keys().next().value; if (oldestKey) { filePayloadCache.delete(oldestKey); } } filePayloadCache.set(absolutePath, payload); } /** Reuses file text and line counts when size and mtime still match. */ function loadFilePayload(file: FileRecord): CachedFilePayload { const stats = statSync(file.absolutePath, { bigint: true }); const cached = filePayloadCache.get(file.absolutePath); if (cached && cached.size === stats.size && cached.mtimeNs === stats.mtimeNs) { return cached; } const text = readFileSync(file.absolutePath, "utf8"); const payload: CachedFilePayload = { size: stats.size, mtimeNs: stats.mtimeNs, text, lineCount: countPhysicalLines(text), logicalLineCount: countLogicalLines(text, file.path), }; cacheFilePayload(file.absolutePath, payload); return payload; } async function runProviders( providers: FactProvider[], contexts: ProviderContext[], store: FactStore, ): Promise { for (const context of contexts) { for (const provider of providers) { if (!provider.supports(context)) { continue; } const producedFactsResult = provider.run(context); const producedFacts = isPromiseLike(producedFactsResult) ? await producedFactsResult : producedFactsResult; for (const factId in producedFacts) { const value = producedFacts[factId]; if (context.scope === "file" && context.file) { store.setFileFact(context.file.path, factId, value); } else if (context.scope === "directory" && context.directory) { store.setDirectoryFact(context.directory.path, factId, value); } else if (context.scope === "repo") { store.setRepoFact(factId, value); } } } } } async function runRules( rules: RulePlugin[], contexts: ProviderContext[], overrides: ResolvedRuleOverride[], ): Promise { const findings: Finding[] = []; for (const context of contexts) { for (const rule of rules) { const resolvedRuleConfig = resolveRuleConfigDefaults( resolveRuleConfig(context, rule.id, overrides), ); const ruleContext = { ...context, ruleConfig: resolvedRuleConfig, } satisfies ProviderContext; if (!resolvedRuleConfig.enabled || !rule.supports(ruleContext)) { continue; } const nextFindingsResult = rule.evaluate(ruleContext); const nextFindings = isPromiseLike(nextFindingsResult) ? await nextFindingsResult : nextFindingsResult; for (const finding of nextFindings) { const weightedFinding = { ...finding, score: finding.score * resolvedRuleConfig.weight, } satisfies RuleFinding; const { deltaKeys: _deltaKeys, ...findingWithoutDeltaKeys } = weightedFinding; findings.push({ ...findingWithoutDeltaKeys, deltaIdentity: findingWithoutDeltaKeys.deltaIdentity ?? buildFindingDeltaIdentity(rule.id, weightedFinding, rule.delta), }); } } } return findings; } function buildFileScores(files: FileRecord[], findings: Finding[]) { const byFile = new Map(); for (const finding of findings) { if (!finding.path) { continue; } const next = byFile.get(finding.path) ?? { score: 0, findingCount: 0 }; next.score += finding.score; next.findingCount += 1; byFile.set(finding.path, next); } return files .map((file) => { const aggregate = byFile.get(file.path); return { path: file.path, score: aggregate?.score ?? 0, findingCount: aggregate?.findingCount ?? 0, }; }) .filter((score) => score.findingCount > 0) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); } function buildDirectoryScores(directories: DirectoryRecord[], findings: Finding[]) { const byDirectory = new Map(); for (const finding of findings) { if (finding.scope !== "directory" || !finding.path) { continue; } const next = byDirectory.get(finding.path) ?? { score: 0, findingCount: 0 }; next.score += finding.score; next.findingCount += 1; byDirectory.set(finding.path, next); } return directories .map((directory) => { const aggregate = byDirectory.get(directory.path); return { path: directory.path, score: aggregate?.score ?? 0, findingCount: aggregate?.findingCount ?? 0, }; }) .filter((score) => score.findingCount > 0) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); } function divideOrNull(numerator: number, denominator: number): number | null { return denominator > 0 ? numerator / denominator : null; } function buildSummary( files: FileRecord[], directories: DirectoryRecord[], findings: Finding[], store: FactStore, ): AnalysisSummary { const repoScore = findings.reduce((total, finding) => total + finding.score, 0); const physicalLineCount = files.reduce((total, file) => total + file.lineCount, 0); const logicalLineCount = files.reduce((total, file) => total + file.logicalLineCount, 0); const functionCount = files.reduce( (total, file) => total + (store.getFileFact(file.path, "file.functionSummaries")?.length ?? 0), 0, ); const kloc = logicalLineCount / 1000; return { fileCount: files.length, directoryCount: directories.length, findingCount: findings.length, repoScore, physicalLineCount, logicalLineCount, functionCount, normalized: { scorePerFile: divideOrNull(repoScore, files.length), scorePerKloc: divideOrNull(repoScore, kloc), scorePerFunction: divideOrNull(repoScore, functionCount), findingsPerFile: divideOrNull(findings.length, files.length), findingsPerKloc: divideOrNull(findings.length, kloc), findingsPerFunction: divideOrNull(findings.length, functionCount), }, }; } /** Keeps only file facts that later providers or delayed rules still need. */ function requiredFileFacts(items: Array<{ requires: string[] }>): Set { const facts = new Set(); for (const item of items) { for (const factId of item.requires) { if (factId.startsWith("file.")) { facts.add(factId); } } } return facts; } /** Runs file rules in an immediate pass and a delayed pass so transient file facts can be dropped before broader-scope work. */ export async function analyzeRepository( rootDir: string, config: AnalyzerConfig, registry: Registry, options: AnalyzeRepositoryOptions = {}, ): Promise { const discovery = await discoverSourceFiles(rootDir, config, registry.getLanguages()); const resolvedRuleOverrides = await resolveRuleOverrides( rootDir, config, discovery.files, discovery.directories, ); const store = new FactStore(); const runtime = createRuntime(rootDir, config, discovery.files, discovery.directories, store); const fileProviders = registry.getFactProviders().filter((provider) => provider.scope === "file"); const directoryProviders = registry .getFactProviders() .filter((provider) => provider.scope === "directory"); const repoProviders = registry.getFactProviders().filter((provider) => provider.scope === "repo"); const fileRules = registry.getRules().filter((rule) => rule.scope === "file"); const directoryRules = registry.getRules().filter((rule) => rule.scope === "directory"); const repoRules = registry.getRules().filter((rule) => rule.scope === "repo"); const fileBaseFacts = ["file.record", "file.text", "file.lineCount", "file.logicalLineCount"]; const orderedFileProviders = orderFactProviders(fileProviders, fileBaseFacts); const fileDerivedFacts = orderedFileProviders.flatMap((provider) => provider.provides); const orderedDirectoryProviders = orderFactProviders(directoryProviders, [ "directory.record", ...fileBaseFacts, ...fileDerivedFacts, ]); const directoryDerivedFacts = orderedDirectoryProviders.flatMap((provider) => provider.provides); const orderedRepoProviders = orderFactProviders(repoProviders, [ "repo.files", "repo.directories", "directory.record", ...fileBaseFacts, ...fileDerivedFacts, ...directoryDerivedFacts, ]); const availableFacts = [ ...fileBaseFacts, ...fileDerivedFacts, "directory.record", ...directoryDerivedFacts, "repo.files", "repo.directories", ...orderedRepoProviders.flatMap((provider) => provider.provides), ]; validateRuleRequirements( registry.getRules().map((rule) => ({ id: rule.id, requires: rule.requires })), availableFacts, ); const immediateFileRules = fileRules.filter((rule) => rule.requires.every((factId) => factId.startsWith("file.")), ); const delayedFileRules = fileRules.filter((rule) => !immediateFileRules.includes(rule)); const durableFileFacts = new Set(["file.lineCount", "file.logicalLineCount"]); for (const factId of requiredFileFacts([ ...orderedDirectoryProviders, ...orderedRepoProviders, ...delayedFileRules, ...directoryRules, ...repoRules, ])) { durableFileFacts.add(factId); } store.setRepoFact("repo.files", discovery.files); store.setRepoFact("repo.directories", discovery.directories); for (const directory of discovery.directories) { store.setDirectoryFact(directory.path, "directory.record", directory); } const findings: Finding[] = []; for (const file of discovery.files) { const payload = loadFilePayload(file); file.lineCount = payload.lineCount; file.logicalLineCount = payload.logicalLineCount; store.setFileFacts(file.path, { "file.record": file, "file.text": payload.text, "file.lineCount": file.lineCount, "file.logicalLineCount": file.logicalLineCount, }); const context = { scope: "file", file, runtime } satisfies ProviderContext; await runProviders(orderedFileProviders, [context], store); findings.push(...(await runRules(immediateFileRules, [context], resolvedRuleOverrides))); options.hooks?.onFileAnalyzed?.(file, store); store.retainFileFacts(file.path, durableFileFacts); options.hooks?.onFileReleased?.(file, store); } await runProviders( orderedDirectoryProviders, discovery.directories.map((directory) => ({ scope: "directory", directory, runtime })), store, ); await runProviders(orderedRepoProviders, [{ scope: "repo", runtime }], store); findings.push( ...(await runRules( delayedFileRules, discovery.files.map((file) => ({ scope: "file", file, runtime })), resolvedRuleOverrides, )), ); findings.push( ...(await runRules( directoryRules, discovery.directories.map((directory) => ({ scope: "directory", directory, runtime })), resolvedRuleOverrides, )), ); findings.push( ...(await runRules(repoRules, [{ scope: "repo", runtime }], resolvedRuleOverrides)), ); const fileScores = buildFileScores(discovery.files, findings); const directoryScores = buildDirectoryScores(discovery.directories, findings); const summary = buildSummary(discovery.files, discovery.directories, findings, store); return { rootDir, config, summary, files: discovery.files, directories: discovery.directories, findings, fileScores, directoryScores, repoScore: summary.repoScore, }; }