From 36db1b3d2ce7b3e65890adf819f15161a87a5bb9 Mon Sep 17 00:00:00 2001 From: abhigyanpatwari Date: Sun, 7 Jun 2026 04:18:10 +0530 Subject: [PATCH] perf(ingestion): prune inert local value symbols --- .../src/core/ingestion/local-symbol-pruner.ts | 83 +++++++++ .../core/ingestion/pipeline-phases/index.ts | 1 + .../src/core/ingestion/pipeline-phases/mro.ts | 4 +- .../pipeline-phases/prune-local-symbols.ts | 32 ++++ gitnexus/src/core/ingestion/pipeline.ts | 5 +- .../test/unit/local-symbol-pruner.test.ts | 175 ++++++++++++++++++ 6 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 gitnexus/src/core/ingestion/local-symbol-pruner.ts create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts create mode 100644 gitnexus/test/unit/local-symbol-pruner.test.ts diff --git a/gitnexus/src/core/ingestion/local-symbol-pruner.ts b/gitnexus/src/core/ingestion/local-symbol-pruner.ts new file mode 100644 index 000000000..69378bdf7 --- /dev/null +++ b/gitnexus/src/core/ingestion/local-symbol-pruner.ts @@ -0,0 +1,83 @@ +import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import { parseTruthyEnv } from './utils/env.js'; + +const LOCAL_VALUE_LABELS = new Set(['Const', 'Variable', 'Static']); +const KEEP_LOCAL_VALUE_SYMBOLS_ENV = 'GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS'; + +export interface LocalSymbolPruneStats { + candidateNodes: number; + prunedNodes: number; + keptWithSemanticEdges: number; + skippedByEnv: boolean; +} + +export const shouldKeepLocalValueSymbols = (): boolean => + parseTruthyEnv(process.env[KEEP_LOCAL_VALUE_SYMBOLS_ENV]); + +const emptyStats = (skippedByEnv: boolean): LocalSymbolPruneStats => ({ + candidateNodes: 0, + prunedNodes: 0, + keptWithSemanticEdges: 0, + skippedByEnv, +}); + +const isLocalValueCandidate = (node: GraphNode): boolean => { + if (!LOCAL_VALUE_LABELS.has(node.label)) return false; + if (node.properties.scope !== 'block') return false; + return node.properties.isExported !== true; +}; + +const isFileDefinesEdgeToCandidate = ( + graph: KnowledgeGraph, + rel: GraphRelationship, + candidateId: string, +): boolean => { + if (rel.type !== 'DEFINES') return false; + if (rel.targetId !== candidateId) return false; + return graph.getNode(rel.sourceId)?.label === 'File'; +}; + +export const pruneLocalValueSymbols = ( + graph: KnowledgeGraph, + options: { keepLocalValueSymbols?: boolean } = {}, +): LocalSymbolPruneStats => { + if (options.keepLocalValueSymbols ?? shouldKeepLocalValueSymbols()) { + return emptyStats(true); + } + + const candidateIds = new Set(); + for (const node of graph.iterNodes()) { + if (isLocalValueCandidate(node)) candidateIds.add(node.id); + } + + if (candidateIds.size === 0) return emptyStats(false); + + const candidatesWithSemanticEdges = new Set(); + for (const rel of graph.iterRelationships()) { + if (candidateIds.has(rel.sourceId)) { + if (!isFileDefinesEdgeToCandidate(graph, rel, rel.sourceId)) { + candidatesWithSemanticEdges.add(rel.sourceId); + } + } + + if (candidateIds.has(rel.targetId)) { + if (!isFileDefinesEdgeToCandidate(graph, rel, rel.targetId)) { + candidatesWithSemanticEdges.add(rel.targetId); + } + } + } + + let prunedNodes = 0; + for (const candidateId of candidateIds) { + if (candidatesWithSemanticEdges.has(candidateId)) continue; + if (graph.removeNode(candidateId)) prunedNodes++; + } + + return { + candidateNodes: candidateIds.size, + prunedNodes, + keptWithSemanticEdges: candidatesWithSemanticEdges.size, + skippedByEnv: false, + }; +}; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index b1dcf9082..60f41d87d 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -20,6 +20,7 @@ export { scopeResolutionPhase, type ScopeResolutionOutput, } from '../scope-resolution/pipeline/phase.js'; +export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js'; export { mroPhase, type MROOutput } from './mro.js'; export { communitiesPhase, type CommunitiesOutput } from './communities.js'; export { processesPhase, type ProcessesOutput } from './processes.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts index cdc02bf29..f5c942551 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts @@ -4,7 +4,7 @@ * Computes Method Resolution Order (MRO) and creates METHOD_OVERRIDES * and METHOD_IMPLEMENTS edges. * - * @deps crossFile, scopeResolution + * @deps crossFile, scopeResolution, pruneLocalSymbols * @reads graph (all nodes and relationships) * @writes graph (METHOD_OVERRIDES, METHOD_IMPLEMENTS edges) */ @@ -25,7 +25,7 @@ export interface MROOutput { export const mroPhase: PipelinePhase = { name: 'mro', - deps: ['crossFile', 'scopeResolution', 'structure'], + deps: ['crossFile', 'scopeResolution', 'pruneLocalSymbols', 'structure'], async execute( ctx: PipelineContext, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts b/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts new file mode 100644 index 000000000..6f07a6f4f --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/prune-local-symbols.ts @@ -0,0 +1,32 @@ +/** + * Phase: pruneLocalSymbols + * + * Drops inert function/block-local value symbols after scope resolution has + * already used them for binding and call resolution. + * + * @deps scopeResolution + * @reads graph (nodes and relationships) + * @writes graph (removes unreferenced local Const/Variable/Static nodes) + */ + +import type { PipelinePhase, PipelineContext } from './types.js'; +import { pruneLocalValueSymbols, type LocalSymbolPruneStats } from '../local-symbol-pruner.js'; +import { isDev } from '../utils/env.js'; +import { logger } from '../../logger.js'; + +export type PruneLocalSymbolsOutput = LocalSymbolPruneStats; + +export const pruneLocalSymbolsPhase: PipelinePhase = { + name: 'pruneLocalSymbols', + deps: ['scopeResolution'], + + async execute(ctx: PipelineContext): Promise { + const stats = pruneLocalValueSymbols(ctx.graph); + + if (isDev && !stats.skippedByEnv && stats.prunedNodes > 0) { + logger.info(`Pruned ${stats.prunedNodes}/${stats.candidateNodes} inert local value symbols`); + } + + return stats; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 6399b8694..3c18c0b13 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -31,6 +31,7 @@ import { ormPhase, crossFilePhase, scopeResolutionPhase, + pruneLocalSymbolsPhase, mroPhase, communitiesPhase, processesPhase, @@ -124,7 +125,8 @@ export interface PipelineOptions { * Phase dependency graph: * * scan → structure → [markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → mro → communities → processes + * → crossFile → scopeResolution → pruneLocalSymbols + * → mro → communities → processes * * To add a new phase: create a file in pipeline-phases/, export the phase * object, and add it to the appropriate position in this array. @@ -141,6 +143,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { ormPhase, crossFilePhase, scopeResolutionPhase, + pruneLocalSymbolsPhase, ]; if (!options?.skipGraphPhases) { diff --git a/gitnexus/test/unit/local-symbol-pruner.test.ts b/gitnexus/test/unit/local-symbol-pruner.test.ts new file mode 100644 index 000000000..dbe6b34df --- /dev/null +++ b/gitnexus/test/unit/local-symbol-pruner.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { GraphNode, GraphRelationship, NodeLabel, RelationshipType } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + pruneLocalValueSymbols, + shouldKeepLocalValueSymbols, +} from '../../src/core/ingestion/local-symbol-pruner.js'; +import { pruneLocalSymbolsPhase } from '../../src/core/ingestion/pipeline-phases/prune-local-symbols.js'; + +const fileNode = (): GraphNode => ({ + id: 'file:src/app.ts', + label: 'File', + properties: { + name: 'app.ts', + filePath: 'src/app.ts', + }, +}); + +const node = ( + id: string, + label: NodeLabel, + properties: Partial = {}, +): GraphNode => ({ + id, + label, + properties: { + name: id, + filePath: 'src/app.ts', + startLine: 1, + endLine: 1, + ...properties, + }, +}); + +const rel = ( + id: string, + sourceId: string, + targetId: string, + type: RelationshipType = 'DEFINES', +): GraphRelationship => ({ + id, + sourceId, + targetId, + type, + confidence: 1, + reason: 'test', +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('pruneLocalValueSymbols', () => { + it.each(['Const', 'Variable', 'Static'] as const)( + 'prunes inert block-scope %s nodes after scope resolution', + (label) => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node(`${label}:tmp`, label, { scope: 'block' })); + graph.addRelationship(rel(`rel:${label}`, 'file:src/app.ts', `${label}:tmp`)); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats).toEqual({ + candidateNodes: 1, + prunedNodes: 1, + keptWithSemanticEdges: 0, + skippedByEnv: false, + }); + expect(graph.getNode(`${label}:tmp`)).toBeUndefined(); + expect(graph.relationshipCount).toBe(0); + }, + ); + + it.each(['module', 'file'] as const)('keeps %s-scope value symbols', (scope) => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node(`Const:${scope}`, 'Const', { scope })); + graph.addRelationship(rel(`rel:${scope}`, 'file:src/app.ts', `Const:${scope}`)); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats.prunedNodes).toBe(0); + expect(stats.candidateNodes).toBe(0); + expect(graph.getNode(`Const:${scope}`)).toBeDefined(); + expect(graph.relationshipCount).toBe(1); + }); + + it('keeps block-scope value symbols with semantic edges', () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Function:run', 'Function')); + graph.addNode(node('Const:client', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:def', 'file:src/app.ts', 'Const:client')); + graph.addRelationship(rel('rel:access', 'Function:run', 'Const:client', 'ACCESSES')); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats).toMatchObject({ + candidateNodes: 1, + prunedNodes: 0, + keptWithSemanticEdges: 1, + skippedByEnv: false, + }); + expect(graph.getNode('Const:client')).toBeDefined(); + expect(graph.relationshipCount).toBe(2); + }); + + it('keeps block-scope value symbols defined by explicit scope graph nodes', () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Scope:function:run', 'CodeElement')); + graph.addNode(node('Const:client', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:file-def', 'file:src/app.ts', 'Const:client')); + graph.addRelationship(rel('rel:scope-def', 'Scope:function:run', 'Const:client')); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats.prunedNodes).toBe(0); + expect(stats.keptWithSemanticEdges).toBe(1); + expect(graph.getNode('Const:client')).toBeDefined(); + }); + + it('does not prune function-like local symbols', () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Function:inner', 'Function', { scope: 'block' })); + graph.addRelationship(rel('rel:function', 'file:src/app.ts', 'Function:inner')); + + const stats = pruneLocalValueSymbols(graph); + + expect(stats.candidateNodes).toBe(0); + expect(graph.getNode('Function:inner')).toBeDefined(); + }); + + it('can be disabled with GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS', () => { + vi.stubEnv('GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS', '1'); + + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Const:tmp', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:def', 'file:src/app.ts', 'Const:tmp')); + + const stats = pruneLocalValueSymbols(graph); + + expect(shouldKeepLocalValueSymbols()).toBe(true); + expect(stats.skippedByEnv).toBe(true); + expect(stats.prunedNodes).toBe(0); + expect(graph.getNode('Const:tmp')).toBeDefined(); + }); +}); + +describe('pruneLocalSymbolsPhase', () => { + it('runs after scope resolution and returns prune stats', async () => { + const graph = createKnowledgeGraph(); + graph.addNode(fileNode()); + graph.addNode(node('Const:tmp', 'Const', { scope: 'block' })); + graph.addRelationship(rel('rel:def', 'file:src/app.ts', 'Const:tmp')); + + const stats = await pruneLocalSymbolsPhase.execute( + { + repoPath: '/repo', + graph, + onProgress: () => {}, + pipelineStart: Date.now(), + }, + new Map(), + ); + + expect(pruneLocalSymbolsPhase.name).toBe('pruneLocalSymbols'); + expect(pruneLocalSymbolsPhase.deps).toEqual(['scopeResolution']); + expect(stats.prunedNodes).toBe(1); + expect(graph.getNode('Const:tmp')).toBeUndefined(); + }); +});