knowledge-graph-preview / cli /analyzer /graph-builder.test.js
mr4's picture
Upload 136 files
fd8cdf5 verified
import { describe, it, expect } from 'vitest';
import { buildFileNodes, buildFunctionNodes, buildClassNodes, buildGraph, buildImportEdges, buildCallEdges } from './graph-builder';
function makeFile(overrides = {}) {
return {
path: '/project/src/utils/helper.ts',
relativePath: 'src/utils/helper.ts',
category: 'code',
language: 'typescript',
lineCount: 30,
...overrides,
};
}
function makeParseResult(overrides = {}) {
return {
functions: [],
classes: [],
imports: [],
exports: [],
...overrides,
};
}
describe('buildFileNodes', () => {
describe('node creation (id, type, name)', () => {
it('creates a node with id set to relativePath', () => {
const files = [makeFile({ relativePath: 'src/index.ts' })];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe('src/index.ts');
});
it('sets type to "file"', () => {
const files = [makeFile()];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].type).toBe('file');
});
it('sets name to the file basename', () => {
const files = [makeFile({ relativePath: 'src/utils/helper.ts' })];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].name).toBe('helper.ts');
});
it('creates a node for each file', () => {
const files = [
makeFile({ relativePath: 'src/a.ts' }),
makeFile({ relativePath: 'src/b.ts' }),
makeFile({ relativePath: 'lib/c.ts' }),
];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes).toHaveLength(3);
expect(nodes.map(n => n.id)).toEqual(['src/a.ts', 'src/b.ts', 'lib/c.ts']);
});
});
describe('summary generation', () => {
it('generates summary from exports (max 5)', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({ exports: ['foo', 'bar', 'baz'] })],
]);
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('Exports: foo, bar, baz');
});
it('truncates exports beyond 5 with "..."', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const exports = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
const parseResults = new Map([
['src/utils.ts', makeParseResult({ exports })],
]);
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('Exports: a, b, c, d, e, ...');
});
it('generates summary from classes when no exports', () => {
const files = [makeFile({ relativePath: 'src/model.ts' })];
const parseResults = new Map([
['src/model.ts', makeParseResult({
classes: [
{ name: 'UserModel', startLine: 1, endLine: 20, methods: [], isExported: false },
{ name: 'PostModel', startLine: 22, endLine: 40, methods: [], isExported: false },
],
})],
]);
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('Classes: UserModel, PostModel');
});
it('generates summary from functions when no exports or classes', () => {
const files = [makeFile({ relativePath: 'src/helpers.ts' })];
const parseResults = new Map([
['src/helpers.ts', makeParseResult({
functions: [
{ name: 'add', startLine: 1, endLine: 3, params: [], isExported: false },
{ name: 'subtract', startLine: 5, endLine: 7, params: [], isExported: false },
],
})],
]);
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('Functions: add, subtract');
});
it('falls back to line count and language when parse result is empty', () => {
const files = [makeFile({ relativePath: 'src/empty.ts', lineCount: 10, language: 'typescript' })];
const parseResults = new Map([
['src/empty.ts', makeParseResult()],
]);
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('10 lines of typescript');
});
it('falls back to line count and language when no parse result exists', () => {
const files = [makeFile({ relativePath: 'src/data.json', lineCount: 50, language: 'json' })];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes[0].summary).toBe('50 lines of json');
});
});
describe('tag assignment', () => {
it('includes language tag', () => {
const files = [makeFile({ language: 'python' })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('python');
});
it('includes category tag', () => {
const files = [makeFile({ category: 'config' })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('config');
});
it('assigns "test" tag for files in test directories', () => {
const testPaths = [
'src/test/helper.ts',
'src/__tests__/utils.ts',
'spec/app.spec.ts',
];
for (const relativePath of testPaths) {
const files = [makeFile({ relativePath })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('test');
}
});
it('assigns "test" tag for files with .test. or .spec. in name', () => {
const files = [makeFile({ relativePath: 'src/utils.test.ts' })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('test');
});
it('assigns "component" tag for files in components directory', () => {
const files = [makeFile({ relativePath: 'src/components/Button.tsx' })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('component');
});
it('assigns "util" tag for files in utils/lib/helpers directories', () => {
const utilPaths = [
'src/utils/format.ts',
'src/lib/crypto.ts',
'src/helpers/date.ts',
];
for (const relativePath of utilPaths) {
const files = [makeFile({ relativePath })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('util');
}
});
it('assigns "api" tag for files in api/routes/controllers directories', () => {
const apiPaths = [
'src/api/users.ts',
'src/routes/auth.ts',
'src/controllers/posts.ts',
];
for (const relativePath of apiPaths) {
const files = [makeFile({ relativePath })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('api');
}
});
it('assigns "model" tag for files in models/entities directories', () => {
const modelPaths = [
'src/models/User.ts',
'src/entities/Post.ts',
];
for (const relativePath of modelPaths) {
const files = [makeFile({ relativePath })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('model');
}
});
it('assigns "simple" complexity tag for files under 50 lines', () => {
const files = [makeFile({ lineCount: 30 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('simple');
});
it('assigns "moderate" complexity tag for files between 50-200 lines', () => {
const files = [makeFile({ lineCount: 100 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('moderate');
});
it('assigns "complex" complexity tag for files over 200 lines', () => {
const files = [makeFile({ lineCount: 500 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('complex');
});
it('assigns boundary complexity: 49 lines is simple', () => {
const files = [makeFile({ lineCount: 49 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('simple');
});
it('assigns boundary complexity: 50 lines is moderate', () => {
const files = [makeFile({ lineCount: 50 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('moderate');
});
it('assigns boundary complexity: 200 lines is moderate', () => {
const files = [makeFile({ lineCount: 200 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('moderate');
});
it('assigns boundary complexity: 201 lines is complex', () => {
const files = [makeFile({ lineCount: 201 })];
const nodes = buildFileNodes(files, new Map());
expect(nodes[0].tags).toContain('complex');
});
});
describe('empty/edge cases', () => {
it('returns empty array for empty files list', () => {
const nodes = buildFileNodes([], new Map());
expect(nodes).toEqual([]);
});
it('handles file with no matching parse result gracefully', () => {
const files = [makeFile({ relativePath: 'src/unknown.ts', lineCount: 5, language: 'typescript' })];
const parseResults = new Map();
const nodes = buildFileNodes(files, parseResults);
expect(nodes).toHaveLength(1);
expect(nodes[0].summary).toBe('5 lines of typescript');
});
});
});
describe('buildGraph', () => {
it('includes file nodes in the output', () => {
const files = [makeFile({ relativePath: 'src/index.ts' })];
const parseResults = new Map([
['src/index.ts', makeParseResult({ exports: ['main'] })],
]);
const result = buildGraph(files, parseResults);
const fileNodes = result.nodes.filter(n => n.type === 'file');
expect(fileNodes).toHaveLength(1);
expect(fileNodes[0].id).toBe('src/index.ts');
expect(fileNodes[0].type).toBe('file');
});
it('includes function and class nodes in the output', () => {
const files = [makeFile({ relativePath: 'src/app.ts' })];
const parseResults = new Map([
['src/app.ts', makeParseResult({
functions: [
{ name: 'initialize', startLine: 1, endLine: 20, params: ['config'], isExported: true },
],
classes: [
{ name: 'App', startLine: 25, endLine: 80, methods: ['start', 'stop'], isExported: true },
],
})],
]);
const result = buildGraph(files, parseResults);
expect(result.nodes.some(n => n.type === 'function' && n.id === 'src/app.ts::initialize')).toBe(true);
expect(result.nodes.some(n => n.type === 'class' && n.id === 'src/app.ts::App')).toBe(true);
});
it('includes contains edges for functions and classes', () => {
const files = [makeFile({ relativePath: 'src/app.ts' })];
const parseResults = new Map([
['src/app.ts', makeParseResult({
functions: [
{ name: 'run', startLine: 1, endLine: 15, params: [], isExported: true },
],
classes: [
{ name: 'Server', startLine: 20, endLine: 50, methods: ['listen'], isExported: false },
],
})],
]);
const result = buildGraph(files, parseResults);
expect(result.edges).toContainEqual({
source: 'src/app.ts',
target: 'src/app.ts::run',
type: 'contains',
});
expect(result.edges).toContainEqual({
source: 'src/app.ts',
target: 'src/app.ts::Server',
type: 'contains',
});
});
it('returns empty nodes and edges for empty input', () => {
const result = buildGraph([], new Map());
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
});
describe('buildFunctionNodes', () => {
describe('significance filtering', () => {
it('creates node for exported function (even if under 10 lines)', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'add', startLine: 1, endLine: 5, params: ['a', 'b'], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].name).toBe('add');
});
it('creates node for function with 10+ lines (even if not exported)', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'processData', startLine: 1, endLine: 15, params: ['data'], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].name).toBe('processData');
});
it('skips function under 10 lines and not exported', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'tiny', startLine: 1, endLine: 5, params: [], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
it('boundary: function with exactly 10 lines is significant', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'borderline', startLine: 1, endLine: 10, params: [], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toHaveLength(1);
});
it('boundary: function with 9 lines and not exported is skipped', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'almostSignificant', startLine: 1, endLine: 9, params: [], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toHaveLength(0);
});
});
describe('node properties', () => {
it('sets id to "{relativePath}::{functionName}"', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].id).toBe('src/utils.ts::formatDate');
});
it('sets type to "function"', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'run', startLine: 1, endLine: 20, params: [], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].type).toBe('function');
});
it('generates summary with params and line count', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['name', 'age'], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].summary).toBe('(name, age) → 15 lines');
});
it('generates summary with empty params', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'init', startLine: 1, endLine: 12, params: [], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].summary).toBe('() → 12 lines');
});
});
describe('tags', () => {
it('includes parent file language tag', () => {
const files = [makeFile({ relativePath: 'src/utils.ts', language: 'typescript' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'run', startLine: 1, endLine: 20, params: [], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('typescript');
});
it('includes "exported" tag for exported functions', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'run', startLine: 1, endLine: 20, params: [], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('exported');
});
it('does not include "exported" tag for non-exported functions', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'bigHelper', startLine: 1, endLine: 20, params: [], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes[0].tags).not.toContain('exported');
});
});
describe('contains edges', () => {
it('creates contains edge from file to function', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: [], isExported: true },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.edges).toHaveLength(1);
expect(result.edges[0]).toEqual({
source: 'src/utils.ts',
target: 'src/utils.ts::formatDate',
type: 'contains',
});
});
it('creates multiple edges for multiple significant functions', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({
functions: [
{ name: 'funcA', startLine: 1, endLine: 15, params: [], isExported: true },
{ name: 'funcB', startLine: 20, endLine: 35, params: [], isExported: false },
],
})],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.edges).toHaveLength(2);
expect(result.edges[0].target).toBe('src/utils.ts::funcA');
expect(result.edges[1].target).toBe('src/utils.ts::funcB');
});
});
describe('edge cases', () => {
it('returns empty results for empty files list', () => {
const result = buildFunctionNodes([], new Map());
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
it('returns empty results when no parse results exist', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const result = buildFunctionNodes(files, new Map());
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
it('returns empty results when parse result has no functions', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({ functions: [] })],
]);
const result = buildFunctionNodes(files, parseResults);
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
});
});
describe('buildClassNodes', () => {
describe('node creation', () => {
it('creates node for every class (no significance filter)', () => {
const files = [makeFile({ relativePath: 'src/models/User.ts' })];
const parseResults = new Map([
['src/models/User.ts', makeParseResult({
classes: [
{ name: 'UserModel', startLine: 1, endLine: 20, methods: ['save', 'delete'], isExported: true },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].name).toBe('UserModel');
});
it('creates nodes for multiple classes in one file', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'User', startLine: 1, endLine: 10, methods: [], isExported: true },
{ name: 'Post', startLine: 12, endLine: 25, methods: ['publish'], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes).toHaveLength(2);
expect(result.nodes[0].name).toBe('User');
expect(result.nodes[1].name).toBe('Post');
});
});
describe('node properties', () => {
it('sets id to "{relativePath}::{className}"', () => {
const files = [makeFile({ relativePath: 'src/models/User.ts' })];
const parseResults = new Map([
['src/models/User.ts', makeParseResult({
classes: [
{ name: 'UserModel', startLine: 1, endLine: 20, methods: [], isExported: true },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].id).toBe('src/models/User.ts::UserModel');
});
it('sets type to "class"', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Base', startLine: 1, endLine: 5, methods: [], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].type).toBe('class');
});
it('generates summary with methods list', () => {
const files = [makeFile({ relativePath: 'src/service.ts' })];
const parseResults = new Map([
['src/service.ts', makeParseResult({
classes: [
{ name: 'ApiService', startLine: 1, endLine: 50, methods: ['get', 'post', 'delete'], isExported: true },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].summary).toBe('Methods: get, post, delete');
});
it('generates "Empty class" summary when no methods', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'EmptyBase', startLine: 1, endLine: 3, methods: [], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].summary).toBe('Empty class');
});
});
describe('tags', () => {
it('includes parent file language tag', () => {
const files = [makeFile({ relativePath: 'src/app.py', language: 'python' })];
const parseResults = new Map([
['src/app.py', makeParseResult({
classes: [
{ name: 'App', startLine: 1, endLine: 20, methods: ['run'], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('python');
});
it('includes "exported" tag for exported classes', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'User', startLine: 1, endLine: 20, methods: ['save'], isExported: true },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('exported');
});
it('does not include "exported" tag for non-exported classes', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Internal', startLine: 1, endLine: 20, methods: ['run'], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).not.toContain('exported');
});
it('assigns "small-class" tag for ≤3 methods', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Small', startLine: 1, endLine: 20, methods: ['a', 'b', 'c'], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('small-class');
});
it('assigns "medium-class" tag for 4-10 methods', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Medium', startLine: 1, endLine: 50, methods: ['a', 'b', 'c', 'd', 'e'], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('medium-class');
});
it('assigns "large-class" tag for >10 methods', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const methods = Array.from({ length: 11 }, (_, i) => `method${i}`);
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Large', startLine: 1, endLine: 200, methods, isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('large-class');
});
it('boundary: 0 methods is "small-class"', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Empty', startLine: 1, endLine: 3, methods: [], isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('small-class');
});
it('boundary: 10 methods is "medium-class"', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const methods = Array.from({ length: 10 }, (_, i) => `m${i}`);
const parseResults = new Map([
['src/models.ts', makeParseResult({
classes: [
{ name: 'Boundary', startLine: 1, endLine: 100, methods, isExported: false },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes[0].tags).toContain('medium-class');
});
});
describe('contains edges', () => {
it('creates contains edge from file to class', () => {
const files = [makeFile({ relativePath: 'src/models/User.ts' })];
const parseResults = new Map([
['src/models/User.ts', makeParseResult({
classes: [
{ name: 'UserModel', startLine: 1, endLine: 20, methods: ['save'], isExported: true },
],
})],
]);
const result = buildClassNodes(files, parseResults);
expect(result.edges).toHaveLength(1);
expect(result.edges[0]).toEqual({
source: 'src/models/User.ts',
target: 'src/models/User.ts::UserModel',
type: 'contains',
});
});
});
describe('edge cases', () => {
it('returns empty results for empty files list', () => {
const result = buildClassNodes([], new Map());
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
it('returns empty results when no parse results exist', () => {
const files = [makeFile({ relativePath: 'src/models.ts' })];
const result = buildClassNodes(files, new Map());
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
it('returns empty results when parse result has no classes', () => {
const files = [makeFile({ relativePath: 'src/utils.ts' })];
const parseResults = new Map([
['src/utils.ts', makeParseResult({ classes: [] })],
]);
const result = buildClassNodes(files, parseResults);
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
});
});
describe('buildImportEdges', () => {
describe('relative import resolution', () => {
it('creates an imports edge for a resolved relative import', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['formatDate'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({
source: 'src/app.ts',
target: 'src/utils.ts',
type: 'imports',
});
});
it('resolves parent directory imports (..)', () => {
const files = [
makeFile({ relativePath: 'src/components/Button.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/components/Button.ts', makeParseResult({
imports: [{ source: '../utils', names: [], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({
source: 'src/components/Button.ts',
target: 'src/utils.ts',
type: 'imports',
});
});
it('resolves imports with explicit extension', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/config.json' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './config.json', names: [], resolvedPath: undefined }],
})],
['src/config.json', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/config.json');
});
});
describe('extension resolution', () => {
it('resolves .ts extension', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/helper.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './helper', names: [], resolvedPath: undefined }],
})],
['src/helper.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/helper.ts');
});
it('resolves .js extension', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/legacy.js' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './legacy', names: [], resolvedPath: undefined }],
})],
['src/legacy.js', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/legacy.js');
});
it('resolves .tsx extension', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/Button.tsx' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './Button', names: [], resolvedPath: undefined }],
})],
['src/Button.tsx', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/Button.tsx');
});
it('resolves index.ts for directory imports', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils/index.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: [], resolvedPath: undefined }],
})],
['src/utils/index.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/utils/index.ts');
});
it('resolves index.js for directory imports', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/lib/index.js' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './lib', names: [], resolvedPath: undefined }],
})],
['src/lib/index.js', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe('src/lib/index.js');
});
});
describe('external imports are skipped', () => {
it('skips imports not starting with . or ..', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [
{ source: 'react', names: ['useState'], resolvedPath: undefined },
{ source: '@types/node', names: [], resolvedPath: undefined },
{ source: 'lodash/debounce', names: ['debounce'], resolvedPath: undefined },
],
})],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
});
describe('unresolvable imports are skipped', () => {
it('skips relative imports that cannot be resolved to a known file', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [
{ source: './nonexistent', names: [], resolvedPath: undefined },
{ source: '../missing/module', names: [], resolvedPath: undefined },
],
})],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
});
describe('edge cases', () => {
it('returns empty array when no files have imports', () => {
const files = [makeFile({ relativePath: 'src/app.ts' })];
const parseResults = new Map([
['src/app.ts', makeParseResult({ imports: [] })],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toEqual([]);
});
it('returns empty array for empty files list', () => {
const edges = buildImportEdges([], new Map());
expect(edges).toEqual([]);
});
it('handles file with no parse result', () => {
const files = [makeFile({ relativePath: 'src/app.ts' })];
const edges = buildImportEdges(files, new Map());
expect(edges).toEqual([]);
});
it('creates multiple import edges from one file', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
makeFile({ relativePath: 'src/config.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [
{ source: './utils', names: [], resolvedPath: undefined },
{ source: './config', names: [], resolvedPath: undefined },
],
})],
['src/utils.ts', makeParseResult()],
['src/config.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
expect(edges).toHaveLength(2);
expect(edges[0].target).toBe('src/utils.ts');
expect(edges[1].target).toBe('src/config.ts');
});
it('does not create duplicate edges for same import', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['a', 'b'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult()],
]);
const edges = buildImportEdges(files, parseResults);
// Only one imports edge per import statement
expect(edges).toHaveLength(1);
});
});
});
describe('buildCallEdges', () => {
describe('call edge creation from named imports', () => {
it('creates a calls edge when named import matches a significant function', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['formatDate'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(1);
expect(edges[0]).toEqual({
source: 'src/app.ts',
target: 'src/utils.ts::formatDate',
type: 'calls',
});
});
it('creates multiple call edges for multiple named imports', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['formatDate', 'parseDate'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
{ name: 'parseDate', startLine: 20, endLine: 35, params: ['str'], isExported: true },
],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(2);
expect(edges[0].target).toBe('src/utils.ts::formatDate');
expect(edges[1].target).toBe('src/utils.ts::parseDate');
});
it('skips named imports that do not match a significant function', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['CONSTANT', 'SomeType'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
it('only matches significant functions (exported or 10+ lines)', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['tiny'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
// Not significant: not exported and under 10 lines
{ name: 'tiny', startLine: 1, endLine: 5, params: [], isExported: false },
],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
});
describe('external and unresolvable imports', () => {
it('skips external package imports', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: 'lodash', names: ['debounce'], resolvedPath: undefined }],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
it('skips imports that cannot be resolved', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './nonexistent', names: ['foo'], resolvedPath: undefined }],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toHaveLength(0);
});
});
describe('edge cases', () => {
it('returns empty array for empty files list', () => {
const edges = buildCallEdges([], new Map());
expect(edges).toEqual([]);
});
it('returns empty array when imports have no names', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: [], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: [], isExported: true },
],
})],
]);
const edges = buildCallEdges(files, parseResults);
expect(edges).toEqual([]);
});
it('handles file with no parse result', () => {
const files = [makeFile({ relativePath: 'src/app.ts' })];
const edges = buildCallEdges(files, new Map());
expect(edges).toEqual([]);
});
});
});
describe('buildGraph - import and call edges integration', () => {
it('includes import edges in the graph output', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: [], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult()],
]);
const result = buildGraph(files, parseResults);
const importEdges = result.edges.filter(e => e.type === 'imports');
expect(importEdges).toHaveLength(1);
expect(importEdges[0]).toEqual({
source: 'src/app.ts',
target: 'src/utils.ts',
type: 'imports',
});
});
it('includes call edges in the graph output', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['formatDate'], resolvedPath: undefined }],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
],
})],
]);
const result = buildGraph(files, parseResults);
const callEdges = result.edges.filter(e => e.type === 'calls');
expect(callEdges).toHaveLength(1);
expect(callEdges[0]).toEqual({
source: 'src/app.ts',
target: 'src/utils.ts::formatDate',
type: 'calls',
});
});
it('combines all edge types in the output', () => {
const files = [
makeFile({ relativePath: 'src/app.ts' }),
makeFile({ relativePath: 'src/utils.ts' }),
];
const parseResults = new Map([
['src/app.ts', makeParseResult({
imports: [{ source: './utils', names: ['formatDate'], resolvedPath: undefined }],
functions: [
{ name: 'main', startLine: 1, endLine: 20, params: [], isExported: true },
],
})],
['src/utils.ts', makeParseResult({
functions: [
{ name: 'formatDate', startLine: 1, endLine: 15, params: ['date'], isExported: true },
],
})],
]);
const result = buildGraph(files, parseResults);
const edgeTypes = result.edges.map(e => e.type);
expect(edgeTypes).toContain('contains');
expect(edgeTypes).toContain('imports');
expect(edgeTypes).toContain('calls');
});
});