diff --git a/gitnexus/src/core/graph/graph.ts b/gitnexus/src/core/graph/graph.ts index a3d1df713..2f49e9f4a 100644 --- a/gitnexus/src/core/graph/graph.ts +++ b/gitnexus/src/core/graph/graph.ts @@ -1,9 +1,21 @@ -import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; import { KnowledgeGraph } from './types.js'; +/** Fresh empty iterator per call — `[].values()` returns a new + * exhausted iterator each invocation, so empty-type lookups don't + * share a single already-exhausted iterator across callers. */ +function emptyRelIter(): IterableIterator { + return ([] as GraphRelationship[]).values(); +} + export const createKnowledgeGraph = (): KnowledgeGraph => { const nodeMap = new Map(); const relationshipMap = new Map(); + // Per-type index maintained alongside `relationshipMap`. Bucket + // values are `Map` so per-type iteration is cheap + // and per-edge removal is O(1). See plan + // docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 1). + const relationshipsByType = new Map>(); const addNode = (node: GraphNode) => { if (!nodeMap.has(node.id)) { @@ -12,9 +24,14 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { }; const addRelationship = (relationship: GraphRelationship) => { - if (!relationshipMap.has(relationship.id)) { - relationshipMap.set(relationship.id, relationship); + if (relationshipMap.has(relationship.id)) return; + relationshipMap.set(relationship.id, relationship); + let bucket = relationshipsByType.get(relationship.type); + if (bucket === undefined) { + bucket = new Map(); + relationshipsByType.set(relationship.type, bucket); } + bucket.set(relationship.id, relationship); }; /** @@ -25,10 +42,12 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { nodeMap.delete(nodeId); - // Remove all relationships involving this node + // Remove all relationships involving this node — clean up both + // indexes in lockstep so the per-type buckets never drift. for (const [relId, rel] of relationshipMap) { if (rel.sourceId === nodeId || rel.targetId === nodeId) { relationshipMap.delete(relId); + relationshipsByType.get(rel.type)?.delete(relId); } } return true; @@ -39,7 +58,11 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { * Returns true if the relationship existed and was removed, false otherwise. */ const removeRelationship = (relationshipId: string): boolean => { - return relationshipMap.delete(relationshipId); + const rel = relationshipMap.get(relationshipId); + if (rel === undefined) return false; + relationshipMap.delete(relationshipId); + relationshipsByType.get(rel.type)?.delete(relationshipId); + return true; }; /** @@ -67,6 +90,10 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { iterNodes: () => nodeMap.values(), iterRelationships: () => relationshipMap.values(), + iterRelationshipsByType: (type: RelationshipType) => { + const bucket = relationshipsByType.get(type); + return bucket === undefined ? emptyRelIter() : bucket.values(); + }, forEachNode(fn: (node: GraphNode) => void) { nodeMap.forEach(fn); }, diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index a3c97942c..539f77987 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -6,7 +6,7 @@ * * This file only defines the CLI's KnowledgeGraph with mutation methods. */ -import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; // CLI-specific: full KnowledgeGraph with mutation methods for incremental updates export interface KnowledgeGraph { @@ -14,6 +14,17 @@ export interface KnowledgeGraph { relationships: GraphRelationship[]; iterNodes: () => IterableIterator; iterRelationships: () => IterableIterator; + /** + * Iterate ONLY relationships of the given type, backed by a per-type + * index maintained in `addRelationship` / `removeRelationship` / + * `removeNode` / `removeNodesByFile`. Returns an empty iterator when + * the graph contains no relationships of that type. + * + * Prefer this over `iterRelationships()` + per-edge type filtering + * for hot paths (MRO setup, heritage walks). Backwards-compatible: + * existing `iterRelationships()` callers keep working. + */ + iterRelationshipsByType: (type: RelationshipType) => IterableIterator; forEachNode: (fn: (node: GraphNode) => void) => void; forEachRelationship: (fn: (rel: GraphRelationship) => void) => void; getNode: (id: string) => GraphNode | undefined; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 351b339a7..f449aa46b 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -329,7 +329,21 @@ interface LanguageProviderConfig { * * Default: undefined (language continues to use legacy DAG). */ - readonly emitScopeCaptures?: (sourceText: string, filePath: string) => readonly CaptureMatch[]; + readonly emitScopeCaptures?: ( + sourceText: string, + filePath: string, + /** + * Optional pre-parsed tree-sitter Tree the caller has already + * produced (e.g. from the parse phase's AST cache). When supplied, + * the provider SHOULD skip its own `parser.parse(sourceText)` and + * run its capture query against the supplied tree directly. Typed + * as `unknown` here to avoid leaking the tree-sitter dependency + * into the provider contract — the provider casts at use site. + * Cache miss (parameter omitted or undefined) is always safe and + * MUST trigger a fresh parse. + */ + cachedTree?: unknown, + ) => readonly CaptureMatch[]; /** * Interpret a raw `@import.statement` capture group into a `ParsedImport`. diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 814516bfc..f07befed3 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -23,11 +23,36 @@ import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; import { computePythonArityMetadata } from './arity-metadata.js'; +// Dev-mode counters for the parse-cache hit-rate. Gated by +// `PROF_SCOPE_RESOLUTION=1` to keep the hot path branch-free in +// production. Surfaced via `getPythonCaptureCacheStats()` so +// benchmarks / debug scripts can verify the cache is being used. +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; +let CACHE_HITS = 0; +let CACHE_MISSES = 0; +export function getPythonCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} +export function resetPythonCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} + export function emitPythonScopeCaptures( sourceText: string, _filePath: string, + cachedTree?: unknown, ): readonly CaptureMatch[] { - const tree = getPythonParser().parse(sourceText); + // Skip the parse when the caller (parse phase's ASTCache) already + // produced a Tree for this source. Cache miss = re-parse, same as + // before. The cachedTree parameter is typed as `unknown` at the + // contract layer (see `LanguageProvider.emitScopeCaptures`); cast + // here at the use site. + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = getPythonParser().parse(sourceText); + if (PROF) CACHE_MISSES++; + } else if (PROF) CACHE_HITS++; const rawMatches = getPythonScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index c48cf99f3..e41b8da6c 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -64,32 +64,48 @@ function buildAdjacency(graph: KnowledgeGraph) { // Track which edge type each parent link came from const parentEdgeType = new Map>(); - graph.forEachRelationship((rel) => { - if (rel.type === 'EXTENDS' || rel.type === 'IMPLEMENTS') { - let parents = parentMap.get(rel.sourceId); - if (!parents) { - parents = []; - parentMap.set(rel.sourceId, parents); - } - parents.push(rel.targetId); - - let edgeTypes = parentEdgeType.get(rel.sourceId); - if (!edgeTypes) { - edgeTypes = new Map(); - parentEdgeType.set(rel.sourceId, edgeTypes); - } - edgeTypes.set(rel.targetId, rel.type); + // Three typed iterations replace one full-relationship-map scan + // with per-edge type checks. Each consumes only the edges of the + // type it cares about — see plan + // docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 2). + for (const rel of graph.iterRelationshipsByType('EXTENDS')) { + let parents = parentMap.get(rel.sourceId); + if (!parents) { + parents = []; + parentMap.set(rel.sourceId, parents); } + parents.push(rel.targetId); - if (rel.type === 'HAS_METHOD') { - let methods = methodMap.get(rel.sourceId); - if (!methods) { - methods = []; - methodMap.set(rel.sourceId, methods); - } - methods.push(rel.targetId); + let edgeTypes = parentEdgeType.get(rel.sourceId); + if (!edgeTypes) { + edgeTypes = new Map(); + parentEdgeType.set(rel.sourceId, edgeTypes); } - }); + edgeTypes.set(rel.targetId, 'EXTENDS'); + } + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + let parents = parentMap.get(rel.sourceId); + if (!parents) { + parents = []; + parentMap.set(rel.sourceId, parents); + } + parents.push(rel.targetId); + + let edgeTypes = parentEdgeType.get(rel.sourceId); + if (!edgeTypes) { + edgeTypes = new Map(); + parentEdgeType.set(rel.sourceId, edgeTypes); + } + edgeTypes.set(rel.targetId, 'IMPLEMENTS'); + } + for (const rel of graph.iterRelationshipsByType('HAS_METHOD')) { + let methods = methodMap.get(rel.sourceId); + if (!methods) { + methods = []; + methodMap.set(rel.sourceId, methods); + } + methods.push(rel.targetId); + } return { parentMap, methodMap, parentEdgeType }; } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index b7b143039..a7bcae406 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -318,6 +318,7 @@ const processParsingSequential = async ( files: { path: string; content: string }[], symbolTable: SymbolTableWriter, astCache: ASTCache, + scopeTreeCache: ASTCache | undefined, onFileProgress?: FileProgressCallback, ) => { const parser = await loadParser(); @@ -380,6 +381,9 @@ const processParsingSequential = async ( } astCache.set(file.path, tree); + // Mirror into the cross-phase cache when supplied. parse-impl + // clears `astCache` between chunks; `scopeTreeCache` survives. + scopeTreeCache?.set(file.path, tree); const provider = getProvider(language); const queryString = provider.treeSitterQueries; @@ -699,6 +703,14 @@ export const processParsing = async ( files: { path: string; content: string }[], symbolTable: SymbolTableWriter, astCache: ASTCache, + /** + * Persistent tree cache (separate from `astCache`, which the caller + * clears between chunks). Sequential parses additionally write the + * Tree here so cross-phase consumers (scope-resolution) can read it. + * Worker-mode parses skip — Trees can't cross MessageChannels. + * Pass `undefined` if no consumer needs cross-phase access. + */ + scopeTreeCache: ASTCache | undefined, onFileProgress?: FileProgressCallback, workerPool?: WorkerPool, ): Promise => { @@ -721,6 +733,13 @@ export const processParsing = async ( } // Fallback: sequential parsing (no pre-extracted data) - await processParsingSequential(graph, files, symbolTable, astCache, onFileProgress); + await processParsingSequential( + graph, + files, + symbolTable, + astCache, + scopeTreeCache, + onFileProgress, + ); return null; }; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index f8e0d3b53..dd5cc3117 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -41,7 +41,7 @@ import { getHeritageStrategyForLanguage, } from '../heritage-processor.js'; import { createResolutionContext } from '../model/resolution-context.js'; -import { createASTCache } from '../ast-cache.js'; +import { ASTCache, createASTCache } from '../ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; import { readFileContents } from '../filesystem-walker.js'; import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; @@ -109,6 +109,13 @@ export async function runChunkedParseAndResolve( bindingAccumulator: BindingAccumulator; resolutionContext: ReturnType; usedWorkerPool: boolean; + /** AST cache populated by the sequential parse path. Empty when + * every chunk ran via the worker pool (workers can't return native + * tree-sitter Trees across the MessageChannel). Downstream phases + * (e.g. scope-resolution) read from this to skip re-parsing the + * same source. See plan + * docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). */ + astCache: ASTCache; }> { const ctx = createResolutionContext(); const symbolTable = ctx.model.symbols; @@ -220,9 +227,18 @@ export async function runChunkedParseAndResolve( let filesParsedSoFar = 0; - // AST cache sized for one chunk (sequential fallback uses it for import/call/heritage) + // Two caches with different lifetimes: + // - `astCache` (chunk-local, cleared between chunks) — call / + // heritage / import processors read it during parse to avoid + // re-parsing within the same chunk. + // - `scopeTreeCache` (total-parseable-sized, never cleared by + // parse-impl) — exposed via ParseOutput so scope-resolution can + // skip a second tree-sitter parse. Worker-mode parses don't + // populate either; consumers fall back to a fresh parse. + // See plan docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0); let astCache = createASTCache(maxChunkFiles); + const scopeTreeCache = createASTCache(Math.max(parseableScanned.length, 1)); // Build import resolution context once — suffix index, file lists, resolve cache. const importCtx = buildImportResolutionContext(allPaths); @@ -267,6 +283,7 @@ export async function runChunkedParseAndResolve( chunkFiles, symbolTable, astCache, + scopeTreeCache, (current, _total, filePath) => { const globalCurrent = filesParsedSoFar + current; const parsingProgress = 20 + (globalCurrent / totalParseable) * 62; @@ -595,5 +612,11 @@ export async function runChunkedParseAndResolve( // sequential fallback handled every chunk (either due to `skipWorkers`, // the file-count/byte thresholds, or a pool-creation failure). usedWorkerPool: workerPool !== undefined, + // Surface the persistent scope cache so downstream phases + // (scope-resolution) can skip re-parsing files that the + // sequential path already parsed. Survives chunk boundaries; the + // chunk-local `astCache` above is intentionally NOT exposed + // because parse-impl clears it between chunks. + astCache: scopeTreeCache, }; } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index 6415cb6e5..0f5314efa 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -29,6 +29,7 @@ import type { } from '../workers/parse-worker.js'; import type { createResolutionContext } from '../model/resolution-context.js'; import { runChunkedParseAndResolve } from './parse-impl.js'; +import type { ASTCache } from '../ast-cache.js'; export interface ParseOutput { /** @@ -63,6 +64,15 @@ export interface ParseOutput { * see `PipelineOptions.workerThresholdsForTest`. */ readonly usedWorkerPool: boolean; + /** + * AST cache populated by the sequential parse path. Empty entries + * for files that ran through the worker pool (workers can't return + * native tree-sitter Trees across the MessageChannel). Downstream + * phases (scope-resolution) read from this to skip re-parsing — + * cache miss is safe and falls back to a fresh parse. See plan + * docs/plans/2026-04-20-002-perf-parse-heritage-mro-plan.md (Unit 4). + */ + readonly astCache: ASTCache; } export const parsePhase: PipelinePhase = { diff --git a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts index dfa3b9b4f..19e16e940 100644 --- a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts +++ b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts @@ -38,10 +38,11 @@ export function extractParsedFile( sourceText: string, filePath: string, onWarn?: ScopeBridgeWarn, + cachedTree?: unknown, ): ParsedFile | undefined { if (provider.emitScopeCaptures === undefined) return undefined; try { - const captures = provider.emitScopeCaptures(sourceText, filePath); + const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree); return extractScope(captures, filePath, provider); } catch (err) { const message = `scope extraction failed for ${filePath}: ${ diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts index fac7ad5cc..b9aa39bf9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts @@ -41,10 +41,11 @@ export function buildMro( nodeLookup: GraphNodeLookup, linearize: LinearizeStrategy, ): Map { - // Step 1: parentsByGraphId. + // Step 1: parentsByGraphId — typed iterator skips the per-edge type + // check and the millions of CALLS/ACCESSES/IMPORTS/DEFINES edges + // that aren't relevant to MRO. const parentsByGraphId = new Map(); - for (const rel of graph.iterRelationships()) { - if (rel.type !== 'EXTENDS') continue; + for (const rel of graph.iterRelationshipsByType('EXTENDS')) { let list = parentsByGraphId.get(rel.sourceId); if (list === undefined) { list = []; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index ee39887a3..ded7c83bd 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -78,6 +78,14 @@ export const scopeResolutionPhase: PipelinePhase = { deps: ReadonlyMap>, ): Promise { const { scannedFiles } = getPhaseOutput(deps, 'structure'); + // Reach into the parse phase's AST cache so per-file extract can + // skip a second tree-sitter parse. Cache miss is safe (re-parses). + // Worker-mode parses leave the cache empty for those files; they + // also fall back to a fresh parse — no correctness impact. + const { astCache } = getPhaseOutput<{ astCache: { get(path: string): unknown } }>( + deps, + 'parse', + ); let totalFiles = 0; let totalImports = 0; @@ -110,6 +118,7 @@ export const scopeResolutionPhase: PipelinePhase = { { graph: ctx.graph, files, + treeCache: astCache, onWarn: (msg) => { if (isDev) console.warn(`[scope-resolution:${lang}] ${msg}`); }, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index d294c7144..b8bbf2355 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -42,6 +42,14 @@ export interface RunScopeResolutionInput { readonly graph: KnowledgeGraph; readonly files: readonly { readonly path: string; readonly content: string }[]; readonly onWarn?: (message: string) => void; + /** + * Optional pre-parsed-Tree lookup keyed by file path. When the + * pipeline's parse phase ran sequentially, it populated an + * `ASTCache`; passing that here lets the per-file extract step + * skip a second `tree-sitter parser.parse(...)` call. Cache miss + * is safe — falls back to a fresh parse inside the provider. + */ + readonly treeCache?: { get(filePath: string): unknown }; } export interface RunScopeResolutionStats { @@ -65,8 +73,16 @@ export function runScopeResolution( // ── Phase 1: extract each file → ParsedFile ──────────────────────────── const parsedFiles: ParsedFile[] = []; let filesSkipped = 0; + const treeCache = input.treeCache; for (const file of files) { - const parsed = extractParsedFile(provider.languageProvider, file.content, file.path, onWarn); + const cachedTree = treeCache?.get(file.path); + const parsed = extractParsedFile( + provider.languageProvider, + file.content, + file.path, + onWarn, + cachedTree, + ); if (parsed === undefined) { filesSkipped++; continue; diff --git a/gitnexus/test/unit/graph.test.ts b/gitnexus/test/unit/graph.test.ts index 41bfc8a73..22b06a9be 100644 --- a/gitnexus/test/unit/graph.test.ts +++ b/gitnexus/test/unit/graph.test.ts @@ -247,4 +247,63 @@ describe('createKnowledgeGraph', () => { expect(remaining[0].sourceId).toBe('fn:b'); expect(remaining[0].targetId).toBe('fn:c'); }); + + // ─── iterRelationshipsByType ─────────────────────────────────────── + + describe('iterRelationshipsByType', () => { + it('yields only the requested type', () => { + const g = createKnowledgeGraph(); + g.addRelationship(makeRel('fn:a', 'fn:b', 'CALLS')); + g.addRelationship(makeRel('fn:b', 'fn:c', 'CALLS')); + g.addRelationship(makeRel('cls:X', 'cls:Y', 'EXTENDS')); + g.addRelationship(makeRel('cls:Y', 'cls:Z', 'EXTENDS')); + + expect([...g.iterRelationshipsByType('CALLS')]).toHaveLength(2); + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(2); + }); + + it('returns a fresh empty iterator when the type has no edges', () => { + const g = createKnowledgeGraph(); + g.addRelationship(makeRel('fn:a', 'fn:b', 'CALLS')); + // Two consecutive calls must each be exhaustible — guards against + // returning a single shared exhausted iterator. + expect([...g.iterRelationshipsByType('IMPLEMENTS')]).toHaveLength(0); + expect([...g.iterRelationshipsByType('IMPLEMENTS')]).toHaveLength(0); + }); + + it('reflects removeRelationship on both indexes', () => { + const g = createKnowledgeGraph(); + g.addRelationship(makeRel('cls:X', 'cls:Y', 'EXTENDS')); + g.addRelationship(makeRel('cls:Y', 'cls:Z', 'EXTENDS')); + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(2); + + g.removeRelationship('cls:X-EXTENDS-cls:Y'); + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(1); + expect(g.relationshipCount).toBe(1); + }); + + it('reflects removeNode on both indexes', () => { + const g = createKnowledgeGraph(); + g.addNode(makeNode('cls:X', 'X', 'src/x.ts')); + g.addNode(makeNode('cls:Y', 'Y', 'src/y.ts')); + g.addRelationship(makeRel('cls:X', 'cls:Y', 'EXTENDS')); + g.addRelationship(makeRel('cls:X', 'cls:Y', 'IMPLEMENTS')); + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(1); + expect([...g.iterRelationshipsByType('IMPLEMENTS')]).toHaveLength(1); + + g.removeNode('cls:Y'); + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(0); + expect([...g.iterRelationshipsByType('IMPLEMENTS')]).toHaveLength(0); + expect([...g.iterRelationships()]).toHaveLength(0); + }); + + it('dedupes by id across both indexes', () => { + const g = createKnowledgeGraph(); + const rel = makeRel('cls:X', 'cls:Y', 'EXTENDS'); + g.addRelationship(rel); + g.addRelationship(rel); // dedup by id + expect([...g.iterRelationshipsByType('EXTENDS')]).toHaveLength(1); + expect(g.relationshipCount).toBe(1); + }); + }); });