perf(ingestion): prune inert local value symbols

This commit is contained in:
abhigyanpatwari 2026-06-07 04:18:10 +05:30
parent 95f87fc12a
commit 36db1b3d2c
6 changed files with 297 additions and 3 deletions

View file

@ -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<NodeLabel>(['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<string>();
for (const node of graph.iterNodes()) {
if (isLocalValueCandidate(node)) candidateIds.add(node.id);
}
if (candidateIds.size === 0) return emptyStats(false);
const candidatesWithSemanticEdges = new Set<string>();
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,
};
};

View file

@ -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';

View file

@ -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<MROOutput> = {
name: 'mro',
deps: ['crossFile', 'scopeResolution', 'structure'],
deps: ['crossFile', 'scopeResolution', 'pruneLocalSymbols', 'structure'],
async execute(
ctx: PipelineContext,

View file

@ -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<PruneLocalSymbolsOutput> = {
name: 'pruneLocalSymbols',
deps: ['scopeResolution'],
async execute(ctx: PipelineContext): Promise<PruneLocalSymbolsOutput> {
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;
},
};

View file

@ -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) {

View file

@ -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['properties']> = {},
): 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();
});
});