mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
perf(ingestion): prune inert local value symbols
This commit is contained in:
parent
95f87fc12a
commit
36db1b3d2c
6 changed files with 297 additions and 3 deletions
83
gitnexus/src/core/ingestion/local-symbol-pruner.ts
Normal file
83
gitnexus/src/core/ingestion/local-symbol-pruner.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
175
gitnexus/test/unit/local-symbol-pruner.test.ts
Normal file
175
gitnexus/test/unit/local-symbol-pruner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue