import { existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { dirname, extname, join, resolve } from "node:path"; import type { PromptSignals, RepositorySignals } from "./types.js"; const filePathPattern = /\b(?:[\w.-]+\/)*[\w.-]+\.(?:[cm]?[jt]sx?|json|md|py|go|rs|java|rb|php|css|html|yaml|yml)\b/gi; export function collectPromptSignals(prompt: string): PromptSignals { return { mentionedFileCount: new Set(prompt.match(filePathPattern) ?? []).size }; } const SIGNAL_CACHE_TTL_MS = 10_000; const MAX_FILES = 10_000; const MAX_SCAN_MS = 50; const ignoredDirectories = new Set([".git", ".pi", "node_modules"]); const languageByExtension: Readonly> = { ".ts": "ts", ".tsx": "ts", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js", ".py": "py", ".go": "go", ".rs": "rs", ".java": "java", ".rb": "rb", ".php": "php", ".css": "css", ".html": "html", ".json": "json", ".md": "md", ".yml": "yaml", ".yaml": "yaml", }; let repositoryCache: { cwd: string; expiresAt: number; signals: RepositorySignals } | undefined; export function clearRepositorySignalCache(): void { repositoryCache = undefined; } function projectRoot(cwd: string): string { let current = resolve(cwd); while (true) { if (existsSync(join(current, ".git"))) return current; const parent = dirname(current); if (parent === current) return resolve(cwd); current = parent; } } function gitDiffSize(cwd: string): number | undefined { try { const output = execFileSync("git", ["diff", "--numstat"], { cwd, encoding: "utf8", timeout: 100, stdio: ["ignore", "pipe", "ignore"] }); return output.split("\n").reduce((total, line) => { const [added, removed] = line.split("\t"); return total + (Number(added) || 0) + (Number(removed) || 0); }, 0); } catch { return undefined; } } /** Bounded, best-effort filesystem inspection; failures intentionally return neutral signals. */ export function collectRepositorySignals(cwd: string, now = Date.now()): RepositorySignals { if (repositoryCache?.cwd === cwd && repositoryCache.expiresAt > now) return repositoryCache.signals; try { const root = projectRoot(cwd); const deadline = Date.now() + MAX_SCAN_MS; const directories = [root]; const languageMix: Record = {}; let fileCount = 0; while (directories.length && fileCount < MAX_FILES && Date.now() <= deadline) { const directory = directories.pop()!; for (const entry of readdirSync(directory, { withFileTypes: true })) { if (entry.isDirectory()) { if (!ignoredDirectories.has(entry.name)) directories.push(join(directory, entry.name)); } else if (entry.isFile()) { fileCount += 1; const language = languageByExtension[extname(entry.name).toLowerCase()]; if (language) languageMix[language] = (languageMix[language] ?? 0) + 1; if (fileCount >= MAX_FILES || Date.now() > deadline) break; } } } const signals: RepositorySignals = { projectRoot: root, fileCount, languageMix, diffSize: gitDiffSize(root) }; repositoryCache = { cwd, expiresAt: now + SIGNAL_CACHE_TTL_MS, signals }; return signals; } catch { return {}; } } export function repositorySignalScore(signals: RepositorySignals = {}): number { let score = 0; if ((signals.fileCount ?? 0) >= 10_000) score += 15; else if ((signals.fileCount ?? 0) >= 1_000) score += 10; if ((signals.diffSize ?? 0) >= 500) score += 10; if ((signals.promptFileCount ?? 0) >= 4) score += 10; if (Object.keys(signals.languageMix ?? {}).length >= 4) score += 5; return Math.min(score, 30); }