mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(incremental): change-detection, surface signatures, closure expansion
Three new modules supporting the incremental-indexing pipeline: * core/incremental/git-diff.ts — getChangedFilesSinceCommit() unions 'git diff lastCommit HEAD' (committed) with 'git status --porcelain' (dirty tree). Renames flattened to delete(orig) + add(new). Throws LastCommitMissingError when lastCommit is gone (caller falls back to full rebuild). * core/incremental/surface.ts — extractSurfaceSignature() produces a stable hash of a file's publicly-visible symbols (functions, classes, methods, interfaces, types, heritage). Body-only edits → same hash. Signature/heritage changes → different hash. Drives the closure scoping optimization. * core/incremental/closure.ts — computeImporterClosure() iterative fixpoint: parse each closure file, extract surface, query DB importers, expand. Uses a parseCache so each file is parsed once. Generic over TParseResult so closure logic is decoupled from the pipeline's parse representation. 32 unit tests across the three modules. Tests cover edge cases: clean tree, dirty-only, mixed, renames, deletes, multi-hop cascade, cycle termination, surface invariance, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d9e340b05d
commit
aa8d7ae3f7
6 changed files with 1022 additions and 0 deletions
115
gitnexus/src/core/incremental/closure.ts
Normal file
115
gitnexus/src/core/incremental/closure.ts
Normal file
|
|
@ -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<TParseResult> {
|
||||
/** Initial set of files (from git diff: modified ∪ added). */
|
||||
initialChangedFiles: Set<string>;
|
||||
/** Previously stored surface signatures (filePath → hash). */
|
||||
prevSurfaces: Record<string, string>;
|
||||
/**
|
||||
* 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<TParseResult>;
|
||||
/**
|
||||
* 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<string[]>;
|
||||
}
|
||||
|
||||
export interface ClosureResult<TParseResult> {
|
||||
/** Final set of files that must be re-parsed. */
|
||||
closure: Set<string>;
|
||||
/**
|
||||
* 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<string, TParseResult>;
|
||||
/** Newly computed surface signatures, keyed by file path. */
|
||||
newSurfaces: Map<string, string>;
|
||||
/**
|
||||
* Files added to closure ONLY because an importer chain pulled them in
|
||||
* (not in the initial changed set). Useful for logging.
|
||||
*/
|
||||
expandedFromImporters: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TParseResult>(
|
||||
input: ClosureInput<TParseResult>,
|
||||
): Promise<ClosureResult<TParseResult>> {
|
||||
const { initialChangedFiles, prevSurfaces, parseFile, surfaceFor, queryImporters } = input;
|
||||
|
||||
const closure = new Set<string>(initialChangedFiles);
|
||||
const queue: string[] = [...initialChangedFiles];
|
||||
const parseCache = new Map<string, TParseResult>();
|
||||
const newSurfaces = new Map<string, string>();
|
||||
const expandedFromImporters = new Set<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
212
gitnexus/src/core/incremental/git-diff.ts
Normal file
212
gitnexus/src/core/incremental/git-diff.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Git-based change detection for incremental indexing.
|
||||
*
|
||||
* Combines `git diff --name-status <lastCommit> HEAD` (committed changes since
|
||||
* last index) with `git status --porcelain` (uncommitted/dirty tree changes)
|
||||
* to produce a precise per-file change set.
|
||||
*
|
||||
* Renames (R<sim> 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<string>();
|
||||
const added = new Set<string>();
|
||||
const deleted = new Set<string>();
|
||||
|
||||
// ── 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<sim>, C<sim> (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): XY<space>path[\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<string>,
|
||||
added: Set<string>,
|
||||
deleted: Set<string>,
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
135
gitnexus/src/core/incremental/surface.ts
Normal file
135
gitnexus/src/core/incremental/surface.ts
Normal file
|
|
@ -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<string>([
|
||||
'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<string, unknown>;
|
||||
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('|');
|
||||
}
|
||||
212
gitnexus/test/unit/incremental-closure.test.ts
Normal file
212
gitnexus/test/unit/incremental-closure.test.ts
Normal file
|
|
@ -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<string, string[]>;
|
||||
/** previous surfaces */
|
||||
prevSurfaces: Record<string, string>;
|
||||
/** new surfaces (what parseFile + surfaceFor will produce) */
|
||||
newSurfaces: Record<string, string>;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
163
gitnexus/test/unit/incremental-git-diff.test.ts
Normal file
163
gitnexus/test/unit/incremental-git-diff.test.ts
Normal file
|
|
@ -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 <commit> (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<sim> 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
185
gitnexus/test/unit/incremental-surface.test.ts
Normal file
185
gitnexus/test/unit/incremental-surface.test.ts
Normal file
|
|
@ -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<string, unknown> = {},
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: 'Function',
|
||||
properties: { name, filePath, ...extra },
|
||||
};
|
||||
}
|
||||
|
||||
function method(
|
||||
id: string,
|
||||
filePath: string,
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: 'Method',
|
||||
properties: { name, filePath, ...extra },
|
||||
};
|
||||
}
|
||||
|
||||
function cls(
|
||||
id: string,
|
||||
filePath: string,
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue