diff --git a/src/domain/graph/cycles.ts b/src/domain/graph/cycles.ts index 9517133d..02e72566 100644 --- a/src/domain/graph/cycles.ts +++ b/src/domain/graph/cycles.ts @@ -4,6 +4,25 @@ import { CodeGraph } from '../../graph/model.js'; import { loadNative } from '../../infrastructure/native.js'; import type { BetterSqlite3Database } from '../../types.js'; +/** + * Engine parity note — function-level cycle counts + * + * The native (Rust) and WASM engines may report different function-level cycle + * counts even on the same codebase. This is expected behavior, not a bug in + * the cycle detection algorithm (Tarjan SCC is identical in both engines). + * + * Root cause: the native engine extracts slightly more symbols and resolves + * more call edges than WASM (e.g. 10883 nodes / 4000 calls native vs 10857 + * nodes / 3986 calls WASM on the codegraph repo). The additional precision + * can both create new edges and — more commonly — resolve previously ambiguous + * calls to their correct targets, which breaks false cycles that WASM reports. + * + * For file-level cycles the engines are in parity because import edges are + * resolved identically. The gap only manifests at function-level granularity + * where call-site extraction differs between the Rust and WASM parsers. + * + * See: https://github.com/optave/codegraph/issues/597 + */ export function findCycles( db: BetterSqlite3Database, opts: { fileLevel?: boolean; noTests?: boolean } = {}, diff --git a/tests/graph/cycles.test.ts b/tests/graph/cycles.test.ts index 18014f8c..6b22cf2b 100644 --- a/tests/graph/cycles.test.ts +++ b/tests/graph/cycles.test.ts @@ -148,6 +148,21 @@ describe('formatCycles', () => { }); }); +// ── Engine parity: extraction-level differences ──────────────────── +// +// The native (Rust) and WASM engines produce slightly different function-level +// graphs because the native extractor resolves more symbols and call edges. +// This means function-level cycle *counts* can legitimately differ between +// engines (e.g. 8 native vs 11 WASM on the codegraph repo itself). +// +// The Tarjan SCC algorithm is identical — given the SAME edge set, both +// engines produce the same cycles. The tests below verify that invariant. +// +// File-level cycles are unaffected because import resolution is engine- +// independent. +// +// See: https://github.com/optave/codegraph/issues/597 + // ── Native vs JS parity ──────────────────────────────────────────── describe.skipIf(!hasNative)('Cycle detection: native vs JS parity', () => { @@ -207,3 +222,49 @@ describe.skipIf(!hasNative)('Cycle detection: native vs JS parity', () => { expect(sortCycles(nativeResult)).toEqual(sortCycles(jsResult)); }); }); + +// ── Extraction-level parity gap (issue #597) ─────────────────────── + +describe('Cycle count sensitivity to edge differences', () => { + it('resolving an ambiguous call edge to its correct target can break a false cycle', () => { + // Demonstrates why native (more precise edge targets) can report FEWER cycles than WASM. + // With ambiguous resolution, a -> b -> c -> a forms a 3-node cycle. + // Resolving the ambiguous c -> a edge to its correct target c -> d + // breaks the cycle. + const ambiguousEdges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + ]; + const ambiguousCycles = findCyclesJS(ambiguousEdges); + expect(ambiguousCycles).toHaveLength(1); + + // After resolving: c -> a becomes c -> d (a different target). + // The cycle is broken. + const resolvedEdges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'd' }, + ]; + const resolvedCycles = findCyclesJS(resolvedEdges); + expect(resolvedCycles).toHaveLength(0); + }); + + it('JS cycle detection is deterministic on repeated calls', () => { + // The Tarjan SCC algorithm is deterministic: given the same edge set, + // repeated calls always produce the same result. Any cycle count + // difference between engines comes from the graph they are fed, not + // from the algorithm. + const edges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + { source: 'd', target: 'e' }, + { source: 'e', target: 'd' }, + ]; + const result1 = findCyclesJS(edges); + const result2 = findCyclesJS(edges); + expect(result1).toEqual(result2); + expect(result1).toHaveLength(2); + }); +});