diff --git a/gitnexus/src/core/incremental/closure.ts b/gitnexus/src/core/incremental/closure.ts new file mode 100644 index 000000000..6e07d23f1 --- /dev/null +++ b/gitnexus/src/core/incremental/closure.ts @@ -0,0 +1,115 @@ +/** + * Iterative importer-closure computation for incremental indexing. + * + * Given a set of changed files, walks the IMPORTS graph (queried from the + * existing DB) to determine which other files must also be re-parsed for the + * incremental run to be byte-equivalent to a full rebuild. + * + * Algorithm: + * 1. Start with `closure = changedFiles`. + * 2. For each file `f` newly added to closure: parse it, extract surface. + * 3. If surface differs from `prevSurfaces[f]`, add all importers of `f` + * to closure. + * 4. Repeat until no new files added. + * + * Termination: each iteration either adds files or stops. The universe of + * files is finite (bounded by the repo size), so the loop terminates. + * + * Correctness invariant: a file is added to the closure iff its CALLS or + * IMPORTS edges might resolve differently than they did at `lastCommit`. The + * surface check is the crisp condition: surface unchanged → no resolver + * change → file's edges don't need re-emission. + */ + +export interface ClosureInput { + /** Initial set of files (from git diff: modified ∪ added). */ + initialChangedFiles: Set; + /** Previously stored surface signatures (filePath → hash). */ + prevSurfaces: Record; + /** + * Parse one file and return the parser result. The closure logic only + * cares about producing a stable surface signature, so callers may use + * the same parse worker the main pipeline uses. + */ + parseFile: (filePath: string) => Promise; + /** + * Compute the surface signature for `filePath` given the parse result. + * Returned signature is hashed and compared against `prevSurfaces`. + */ + surfaceFor: (filePath: string, parsed: TParseResult) => string; + /** + * Query the existing DB for files that import `filePath`. Returns + * repo-relative paths. A missing file (e.g. importer of a brand-new file) + * legitimately returns `[]`. + */ + queryImporters: (filePath: string) => Promise; +} + +export interface ClosureResult { + /** Final set of files that must be re-parsed. */ + closure: Set; + /** + * Cache of parsed results, keyed by file path. The orchestrator hands + * this to the pipeline so the parse phase doesn't re-parse closure files. + */ + parseCache: Map; + /** Newly computed surface signatures, keyed by file path. */ + newSurfaces: Map; + /** + * Files added to closure ONLY because an importer chain pulled them in + * (not in the initial changed set). Useful for logging. + */ + expandedFromImporters: Set; +} + +/** + * Compute the transitive importer closure of the initial changed files, + * pruned by the surface-change optimization. + * + * The function is generic over `TParseResult` — the orchestrator is free + * to use the existing parse-worker result type, but the closure module + * itself is decoupled from any specific parse representation. + */ +export async function computeImporterClosure( + input: ClosureInput, +): Promise> { + const { initialChangedFiles, prevSurfaces, parseFile, surfaceFor, queryImporters } = input; + + const closure = new Set(initialChangedFiles); + const queue: string[] = [...initialChangedFiles]; + const parseCache = new Map(); + const newSurfaces = new Map(); + const expandedFromImporters = new Set(); + + while (queue.length > 0) { + const f = queue.shift()!; + + // Parse this file (if we haven't already in this run). + let parsed = parseCache.get(f); + if (parsed === undefined) { + parsed = await parseFile(f); + parseCache.set(f, parsed); + } + + // Compute the new surface signature. + const newSurface = surfaceFor(f, parsed); + newSurfaces.set(f, newSurface); + + // If the surface changed, every file importing `f` may have stale + // resolver output and must be re-parsed. + const prev = prevSurfaces[f]; + const changed = prev === undefined || prev !== newSurface; + if (changed) { + const importers = await queryImporters(f); + for (const i of importers) { + if (!closure.has(i)) { + closure.add(i); + queue.push(i); + expandedFromImporters.add(i); + } + } + } + } + + return { closure, parseCache, newSurfaces, expandedFromImporters }; +} diff --git a/gitnexus/src/core/incremental/git-diff.ts b/gitnexus/src/core/incremental/git-diff.ts new file mode 100644 index 000000000..eaaa21134 --- /dev/null +++ b/gitnexus/src/core/incremental/git-diff.ts @@ -0,0 +1,212 @@ +/** + * Git-based change detection for incremental indexing. + * + * Combines `git diff --name-status HEAD` (committed changes since + * last index) with `git status --porcelain` (uncommitted/dirty tree changes) + * to produce a precise per-file change set. + * + * Renames (R in diff output, R in porcelain) are flattened into + * delete(old) + add(new) — downstream consumers don't need to know about + * renames specifically; they just need to know "this path's nodes are stale, + * delete them" and "this path is new, parse it". + * + * Returns a typed result; throws `LastCommitMissingError` when `lastCommit` + * no longer exists in the repository (rebase or shallow clone). Callers + * should fall back to a full rebuild on this error. + */ + +import { execFileSync } from 'child_process'; + +export interface ChangedFiles { + /** Files whose content changed (re-parse needed). */ + modified: string[]; + /** Files newly introduced since lastCommit (re-parse needed). */ + added: string[]; + /** Files that existed at lastCommit but no longer do (delete-only, no parse). */ + deleted: string[]; +} + +export class LastCommitMissingError extends Error { + constructor(commit: string) { + super(`lastCommit ${commit} not found in repository — cannot compute incremental diff`); + this.name = 'LastCommitMissingError'; + } +} + +/** + * Compute the set of files changed in `repoPath` since `lastCommit`. + * Combines committed differences (`git diff` against HEAD) with the + * current dirty working-tree state (`git status`). + * + * @throws LastCommitMissingError when `lastCommit` is not reachable. + */ +export function getChangedFilesSinceCommit( + repoPath: string, + lastCommit: string, +): ChangedFiles { + if (!commitExists(repoPath, lastCommit)) { + throw new LastCommitMissingError(lastCommit); + } + + const modified = new Set(); + const added = new Set(); + const deleted = new Set(); + + // ── Committed differences: lastCommit..HEAD ──────────────────────────── + // Output (NUL-delimited via -z): STATUS\0path1\0[path2\0] + // Status codes: M, A, D, T (type change → treat as modified), + // R, C (rename/copy with similarity %). + const diffRaw = runGit(repoPath, [ + 'diff', + '--name-status', + '-z', + '--no-renames=false', + `${lastCommit}`, + 'HEAD', + ]); + + for (const entry of parseNameStatusZ(diffRaw)) { + classify(entry, modified, added, deleted); + } + + // ── Working-tree differences: HEAD..disk ─────────────────────────────── + // Format (NUL-delimited): XYpath[\0orig-path] + // X = staged, Y = unstaged. Either non-' ' counts as a change. + const statusRaw = runGit(repoPath, ['status', '--porcelain', '-z']); + for (const entry of parsePorcelainZ(statusRaw)) { + classify(entry, modified, added, deleted); + } + + // Resolve overlaps: a file added in the diff and modified in the status + // is just "new on disk" → keep it in `added`. A file deleted in the diff + // but present in status as added (re-introduced) → modified. + for (const f of added) { + modified.delete(f); + deleted.delete(f); + } + for (const f of deleted) { + modified.delete(f); + } + + return { + modified: [...modified].sort(), + added: [...added].sort(), + deleted: [...deleted].sort(), + }; +} + +interface ParsedEntry { + status: string; + path: string; + origPath?: string; +} + +function classify( + entry: ParsedEntry, + modified: Set, + added: Set, + deleted: Set, +): void { + const code = entry.status[0]; + switch (code) { + case 'M': + case 'T': // type change (file → symlink, etc.) + modified.add(entry.path); + break; + case 'A': + case '?': // untracked (porcelain '??') treat as added + added.add(entry.path); + break; + case 'D': + deleted.add(entry.path); + break; + case 'R': // rename: flatten to delete(orig) + add(new) + case 'C': // copy: original survives; new file is added + if (entry.origPath && code === 'R') deleted.add(entry.origPath); + added.add(entry.path); + break; + case 'U': // unmerged — surface as modified, caller's problem + modified.add(entry.path); + break; + default: + // Unknown status — be conservative, treat as modified. + modified.add(entry.path); + } +} + +/** + * Parse `git diff --name-status -z` output. NUL-delimited, status before each path: + * "M\0a.ts\0A\0b.ts\0R100\0old.ts\0new.ts\0" + */ +function parseNameStatusZ(raw: string): ParsedEntry[] { + const entries: ParsedEntry[] = []; + if (!raw) return entries; + const tokens = raw.split('\0').filter((t) => t.length > 0); + let i = 0; + while (i < tokens.length) { + const status = tokens[i++]; + const path = tokens[i++]; + if (path === undefined) break; + if (status[0] === 'R' || status[0] === 'C') { + const newPath = tokens[i++]; + if (newPath !== undefined) { + entries.push({ status, path: newPath, origPath: path }); + } + } else { + entries.push({ status, path }); + } + } + return entries; +} + +/** + * Parse `git status --porcelain -z` output. NUL-delimited, two-char status + * code + space + path; for renames the original path follows after another NUL: + * "M a.ts\0?? b.ts\0R new.ts\0old.ts\0" + */ +function parsePorcelainZ(raw: string): ParsedEntry[] { + const entries: ParsedEntry[] = []; + if (!raw) return entries; + const tokens = raw.split('\0').filter((t) => t.length > 0); + let i = 0; + while (i < tokens.length) { + const tok = tokens[i++]; + // First two chars are the XY status, then a space, then the path. + const xy = tok.slice(0, 2); + const path = tok.slice(3); + // Effective status: prefer staged (X) when not ' ', otherwise unstaged (Y). + const code = xy[0] !== ' ' && xy[0] !== '?' ? xy[0] : xy[1]; + if (xy.startsWith('R') || xy[1] === 'R') { + // Rename: next token is the original path. + const orig = tokens[i++]; + entries.push({ status: 'R', path, origPath: orig }); + } else if (xy === '??') { + entries.push({ status: '?', path }); + } else { + entries.push({ status: code, path }); + } + } + return entries; +} + +function commitExists(repoPath: string, commit: string): boolean { + try { + execFileSync('git', ['cat-file', '-e', `${commit}^{commit}`], { + cwd: repoPath, + stdio: ['ignore', 'ignore', 'ignore'], + }); + return true; + } catch { + return false; + } +} + +function runGit(repoPath: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + // Larger buffer for large repos with many changes. + maxBuffer: 100 * 1024 * 1024, + }); +} diff --git a/gitnexus/src/core/incremental/surface.ts b/gitnexus/src/core/incremental/surface.ts new file mode 100644 index 000000000..7cf3e8eb9 --- /dev/null +++ b/gitnexus/src/core/incremental/surface.ts @@ -0,0 +1,135 @@ +/** + * Public-surface signature extraction for incremental indexing. + * + * The surface signature of a file is a stable hash of everything that could + * affect callers in *other* files: the names, signatures, and heritage of + * its publicly-visible symbols (functions, classes, methods, interfaces). + * + * If a file's surface hash is unchanged between runs, no other file's + * resolution depends on the changed content — we only need to re-parse the + * file itself, not its importers. This is the closure-scoping optimization + * driven by `closure.ts`. + * + * The hash is content-only and excludes formatting, comments, and ordering + * (we sort the symbol list before hashing). It does NOT include implementation + * bodies — that's the whole point: a body-only edit produces the same surface. + */ + +import { createHash } from 'crypto'; +import type { GraphNode } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; + +/** Node labels whose presence in a file contributes to its public surface. */ +const SURFACE_LABELS = new Set([ + 'Function', + 'Class', + 'Method', + 'Constructor', + 'Interface', + 'TypeAlias', + 'Enum', + 'Struct', + 'Trait', + 'Namespace', + 'Module', + 'Const', + 'Static', + 'Property', + 'Record', + 'Delegate', + 'Annotation', + 'Template', + 'Union', + 'Macro', + 'Typedef', +]); + +/** + * Extract the surface signature of `filePath` from `graph`. + * Returns a stable hex hash; identical-surface inputs → identical output. + */ +export function extractSurfaceSignature( + graph: KnowledgeGraph, + filePath: string, +): string { + const lines: string[] = []; + + // Gather surface-relevant nodes for this file. + const surfaceNodes: GraphNode[] = []; + graph.forEachNode((node) => { + if ( + node.properties?.filePath === filePath && + SURFACE_LABELS.has(node.label) + ) { + surfaceNodes.push(node); + } + }); + + // Sort deterministically by id so reorder doesn't affect the hash. + surfaceNodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + for (const n of surfaceNodes) { + lines.push(serializeNode(n)); + } + + // Heritage: edges that fan OUT of this file affect downstream resolution. + // (EXTENDS / IMPLEMENTS targets — if the parent class changes, dependents + // may resolve methods differently.) These edges are co-located with the + // child symbol whose `filePath` matches; we walk them off the source side. + const edgeKeys: string[] = []; + graph.forEachRelationship((rel) => { + if (rel.type !== 'EXTENDS' && rel.type !== 'IMPLEMENTS') return; + const src = graph.getNode(rel.sourceId); + if (!src) return; + if (src.properties?.filePath !== filePath) return; + edgeKeys.push(`H ${rel.type} ${rel.sourceId} -> ${rel.targetId}`); + }); + edgeKeys.sort(); + for (const k of edgeKeys) lines.push(k); + + const h = createHash('sha256'); + for (const line of lines) { + h.update(line); + h.update('\n'); + } + return h.digest('hex'); +} + +/** True iff `current` differs from `prev` (or `prev` is undefined). */ +export function surfaceChanged( + prev: string | undefined, + current: string, +): boolean { + return prev === undefined || prev !== current; +} + +/** + * Serialize a single surface node to a stable string. We include label, + * name, parameter count + types, return type, and visibility-style flags + * — anything that could affect how a caller resolves against this node. + * + * We DO NOT include line numbers, file offsets, or any other position + * data: surface should be invariant under whitespace/formatting changes. + */ +function serializeNode(node: GraphNode): string { + const p = (node.properties ?? {}) as Record; + const parts: string[] = [ + `N`, + node.label, + String(p.name ?? ''), + `id=${node.id}`, + ]; + if (p.parameterCount !== undefined) parts.push(`pc=${String(p.parameterCount)}`); + if (Array.isArray(p.parameterTypes)) { + parts.push(`pt=${(p.parameterTypes as string[]).join(',')}`); + } + if (p.returnType !== undefined) parts.push(`rt=${String(p.returnType)}`); + if (p.visibility !== undefined) parts.push(`v=${String(p.visibility)}`); + if (p.isStatic) parts.push('static'); + if (p.isAbstract) parts.push('abstract'); + if (p.isReadonly) parts.push('readonly'); + if (p.isAsync) parts.push('async'); + // `level` (inheritance depth marker for methods) affects MRO + if (p.level !== undefined) parts.push(`lvl=${String(p.level)}`); + return parts.join('|'); +} diff --git a/gitnexus/test/unit/incremental-closure.test.ts b/gitnexus/test/unit/incremental-closure.test.ts new file mode 100644 index 000000000..697056e49 --- /dev/null +++ b/gitnexus/test/unit/incremental-closure.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from 'vitest'; +import { computeImporterClosure } from '../../src/core/incremental/closure.js'; + +/** + * Mini test harness: a fixture import graph + per-file surface signatures. + * + * `imports[X] = [a, b]` means files `a` and `b` import file `X`. So when we + * ask for "importers of X" the answer is `[a, b]`. + */ +interface Fixture { + files: string[]; + /** target → list of importers */ + imports: Record; + /** previous surfaces */ + prevSurfaces: Record; + /** new surfaces (what parseFile + surfaceFor will produce) */ + newSurfaces: Record; +} + +function makeHarness(fx: Fixture) { + const parseFile = vi.fn(async (f: string) => ({ filePath: f })); + const surfaceFor = vi.fn((f: string) => fx.newSurfaces[f] ?? ''); + const queryImporters = vi.fn(async (f: string) => fx.imports[f] ?? []); + return { parseFile, surfaceFor, queryImporters }; +} + +describe('computeImporterClosure', () => { + it('empty input → empty closure', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: [], + imports: {}, + prevSurfaces: {}, + newSurfaces: {}, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(), + prevSurfaces: {}, + parseFile, + surfaceFor, + queryImporters, + }); + expect(r.closure.size).toBe(0); + expect(parseFile).not.toHaveBeenCalled(); + }); + + it('single file with unchanged surface → closure size 1, no expansion', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts'], + imports: { 'a.ts': ['b.ts', 'c.ts'] }, + prevSurfaces: { 'a.ts': 'sig-a-v1' }, + newSurfaces: { 'a.ts': 'sig-a-v1' }, // same + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'sig-a-v1' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure].sort()).toEqual(['a.ts']); + // queryImporters should not have been called: surface unchanged. + expect(queryImporters).not.toHaveBeenCalled(); + expect(r.expandedFromImporters.size).toBe(0); + }); + + it('single file with changed surface → expands to direct importers', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts', 'c.ts'], + imports: { + 'a.ts': ['b.ts', 'c.ts'], + 'b.ts': [], + 'c.ts': [], + }, + prevSurfaces: { 'a.ts': 'sig-old', 'b.ts': 'sig-b', 'c.ts': 'sig-c' }, + newSurfaces: { 'a.ts': 'sig-new', 'b.ts': 'sig-b', 'c.ts': 'sig-c' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'sig-old', 'b.ts': 'sig-b', 'c.ts': 'sig-c' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure].sort()).toEqual(['a.ts', 'b.ts', 'c.ts']); + expect([...r.expandedFromImporters].sort()).toEqual(['b.ts', 'c.ts']); + }); + + it('multi-hop cascade (A surface change → B in closure → B surface change → C in closure)', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts', 'c.ts'], + imports: { + 'a.ts': ['b.ts'], // b imports a + 'b.ts': ['c.ts'], // c imports b + 'c.ts': [], + }, + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old', 'c.ts': 'c-stable' }, + newSurfaces: { 'a.ts': 'new', 'b.ts': 'b-new', 'c.ts': 'c-stable' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old', 'c.ts': 'c-stable' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure].sort()).toEqual(['a.ts', 'b.ts', 'c.ts']); + }); + + it('cascade halts when surface stops changing mid-chain', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts', 'c.ts'], + imports: { + 'a.ts': ['b.ts'], + 'b.ts': ['c.ts'], + }, + // a's surface changed, b is in closure but b's surface unchanged → c stays out + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-stable', 'c.ts': 'c-stable' }, + newSurfaces: { 'a.ts': 'new', 'b.ts': 'b-stable', 'c.ts': 'c-stable' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-stable', 'c.ts': 'c-stable' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure].sort()).toEqual(['a.ts', 'b.ts']); + expect([...r.expandedFromImporters].sort()).toEqual(['b.ts']); + }); + + it('terminates on cycles in the import graph', async () => { + // a ↔ b cycle. a's surface changes, b is added; b's surface also "changed" + // (relative to undefined prev), so its importers are queried — which + // includes a, already in closure. Loop terminates because closure + // membership prevents re-add. + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts'], + imports: { 'a.ts': ['b.ts'], 'b.ts': ['a.ts'] }, + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old' }, + newSurfaces: { 'a.ts': 'new', 'b.ts': 'b-new' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure].sort()).toEqual(['a.ts', 'b.ts']); + // Each file parsed exactly once even though they import each other. + expect(parseFile).toHaveBeenCalledTimes(2); + }); + + it('newly-added file (no prev surface) triggers expansion', async () => { + // For a brand-new file, prevSurfaces[f] is undefined → surfaceChanged + // returns true → its importers are queried. (For a truly *new* file, + // there should be no importers yet, so closure stays at {f}.) + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['new.ts'], + imports: {}, + prevSurfaces: {}, + newSurfaces: { 'new.ts': 'sig-new' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['new.ts']), + prevSurfaces: {}, + parseFile, + surfaceFor, + queryImporters, + }); + expect([...r.closure]).toEqual(['new.ts']); + expect(queryImporters).toHaveBeenCalledOnce(); + }); + + it('parses each file exactly once and caches the result', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts'], + imports: { 'a.ts': ['b.ts'] }, + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b' }, + newSurfaces: { 'a.ts': 'new', 'b.ts': 'b' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect(parseFile).toHaveBeenCalledTimes(2); + expect(r.parseCache.size).toBe(2); + expect(r.parseCache.has('a.ts')).toBe(true); + expect(r.parseCache.has('b.ts')).toBe(true); + }); + + it('records new surfaces for every parsed file', async () => { + const { parseFile, surfaceFor, queryImporters } = makeHarness({ + files: ['a.ts', 'b.ts'], + imports: { 'a.ts': ['b.ts'] }, + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old' }, + newSurfaces: { 'a.ts': 'new', 'b.ts': 'b-new' }, + }); + const r = await computeImporterClosure({ + initialChangedFiles: new Set(['a.ts']), + prevSurfaces: { 'a.ts': 'old', 'b.ts': 'b-old' }, + parseFile, + surfaceFor, + queryImporters, + }); + expect(r.newSurfaces.get('a.ts')).toBe('new'); + expect(r.newSurfaces.get('b.ts')).toBe('b-new'); + }); +}); diff --git a/gitnexus/test/unit/incremental-git-diff.test.ts b/gitnexus/test/unit/incremental-git-diff.test.ts new file mode 100644 index 000000000..85efe8d36 --- /dev/null +++ b/gitnexus/test/unit/incremental-git-diff.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import { + getChangedFilesSinceCommit, + LastCommitMissingError, +} from '../../src/core/incremental/git-diff.js'; + +vi.mock('child_process', () => ({ + execFileSync: vi.fn(), +})); + +const mockExec = vi.mocked(execFileSync); + +/** + * Mock helper: queue a sequence of execFileSync return values. + * Order matters — the first call returns the first value, etc. + * + * Calls in `getChangedFilesSinceCommit`: + * 1. cat-file -e (commitExists check) + * 2. diff --name-status -z (committed changes) + * 3. status --porcelain -z (dirty tree) + */ +function mockGitSequence( + catFileSucceeds: boolean, + diffOutput: string, + statusOutput: string, +) { + mockExec.mockReset(); + if (catFileSucceeds) { + mockExec.mockImplementationOnce(() => Buffer.from('')); + } else { + mockExec.mockImplementationOnce(() => { + throw new Error('not in repo'); + }); + } + mockExec.mockImplementationOnce(() => diffOutput); + mockExec.mockImplementationOnce(() => statusOutput); +} + +describe('getChangedFilesSinceCommit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws LastCommitMissingError when commit not in repo', () => { + mockGitSequence(false, '', ''); + expect(() => getChangedFilesSinceCommit('/repo', 'deadbeef')).toThrow( + LastCommitMissingError, + ); + }); + + it('returns empty arrays for clean repo and no committed changes', () => { + mockGitSequence(true, '', ''); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r).toEqual({ modified: [], added: [], deleted: [] }); + }); + + it('parses committed M/A/D from diff --name-status -z', () => { + mockGitSequence( + true, + 'M\0src/a.ts\0A\0src/b.ts\0D\0src/c.ts\0', + '', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r).toEqual({ + modified: ['src/a.ts'], + added: ['src/b.ts'], + deleted: ['src/c.ts'], + }); + }); + + it('flattens R renames into delete(orig) + add(new)', () => { + mockGitSequence( + true, + 'R100\0src/old.ts\0src/new.ts\0', + '', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r).toEqual({ + modified: [], + added: ['src/new.ts'], + deleted: ['src/old.ts'], + }); + }); + + it('treats T (type change) as modified', () => { + mockGitSequence(true, 'T\0src/link.ts\0', ''); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r.modified).toContain('src/link.ts'); + }); + + it('parses dirty tree from status --porcelain -z', () => { + // 'M a.ts' = staged-modified. ' M b.ts' = unstaged-modified. + // '?? c.ts' = untracked. ' D d.ts' = unstaged-deleted. + mockGitSequence( + true, + '', + 'M a.ts\0 M b.ts\0?? c.ts\0 D d.ts\0', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r.modified.sort()).toEqual(['a.ts', 'b.ts']); + expect(r.added).toEqual(['c.ts']); + expect(r.deleted).toEqual(['d.ts']); + }); + + it('unions committed + dirty changes', () => { + mockGitSequence( + true, + 'M\0a.ts\0', // committed: a.ts modified + ' M b.ts\0?? c.ts\0', // dirty: b.ts modified, c.ts untracked + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r.modified.sort()).toEqual(['a.ts', 'b.ts']); + expect(r.added).toEqual(['c.ts']); + expect(r.deleted).toEqual([]); + }); + + it('resolves overlap: file added in diff and modified in status → added', () => { + mockGitSequence( + true, + 'A\0newfile.ts\0', + ' M newfile.ts\0', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r).toEqual({ + modified: [], + added: ['newfile.ts'], + deleted: [], + }); + }); + + it('handles porcelain rename ("R new\\0old")', () => { + mockGitSequence( + true, + '', + 'R new.ts\0old.ts\0', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r.added).toEqual(['new.ts']); + // Porcelain rename: `R` in status doesn't pre-flatten old into deleted + // because git already resolved the rename — but our parser does flatten + // when the diff layer flags it. For status-only, the original is the + // rename source and we don't claim to know it was deleted from index. + }); + + it('returns sorted output for stable comparison', () => { + mockGitSequence( + true, + 'M\0z.ts\0M\0a.ts\0M\0m.ts\0', + '', + ); + const r = getChangedFilesSinceCommit('/repo', 'abc'); + expect(r.modified).toEqual(['a.ts', 'm.ts', 'z.ts']); + }); + + it('passes correct cwd to git', () => { + mockGitSequence(true, '', ''); + getChangedFilesSinceCommit('/some/path', 'abc'); + for (const call of mockExec.mock.calls) { + expect((call[2] as { cwd: string }).cwd).toBe('/some/path'); + } + }); +}); diff --git a/gitnexus/test/unit/incremental-surface.test.ts b/gitnexus/test/unit/incremental-surface.test.ts new file mode 100644 index 000000000..5ecd6346e --- /dev/null +++ b/gitnexus/test/unit/incremental-surface.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + extractSurfaceSignature, + surfaceChanged, +} from '../../src/core/incremental/surface.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; + +function fn( + id: string, + filePath: string, + name: string, + extra: Record = {}, +): GraphNode { + return { + id, + label: 'Function', + properties: { name, filePath, ...extra }, + }; +} + +function method( + id: string, + filePath: string, + name: string, + extra: Record = {}, +): GraphNode { + return { + id, + label: 'Method', + properties: { name, filePath, ...extra }, + }; +} + +function cls( + id: string, + filePath: string, + name: string, + extra: Record = {}, +): GraphNode { + return { + id, + label: 'Class', + properties: { name, filePath, ...extra }, + }; +} + +function rel( + id: string, + type: GraphRelationship['type'], + src: string, + dst: string, +): GraphRelationship { + return { id, type, sourceId: src, targetId: dst, confidence: 1, reason: 't' }; +} + +describe('extractSurfaceSignature', () => { + it('returns a stable hash for an empty file', () => { + const g = createKnowledgeGraph(); + const h1 = extractSurfaceSignature(g, 'a.ts'); + const h2 = extractSurfaceSignature(g, 'a.ts'); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[a-f0-9]{64}$/); + }); + + it('produces the same hash for the same surface', () => { + const g1 = createKnowledgeGraph(); + g1.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + const g2 = createKnowledgeGraph(); + g2.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + expect(extractSurfaceSignature(g1, 'a.ts')).toBe( + extractSurfaceSignature(g2, 'a.ts'), + ); + }); + + it('is invariant under node insertion order', () => { + const g1 = createKnowledgeGraph(); + g1.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + g1.addNode(fn('Function:a.ts:bar#1', 'a.ts', 'bar', { parameterCount: 1 })); + const g2 = createKnowledgeGraph(); + g2.addNode(fn('Function:a.ts:bar#1', 'a.ts', 'bar', { parameterCount: 1 })); + g2.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + expect(extractSurfaceSignature(g1, 'a.ts')).toBe( + extractSurfaceSignature(g2, 'a.ts'), + ); + }); + + it('changes when a function is renamed', () => { + const g1 = createKnowledgeGraph(); + g1.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo')); + const g2 = createKnowledgeGraph(); + g2.addNode(fn('Function:a.ts:bar#0', 'a.ts', 'bar')); + expect(extractSurfaceSignature(g1, 'a.ts')).not.toBe( + extractSurfaceSignature(g2, 'a.ts'), + ); + }); + + it('changes when a function signature changes', () => { + const g1 = createKnowledgeGraph(); + g1.addNode( + fn('Function:a.ts:foo#1', 'a.ts', 'foo', { + parameterCount: 1, + parameterTypes: ['number'], + returnType: 'string', + }), + ); + const g2 = createKnowledgeGraph(); + g2.addNode( + fn('Function:a.ts:foo#1', 'a.ts', 'foo', { + parameterCount: 1, + parameterTypes: ['string'], + returnType: 'string', + }), + ); + expect(extractSurfaceSignature(g1, 'a.ts')).not.toBe( + extractSurfaceSignature(g2, 'a.ts'), + ); + }); + + it('does NOT change for body-only edits (no surface mutation)', () => { + // Body-only edits don't add/remove/rename surface nodes — same hash. + const g = createKnowledgeGraph(); + g.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + const h1 = extractSurfaceSignature(g, 'a.ts'); + // Re-build with same surface (simulating a re-parse of a body-only edit). + const g2 = createKnowledgeGraph(); + g2.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo', { parameterCount: 0 })); + const h2 = extractSurfaceSignature(g2, 'a.ts'); + expect(h1).toBe(h2); + }); + + it('only considers nodes whose filePath matches', () => { + const g = createKnowledgeGraph(); + g.addNode(fn('Function:a.ts:foo#0', 'a.ts', 'foo')); + g.addNode(fn('Function:b.ts:bar#0', 'b.ts', 'bar')); + const ha = extractSurfaceSignature(g, 'a.ts'); + const hb = extractSurfaceSignature(g, 'b.ts'); + expect(ha).not.toBe(hb); + // Adding a node in a OTHER file should not change a's signature. + g.addNode(fn('Function:c.ts:baz#0', 'c.ts', 'baz')); + expect(extractSurfaceSignature(g, 'a.ts')).toBe(ha); + }); + + it('changes when EXTENDS target changes', () => { + const g1 = createKnowledgeGraph(); + g1.addNode(cls('Class:a.ts:Child#0', 'a.ts', 'Child')); + g1.addNode(cls('Class:b.ts:ParentA#0', 'b.ts', 'ParentA')); + g1.addRelationship( + rel('r1', 'EXTENDS', 'Class:a.ts:Child#0', 'Class:b.ts:ParentA#0'), + ); + + const g2 = createKnowledgeGraph(); + g2.addNode(cls('Class:a.ts:Child#0', 'a.ts', 'Child')); + g2.addNode(cls('Class:b.ts:ParentB#0', 'b.ts', 'ParentB')); + g2.addRelationship( + rel('r1', 'EXTENDS', 'Class:a.ts:Child#0', 'Class:b.ts:ParentB#0'), + ); + + expect(extractSurfaceSignature(g1, 'a.ts')).not.toBe( + extractSurfaceSignature(g2, 'a.ts'), + ); + }); + + it('includes Method/Class/Interface in the surface', () => { + const g = createKnowledgeGraph(); + g.addNode(cls('Class:a.ts:C#0', 'a.ts', 'C')); + g.addNode(method('Method:a.ts:C.m#0', 'a.ts', 'm')); + const h = extractSurfaceSignature(g, 'a.ts'); + expect(h).toMatch(/^[a-f0-9]{64}$/); + }); +}); + +describe('surfaceChanged', () => { + it('returns true when prev is undefined (first index of file)', () => { + expect(surfaceChanged(undefined, 'abc')).toBe(true); + }); + + it('returns false when prev equals current', () => { + expect(surfaceChanged('abc', 'abc')).toBe(false); + }); + + it('returns true when prev differs from current', () => { + expect(surfaceChanged('abc', 'def')).toBe(true); + }); +});