Dev Intel Pipeline v2 — multi-language semantic graph extractor
Phase 1: extract.js — tree-sitter AST parser (TS/JS/Python/Go/Java/Bash) + config parsers (YAML/HCL) Phase 2: graph.js — in-memory directed graph store with build/query/diff CLI Phase 3: namespace.js — cross-repo namespace registry with 3-tier resolution Phase 4: semantic-diff.js — categorized diffs with impact scoring (0-100) Phase 5: pipeline.js — batch extraction, incremental diffing, benchmarking Benchmark: 4,325 files, 21,646 nodes, 133,979 edges in 67s (15ms/file) BMad SPA reviews: all phases GO
This commit is contained in:
102
extract-config.js
Normal file
102
extract-config.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jsYaml = require('js-yaml');
|
||||
|
||||
function extractYaml(filePath, repoRoot) {
|
||||
const sourceCode = fs.readFileSync(filePath, 'utf8');
|
||||
const relPath = path.relative(repoRoot, filePath);
|
||||
const moduleId = relPath;
|
||||
const entities = [];
|
||||
const relationships = [];
|
||||
|
||||
entities.push({
|
||||
id: moduleId,
|
||||
type: 'Config',
|
||||
name: relPath,
|
||||
kind: 'yaml-config',
|
||||
visibility: 'public',
|
||||
line_range: [1, sourceCode.split('\n').length]
|
||||
});
|
||||
|
||||
try {
|
||||
// Attempt to load multiple documents
|
||||
const docs = jsYaml.loadAll(sourceCode);
|
||||
let lineNum = 1;
|
||||
for (const doc of docs) {
|
||||
if (doc && typeof doc === 'object') {
|
||||
for (const key of Object.keys(doc)) {
|
||||
const keyId = `${moduleId}:${key}`;
|
||||
entities.push({
|
||||
id: keyId,
|
||||
type: 'Config',
|
||||
name: key,
|
||||
kind: 'yaml-key',
|
||||
visibility: 'public',
|
||||
line_range: [lineNum, lineNum] // Approximation without AST
|
||||
});
|
||||
relationships.push({
|
||||
type: 'CONTAINS',
|
||||
source: moduleId,
|
||||
target: keyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Log warning, return base module
|
||||
console.error(`YAML parse error in ${relPath}: ${e.message}`);
|
||||
}
|
||||
|
||||
return { file: filePath, language: 'yaml', entities, relationships };
|
||||
}
|
||||
|
||||
function extractHcl(filePath, repoRoot) {
|
||||
const sourceCode = fs.readFileSync(filePath, 'utf8');
|
||||
const relPath = path.relative(repoRoot, filePath);
|
||||
const moduleId = relPath;
|
||||
const entities = [];
|
||||
const relationships = [];
|
||||
const lines = sourceCode.split('\n');
|
||||
|
||||
entities.push({
|
||||
id: moduleId,
|
||||
type: 'Config',
|
||||
name: relPath,
|
||||
kind: 'terraform',
|
||||
visibility: 'public',
|
||||
line_range: [1, lines.length]
|
||||
});
|
||||
|
||||
// Regex for top-level HCL blocks (e.g., resource "aws_s3_bucket" "my_bucket" {)
|
||||
const blockRegex = /^(resource|data|module|variable|output|provider)\s+"([^"]+)"(?:\s+"([^"]+)")?\s*\{/;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(blockRegex);
|
||||
if (match) {
|
||||
const type = match[1];
|
||||
const name1 = match[2];
|
||||
const name2 = match[3];
|
||||
const fullName = name2 ? `${type}.${name1}.${name2}` : `${type}.${name1}`;
|
||||
const blockId = `${moduleId}:${fullName}`;
|
||||
|
||||
entities.push({
|
||||
id: blockId,
|
||||
type: 'Config',
|
||||
name: fullName,
|
||||
kind: 'hcl-block',
|
||||
visibility: 'public',
|
||||
line_range: [i + 1, i + 1] // Approximation
|
||||
});
|
||||
|
||||
relationships.push({
|
||||
type: 'CONTAINS',
|
||||
source: moduleId,
|
||||
target: blockId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { file: filePath, language: 'hcl', entities, relationships };
|
||||
}
|
||||
|
||||
module.exports = { extractYaml, extractHcl };
|
||||
806
extract.js
Normal file
806
extract.js
Normal file
@@ -0,0 +1,806 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Parser = require('tree-sitter');
|
||||
const jsYaml = require('js-yaml');
|
||||
|
||||
// --- Language Grammars (tree-sitter for code only) ---
|
||||
const GRAMMARS = {
|
||||
typescript: require('tree-sitter-typescript').typescript,
|
||||
tsx: require('tree-sitter-typescript').tsx,
|
||||
javascript: require('tree-sitter-javascript'),
|
||||
python: require('tree-sitter-python'),
|
||||
java: require('tree-sitter-java'),
|
||||
go: require('tree-sitter-go'),
|
||||
bash: require('tree-sitter-bash'),
|
||||
};
|
||||
|
||||
const { extractYaml, extractHcl } = require('./extract-config.js');
|
||||
|
||||
const EXT_MAP = {
|
||||
'.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'javascript',
|
||||
'.py': 'python', '.java': 'java', '.go': 'go',
|
||||
'.sh': 'bash', '.bash': 'bash',
|
||||
'.yaml': 'yaml', '.yml': 'yaml',
|
||||
'.tf': 'hcl', '.hcl': 'hcl',
|
||||
'.kcl': 'yaml', // KCL has no tree-sitter grammar; parse as YAML (structural approximation)
|
||||
};
|
||||
|
||||
// --- Language Adapters ---
|
||||
// Each adapter defines node types for that language's AST
|
||||
const ADAPTERS = {
|
||||
typescript: {
|
||||
classNodes: ['class_declaration'],
|
||||
functionNodes: ['function_declaration'],
|
||||
arrowFuncParent: 'lexical_declaration',
|
||||
methodNodes: ['method_definition'],
|
||||
fieldNodes: ['public_field_definition'],
|
||||
importNodes: ['import_statement'],
|
||||
requireFunc: 'require',
|
||||
exportWrapper: 'export_statement',
|
||||
varDecl: ['lexical_declaration', 'variable_declaration'],
|
||||
callExpr: 'call_expression',
|
||||
funcField: 'function',
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
sourceField: 'source',
|
||||
valueField: 'value',
|
||||
arrowTypes: ['arrow_function', 'function'],
|
||||
accessModifier: 'accessibility_modifier',
|
||||
heritage: 'class_heritage',
|
||||
implementsClause: 'implements_clause',
|
||||
},
|
||||
python: {
|
||||
classNodes: ['class_definition'],
|
||||
functionNodes: ['function_definition'],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: [], // methods are function_definition inside class
|
||||
fieldNodes: [],
|
||||
importNodes: ['import_statement', 'import_from_statement'],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: ['assignment', 'augmented_assignment'],
|
||||
callExpr: 'call',
|
||||
funcField: 'function',
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
sourceField: null,
|
||||
valueField: 'right',
|
||||
arrowTypes: ['lambda'],
|
||||
accessModifier: null,
|
||||
heritage: null,
|
||||
implementsClause: null,
|
||||
},
|
||||
java: {
|
||||
classNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'],
|
||||
functionNodes: ['method_declaration', 'constructor_declaration'],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: ['method_declaration', 'constructor_declaration'],
|
||||
fieldNodes: ['field_declaration'],
|
||||
importNodes: ['import_declaration'],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: ['local_variable_declaration', 'field_declaration'],
|
||||
callExpr: 'method_invocation',
|
||||
funcField: 'name',
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
sourceField: null,
|
||||
valueField: null,
|
||||
arrowTypes: ['lambda_expression'],
|
||||
accessModifier: 'modifiers',
|
||||
heritage: 'superclass',
|
||||
implementsClause: 'super_interfaces',
|
||||
},
|
||||
go: {
|
||||
classNodes: ['type_declaration'], // struct types
|
||||
functionNodes: ['function_declaration', 'method_declaration'],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: ['method_declaration'],
|
||||
fieldNodes: [],
|
||||
importNodes: ['import_declaration'],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: ['var_declaration', 'short_var_declaration', 'const_declaration'],
|
||||
callExpr: 'call_expression',
|
||||
funcField: 'function',
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
sourceField: 'path',
|
||||
valueField: null,
|
||||
arrowTypes: ['func_literal'],
|
||||
accessModifier: null,
|
||||
heritage: null,
|
||||
implementsClause: null,
|
||||
},
|
||||
yaml: {
|
||||
classNodes: [],
|
||||
functionNodes: [],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: [],
|
||||
fieldNodes: [],
|
||||
importNodes: [],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: [],
|
||||
callExpr: null,
|
||||
funcField: null,
|
||||
nameField: null,
|
||||
bodyField: null,
|
||||
sourceField: null,
|
||||
valueField: null,
|
||||
arrowTypes: [],
|
||||
accessModifier: null,
|
||||
heritage: null,
|
||||
implementsClause: null,
|
||||
},
|
||||
hcl: {
|
||||
classNodes: [],
|
||||
functionNodes: [],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: [],
|
||||
fieldNodes: [],
|
||||
importNodes: [],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: [],
|
||||
callExpr: 'function_call',
|
||||
funcField: null,
|
||||
nameField: null,
|
||||
bodyField: 'body',
|
||||
sourceField: null,
|
||||
valueField: null,
|
||||
arrowTypes: [],
|
||||
accessModifier: null,
|
||||
heritage: null,
|
||||
implementsClause: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Alias adapters
|
||||
ADAPTERS.tsx = ADAPTERS.typescript;
|
||||
ADAPTERS.javascript = ADAPTERS.typescript;
|
||||
|
||||
ADAPTERS.bash = {
|
||||
classNodes: [],
|
||||
functionNodes: ['function_definition'],
|
||||
arrowFuncParent: null,
|
||||
methodNodes: [],
|
||||
fieldNodes: [],
|
||||
importNodes: [],
|
||||
requireFunc: null,
|
||||
exportWrapper: null,
|
||||
varDecl: ['variable_assignment'],
|
||||
callExpr: 'command',
|
||||
funcField: 'name',
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
sourceField: null,
|
||||
valueField: null,
|
||||
arrowTypes: [],
|
||||
accessModifier: null,
|
||||
heritage: null,
|
||||
implementsClause: null,
|
||||
};
|
||||
|
||||
// --- Core Extractor ---
|
||||
function extract(filePath, repoRoot) {
|
||||
const ext = path.extname(filePath);
|
||||
const lang = EXT_MAP[ext];
|
||||
if (!lang) {
|
||||
console.error(`Unsupported extension: ${ext}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (lang === 'yaml') return extractYaml(filePath, repoRoot);
|
||||
if (lang === 'hcl') return extractHcl(filePath, repoRoot);
|
||||
|
||||
const grammar = GRAMMARS[lang];
|
||||
const adapter = ADAPTERS[lang];
|
||||
if (!grammar || !adapter) {
|
||||
console.error(`No grammar/adapter for: ${lang}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(grammar);
|
||||
|
||||
let sourceCode;
|
||||
try {
|
||||
sourceCode = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(`Failed to read ${filePath}: ${err.message}`);
|
||||
return { file: filePath, language: lang, entities: [], relationships: [], error: err.message };
|
||||
}
|
||||
|
||||
let tree;
|
||||
try {
|
||||
tree = parser.parse(sourceCode);
|
||||
} catch (err) {
|
||||
console.error(`Failed to parse ${filePath}: ${err.message}`);
|
||||
return { file: filePath, language: lang, entities: [], relationships: [], error: err.message };
|
||||
}
|
||||
|
||||
const relPath = path.relative(repoRoot, filePath);
|
||||
const moduleId = relPath;
|
||||
const entities = [];
|
||||
const relationships = [];
|
||||
|
||||
function getText(node) {
|
||||
return sourceCode.substring(node.startIndex, node.endIndex);
|
||||
}
|
||||
|
||||
function lineRange(node) {
|
||||
return [node.startPosition.row + 1, node.endPosition.row + 1];
|
||||
}
|
||||
|
||||
function isExported(node) {
|
||||
if (adapter.exportWrapper) {
|
||||
// ES6 export
|
||||
if (node.parent && node.parent.type === adapter.exportWrapper) return true;
|
||||
// CommonJS: module.exports = { ... } or exports.foo = ...
|
||||
// Check if this function/class name appears in a module.exports assignment
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const name = getText(nameNode);
|
||||
// Walk up to find module.exports references to this name
|
||||
const root = tree.rootNode;
|
||||
for (const child of root.children) {
|
||||
if (child.type === 'expression_statement') {
|
||||
const expr = child.children[0];
|
||||
if (expr && expr.type === 'assignment_expression') {
|
||||
const left = expr.childForFieldName('left');
|
||||
if (left) {
|
||||
const leftText = getText(left);
|
||||
// module.exports.foo = ... or exports.foo = ...
|
||||
if (leftText === `module.exports.${name}` || leftText === `exports.${name}`) return true;
|
||||
// module.exports = { foo, bar } or module.exports = foo
|
||||
if (leftText === 'module.exports') {
|
||||
const right = expr.childForFieldName('right');
|
||||
if (right) {
|
||||
const rightText = getText(right);
|
||||
if (rightText === name || rightText.includes(name)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Python: no export concept, everything is public
|
||||
// Java: check modifiers
|
||||
// Go: capitalized name = exported
|
||||
if (lang === 'go') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const name = getText(nameNode);
|
||||
return name[0] === name[0].toUpperCase();
|
||||
}
|
||||
}
|
||||
if (lang === 'java') {
|
||||
const mods = node.children.find(c => c.type === 'modifiers');
|
||||
if (mods) return getText(mods).includes('public');
|
||||
return false;
|
||||
}
|
||||
return true; // Python: everything is public
|
||||
}
|
||||
|
||||
function addEntity(e) {
|
||||
if (!entities.find(x => x.id === e.id)) entities.push(e);
|
||||
}
|
||||
|
||||
const _relSet = new Set();
|
||||
function addRel(r) {
|
||||
const key = `${r.type}:${r.source}->${r.target}`;
|
||||
if (!_relSet.has(key)) {
|
||||
_relSet.add(key);
|
||||
relationships.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Import Extraction ---
|
||||
function extractImports(node) {
|
||||
if (adapter.importNodes.includes(node.type)) {
|
||||
if (lang === 'typescript' || lang === 'tsx' || lang === 'javascript') {
|
||||
const sourceNode = node.childForFieldName('source');
|
||||
if (sourceNode) {
|
||||
const depName = getText(sourceNode).replace(/['"]/g, '');
|
||||
// Resolve relative imports against file directory
|
||||
let resolvedDep = depName;
|
||||
if (depName.startsWith('.')) {
|
||||
resolvedDep = path.posix.normalize(path.posix.join(path.dirname(relPath), depName));
|
||||
}
|
||||
const depId = `dep:${resolvedDep}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: resolvedDep, kind: 'import', visibility: 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (lang === 'python') {
|
||||
// import X or from X import Y
|
||||
const modNode = node.childForFieldName('module_name') || node.childForFieldName('name');
|
||||
let depName = 'unknown';
|
||||
if (modNode) {
|
||||
depName = getText(modNode);
|
||||
} else {
|
||||
// Fallback: grab dotted name from children
|
||||
const dotted = node.children.find(c => c.type === 'dotted_name');
|
||||
if (dotted) depName = getText(dotted);
|
||||
}
|
||||
const depId = `dep:${depName}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: depName, kind: 'import', visibility: 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
return true;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// import com.foo.Bar;
|
||||
const scopedId = node.children.find(c => c.type === 'scoped_identifier');
|
||||
if (scopedId) {
|
||||
const depName = getText(scopedId);
|
||||
const depId = `dep:${depName}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: depName, kind: 'import', visibility: 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (lang === 'go') {
|
||||
// import "fmt" or import ( "fmt" "os" )
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'import_spec' || child.type === 'import_spec_list') {
|
||||
const specs = child.type === 'import_spec_list' ? child.namedChildren : [child];
|
||||
for (const spec of specs) {
|
||||
const pathNode = spec.childForFieldName('path');
|
||||
if (pathNode) {
|
||||
const depName = getText(pathNode).replace(/"/g, '');
|
||||
const depId = `dep:${depName}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: depName, kind: 'import', visibility: 'internal', line_range: lineRange(spec) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// CommonJS require() for JS/TS
|
||||
if (adapter.requireFunc && (node.type === 'lexical_declaration' || node.type === 'variable_declaration')) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'variable_declarator') {
|
||||
const value = child.childForFieldName('value');
|
||||
if (value && value.type === 'call_expression') {
|
||||
const func = value.childForFieldName('function');
|
||||
if (func && getText(func) === adapter.requireFunc) {
|
||||
const args = value.childForFieldName('arguments');
|
||||
if (args && args.namedChildCount > 0) {
|
||||
const arg = args.namedChildren[0];
|
||||
if (arg.type === 'string') {
|
||||
const depName = getText(arg).replace(/['"]/g, '');
|
||||
let resolvedDep = depName;
|
||||
if (depName.startsWith('.')) {
|
||||
resolvedDep = path.posix.normalize(path.posix.join(path.dirname(relPath), depName));
|
||||
}
|
||||
const depId = `dep:${resolvedDep}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: resolvedDep, kind: 'require', visibility: 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bash: source ./utils.sh -> IMPORTS
|
||||
if (lang === 'bash' && node.type === 'command') {
|
||||
const cmd = node.namedChildren[0];
|
||||
if (cmd && getText(cmd) === 'source') {
|
||||
const arg = node.namedChildren[1];
|
||||
if (arg) {
|
||||
const depName = getText(arg);
|
||||
const depId = `dep:${depName}`;
|
||||
addEntity({ id: depId, type: 'Dependency', name: depName, kind: 'import', visibility: 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'IMPORTS', source: moduleId, target: depId });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Class Extraction ---
|
||||
function extractClass(node, parentId) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return null;
|
||||
const name = getText(nameNode);
|
||||
const id = `${parentId}:${name}`;
|
||||
const exported = isExported(node);
|
||||
|
||||
let kind = 'class';
|
||||
if (lang === 'go') kind = 'struct';
|
||||
if (node.type === 'interface_declaration') kind = 'interface';
|
||||
if (node.type === 'enum_declaration') kind = 'enum';
|
||||
|
||||
addEntity({ id, type: 'Class', name, kind, visibility: exported ? 'public' : 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'CONTAINS', source: parentId, target: id });
|
||||
|
||||
// Implements/extends
|
||||
if (adapter.heritage) {
|
||||
const heritage = node.children.filter(c => c.type === adapter.heritage);
|
||||
for (const h of heritage) {
|
||||
for (const child of h.namedChildren) {
|
||||
if (adapter.implementsClause && child.type === adapter.implementsClause) {
|
||||
for (const impl of child.namedChildren) {
|
||||
addRel({ type: 'IMPLEMENTS', source: id, target: getText(impl) });
|
||||
}
|
||||
} else {
|
||||
addRel({ type: 'IMPLEMENTS', source: id, target: getText(child) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Java: superclass and super_interfaces
|
||||
if (lang === 'java') {
|
||||
const superclass = node.childForFieldName('superclass');
|
||||
if (superclass) addRel({ type: 'IMPLEMENTS', source: id, target: getText(superclass).replace(/^extends\s+/, '') });
|
||||
const superInterfaces = node.childForFieldName('interfaces');
|
||||
if (superInterfaces) {
|
||||
for (const iface of superInterfaces.namedChildren) {
|
||||
addRel({ type: 'IMPLEMENTS', source: id, target: getText(iface) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Python: bases
|
||||
if (lang === 'python') {
|
||||
const argList = node.childForFieldName('superclasses');
|
||||
if (argList) {
|
||||
for (const base of argList.namedChildren) {
|
||||
addRel({ type: 'IMPLEMENTS', source: id, target: getText(base) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// --- Method Extraction ---
|
||||
function extractMethod(node, parentId) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return null;
|
||||
const name = getText(nameNode);
|
||||
const id = `${parentId}:${name}`;
|
||||
|
||||
let visibility = 'public';
|
||||
if (adapter.accessModifier) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === adapter.accessModifier) {
|
||||
const modText = getText(child);
|
||||
if (modText.includes('private')) visibility = 'private';
|
||||
else if (modText.includes('protected')) visibility = 'protected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Python: _ prefix = private, __ = very private
|
||||
if (lang === 'python' && name.startsWith('_')) {
|
||||
visibility = name.startsWith('__') ? 'private' : 'protected';
|
||||
}
|
||||
|
||||
addEntity({ id, type: 'Function', name, kind: 'method', visibility, line_range: lineRange(node) });
|
||||
addRel({ type: 'CONTAINS', source: parentId, target: id });
|
||||
return id;
|
||||
}
|
||||
|
||||
// --- Function Extraction ---
|
||||
function extractFunction(node, parentId) {
|
||||
const exported = isExported(node);
|
||||
|
||||
if (adapter.functionNodes.includes(node.type)) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return null;
|
||||
const name = getText(nameNode);
|
||||
const id = `${parentId}:${name}`;
|
||||
|
||||
let visibility = exported ? 'public' : 'internal';
|
||||
if (lang === 'go' && name[0] === name[0].toUpperCase()) visibility = 'public';
|
||||
if (lang === 'go' && name[0] === name[0].toLowerCase()) visibility = 'internal';
|
||||
|
||||
addEntity({ id, type: 'Function', name, kind: 'function', visibility, line_range: lineRange(node) });
|
||||
addRel({ type: 'CONTAINS', source: parentId, target: id });
|
||||
return id;
|
||||
}
|
||||
|
||||
// JS/TS arrow functions
|
||||
if (adapter.arrowFuncParent && node.type === adapter.arrowFuncParent) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'variable_declarator') {
|
||||
const value = child.childForFieldName('value');
|
||||
if (value && adapter.arrowTypes.includes(value.type)) {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const name = getText(nameNode);
|
||||
const id = `${parentId}:${name}`;
|
||||
addEntity({ id, type: 'Function', name, kind: 'function', visibility: exported ? 'public' : 'internal', line_range: lineRange(node) });
|
||||
addRel({ type: 'CONTAINS', source: parentId, target: id });
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Class Field (arrow method vs property) ---
|
||||
function extractClassField(node, parentId) {
|
||||
if (!adapter.fieldNodes.includes(node.type)) return null;
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return null;
|
||||
const value = node.childForFieldName('value');
|
||||
if (value && adapter.arrowTypes.includes(value.type)) {
|
||||
return extractMethod(node, parentId);
|
||||
}
|
||||
return null; // Skip non-function class properties
|
||||
}
|
||||
|
||||
// --- Call Extraction ---
|
||||
function extractCalls(node, parentId) {
|
||||
if (!adapter.callExpr) return;
|
||||
if (node.type === adapter.callExpr) {
|
||||
let funcName;
|
||||
if (lang === 'java') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const obj = node.childForFieldName('object');
|
||||
funcName = obj ? `${getText(obj)}.${getText(nameNode)}` : (nameNode ? getText(nameNode) : null);
|
||||
} else if (lang === 'python') {
|
||||
const funcNode = node.childForFieldName('function');
|
||||
funcName = funcNode ? getText(funcNode) : null;
|
||||
} else if (lang === 'bash') {
|
||||
const funcNode = node.namedChildren[0];
|
||||
funcName = funcNode ? getText(funcNode) : null;
|
||||
} else {
|
||||
const funcNode = node.childForFieldName(adapter.funcField);
|
||||
funcName = funcNode ? getText(funcNode) : null;
|
||||
}
|
||||
if (funcName) {
|
||||
if (adapter.requireFunc && funcName === adapter.requireFunc) return;
|
||||
addRel({ type: 'CALLS', source: parentId, target: funcName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- YAML/HCL Config Extraction ---
|
||||
function extractConfig(node) {
|
||||
if (lang === 'yaml') {
|
||||
addEntity({ id: moduleId, type: 'Config', name: relPath, kind: 'yaml-config', visibility: 'public', line_range: lineRange(node) });
|
||||
// Extract top-level keys as config entries
|
||||
if (node.type === 'stream') {
|
||||
for (const doc of node.namedChildren) {
|
||||
if (doc.type === 'document') {
|
||||
const block = doc.namedChildren[0];
|
||||
if (block && block.type === 'block_node') {
|
||||
const mapping = block.namedChildren[0];
|
||||
if (mapping && mapping.type === 'block_mapping') {
|
||||
for (const pair of mapping.namedChildren) {
|
||||
if (pair.type === 'block_mapping_pair') {
|
||||
const key = pair.childForFieldName('key');
|
||||
if (key) {
|
||||
const keyName = getText(key);
|
||||
const keyId = `${moduleId}:${keyName}`;
|
||||
addEntity({ id: keyId, type: 'Config', name: keyName, kind: 'yaml-key', visibility: 'public', line_range: lineRange(pair) });
|
||||
addRel({ type: 'CONTAINS', source: moduleId, target: keyId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (lang === 'hcl') {
|
||||
addEntity({ id: moduleId, type: 'Config', name: relPath, kind: 'terraform', visibility: 'public', line_range: lineRange(node) });
|
||||
// Extract top-level blocks (resource, data, variable, output, module, provider)
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'block') {
|
||||
const blockType = child.namedChildren[0]; // e.g., "resource"
|
||||
const labels = child.namedChildren.filter(c => c.type === 'string_lit' || c.type === 'identifier');
|
||||
const blockName = labels.map(l => getText(l).replace(/"/g, '')).join('.');
|
||||
const fullName = blockType ? `${getText(blockType)}.${blockName}` : blockName;
|
||||
const blockId = `${moduleId}:${fullName}`;
|
||||
addEntity({ id: blockId, type: 'Config', name: fullName, kind: 'hcl-block', visibility: 'public', line_range: lineRange(child) });
|
||||
addRel({ type: 'CONTAINS', source: moduleId, target: blockId });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Main Walker ---
|
||||
function walk(node, parentId) {
|
||||
if (node.type === 'program' || node.type === 'source_file' || node.type === 'stream' || node.type === 'compilation_unit' || node.type === 'module') {
|
||||
// Config files (YAML/HCL)
|
||||
if (extractConfig(node)) return;
|
||||
|
||||
// Code files
|
||||
addEntity({ id: moduleId, type: 'Module', name: relPath, kind: 'module', visibility: 'public', line_range: lineRange(node) });
|
||||
for (const child of node.children) {
|
||||
walk(child, moduleId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Export wrapper (JS/TS)
|
||||
if (adapter.exportWrapper && node.type === adapter.exportWrapper) {
|
||||
for (const child of node.children) {
|
||||
if (child.type !== 'export' && child.type !== 'default') {
|
||||
walk(child, parentId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Imports
|
||||
if (extractImports(node)) return;
|
||||
|
||||
// Classes
|
||||
if (adapter.classNodes.includes(node.type)) {
|
||||
const classId = extractClass(node, parentId);
|
||||
if (classId) {
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) {
|
||||
for (const child of body.namedChildren || body.children) {
|
||||
walk(child, classId);
|
||||
}
|
||||
}
|
||||
// Go type_declaration: walk type_spec children
|
||||
if (lang === 'go') {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'type_spec') {
|
||||
const structBody = child.childForFieldName('type');
|
||||
if (structBody) {
|
||||
for (const field of structBody.namedChildren) {
|
||||
walk(field, classId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Methods (inside class body)
|
||||
if (adapter.methodNodes.includes(node.type)) {
|
||||
const methodId = extractMethod(node, parentId);
|
||||
if (methodId) {
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) walkBody(body, methodId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Class fields (arrow methods vs properties)
|
||||
if (adapter.fieldNodes.includes(node.type)) {
|
||||
const methodId = extractClassField(node, parentId);
|
||||
if (methodId) {
|
||||
const value = node.childForFieldName('value');
|
||||
if (value) {
|
||||
const body = value.childForFieldName('body');
|
||||
if (body) walkBody(body, methodId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Python: function_definition can be top-level or method (inside class)
|
||||
if (lang === 'python' && node.type === 'function_definition') {
|
||||
if (parentId && parentId.includes(':') && parentId !== moduleId) {
|
||||
// Inside a class → method
|
||||
const methodId = extractMethod(node, parentId);
|
||||
if (methodId) {
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) walkBody(body, methodId);
|
||||
}
|
||||
} else {
|
||||
// Top-level → function
|
||||
const funcId = extractFunction(node, parentId);
|
||||
if (funcId) {
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) walkBody(body, funcId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Go: method_declaration (receiver-based)
|
||||
if (lang === 'go' && node.type === 'method_declaration') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const receiver = node.childForFieldName('receiver');
|
||||
if (nameNode) {
|
||||
const name = getText(nameNode);
|
||||
if (!name || name.length === 0) return;
|
||||
let receiverType = parentId;
|
||||
if (receiver) {
|
||||
const paramList = receiver.namedChildren;
|
||||
for (const p of paramList) {
|
||||
const typeNode = p.childForFieldName('type');
|
||||
if (typeNode) {
|
||||
let raw = getText(typeNode);
|
||||
// Strip pointer (*) and generic brackets safely
|
||||
let typeName = raw.replace(/^\*+/, '').replace(/\[.*\]$/, '').trim();
|
||||
if (typeName.length > 0) {
|
||||
receiverType = `${moduleId}:${typeName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const id = `${receiverType}:${name}`;
|
||||
const visibility = name.length > 0 && name[0] === name[0].toUpperCase() ? 'public' : 'internal';
|
||||
addEntity({ id, type: 'Function', name, kind: 'method', visibility, line_range: lineRange(node) });
|
||||
addRel({ type: 'CONTAINS', source: receiverType, target: id });
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) walkBody(body, id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Functions (top-level)
|
||||
if (adapter.functionNodes.includes(node.type) || (adapter.arrowFuncParent && node.type === adapter.arrowFuncParent)) {
|
||||
const funcId = extractFunction(node, parentId);
|
||||
if (funcId) {
|
||||
const body = node.type === adapter.arrowFuncParent
|
||||
? node // For lexical_declaration, walk the whole thing
|
||||
: node.childForFieldName('body');
|
||||
if (body) walkBody(body, funcId);
|
||||
return;
|
||||
}
|
||||
// Module-level variable (JS/TS only)
|
||||
if (parentId === moduleId && adapter.arrowFuncParent && node.type === adapter.arrowFuncParent) {
|
||||
// Not a function, might be a module-level const
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Java: package_declaration
|
||||
if (lang === 'java' && node.type === 'package_declaration') return;
|
||||
|
||||
// Top-level calls
|
||||
extractCalls(node, parentId);
|
||||
|
||||
for (const child of node.children) {
|
||||
walk(child, parentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk function/method bodies for CALLS only
|
||||
function walkBody(node, parentId) {
|
||||
if (!node) return;
|
||||
extractCalls(node, parentId);
|
||||
for (const child of node.children) {
|
||||
walkBody(child, parentId);
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree.rootNode);
|
||||
|
||||
return { file: filePath, language: lang, entities, relationships };
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
if (require.main === module) {
|
||||
const filePath = process.argv[2];
|
||||
const repoRoot = process.argv[3] || '/app/src';
|
||||
if (!filePath) {
|
||||
console.error("Usage: node extract.js <file> [repo-root]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = extract(filePath, repoRoot);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
module.exports = { extract };
|
||||
278
graph.js
Normal file
278
graph.js
Normal file
@@ -0,0 +1,278 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Developer Intelligence Pipeline v2 - Graph Store
|
||||
* In-memory directed graph using a simple adjacency list.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
class GraphStore {
|
||||
constructor() {
|
||||
this.nodes = new Map(); // entityId -> entity object
|
||||
this.edges = []; // Array of {type, source, target}
|
||||
this._edgeSet = new Set(); // For O(1) dedup
|
||||
this.fileIndex = new Map(); // filePath -> Set of entityIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the graph from an array of extract.js result objects.
|
||||
* @param {Array<Object>} extractResults
|
||||
* @returns {GraphStore}
|
||||
*/
|
||||
static buildGraph(extractResults) {
|
||||
const graph = new GraphStore();
|
||||
|
||||
for (const result of extractResults) {
|
||||
const filePath = result.file;
|
||||
if (!filePath) continue;
|
||||
|
||||
if (!graph.fileIndex.has(filePath)) {
|
||||
graph.fileIndex.set(filePath, new Set());
|
||||
}
|
||||
|
||||
const fileEntities = graph.fileIndex.get(filePath);
|
||||
|
||||
// Add nodes
|
||||
if (Array.isArray(result.entities)) {
|
||||
for (const entity of result.entities) {
|
||||
graph.nodes.set(entity.id, { ...entity, _file: filePath });
|
||||
fileEntities.add(entity.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add edges (deduplicated via Set)
|
||||
if (Array.isArray(result.relationships)) {
|
||||
for (const rel of result.relationships) {
|
||||
const key = `${rel.type}:${rel.source}->${rel.target}`;
|
||||
if (!graph._edgeSet.has(key)) {
|
||||
graph._edgeSet.add(key);
|
||||
graph.edges.push({
|
||||
type: rel.type,
|
||||
source: rel.source,
|
||||
target: rel.target
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the graph to a JSON file.
|
||||
* @param {GraphStore} graph
|
||||
* @param {string} outputPath
|
||||
*/
|
||||
static saveSnapshot(graph, outputPath) {
|
||||
const serialized = {
|
||||
nodes: Object.fromEntries(graph.nodes),
|
||||
edges: graph.edges,
|
||||
fileIndex: Object.fromEntries(
|
||||
Array.from(graph.fileIndex.entries()).map(([k, v]) => [k, Array.from(v)])
|
||||
)
|
||||
};
|
||||
fs.writeFileSync(outputPath, JSON.stringify(serialized, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a JSON file to a GraphStore.
|
||||
* @param {string} inputPath
|
||||
* @returns {GraphStore}
|
||||
*/
|
||||
static loadSnapshot(inputPath) {
|
||||
const data = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
const graph = new GraphStore();
|
||||
|
||||
for (const [id, entity] of Object.entries(data.nodes || {})) {
|
||||
graph.nodes.set(id, entity);
|
||||
}
|
||||
|
||||
graph.edges = data.edges || [];
|
||||
|
||||
for (const [filePath, entityIds] of Object.entries(data.fileIndex || {})) {
|
||||
graph.fileIndex.set(filePath, new Set(entityIds));
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an entity and all its incoming/outgoing edges.
|
||||
* @param {GraphStore} graph
|
||||
* @param {string} entityId
|
||||
* @returns {Object}
|
||||
*/
|
||||
static query(graph, entityId) {
|
||||
const entity = graph.nodes.get(entityId);
|
||||
if (!entity) return null;
|
||||
|
||||
const incoming = graph.edges.filter(e => e.target === entityId);
|
||||
const outgoing = graph.edges.filter(e => e.source === entityId);
|
||||
|
||||
return {
|
||||
entity,
|
||||
incoming,
|
||||
outgoing
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entities that CALL this function.
|
||||
* @param {GraphStore} graph
|
||||
* @param {string} functionName (entityId)
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
static findCallers(graph, functionName) {
|
||||
return graph.edges
|
||||
.filter(e => e.type === 'CALLS' && e.target === functionName)
|
||||
.map(e => graph.nodes.get(e.source))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all modules that IMPORT this module.
|
||||
* @param {GraphStore} graph
|
||||
* @param {string} moduleId (entityId)
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
static findDependents(graph, moduleId) {
|
||||
return graph.edges
|
||||
.filter(e => (e.type === 'IMPORTS' || e.type === 'DEPENDS_ON') && e.target === moduleId)
|
||||
.map(e => graph.nodes.get(e.source))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all public entities in a file.
|
||||
* @param {GraphStore} graph
|
||||
* @param {string} filePath
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
static getExports(graph, filePath) {
|
||||
const entityIds = graph.fileIndex.get(filePath);
|
||||
if (!entityIds) return [];
|
||||
|
||||
return Array.from(entityIds)
|
||||
.map(id => graph.nodes.get(id))
|
||||
.filter(entity => entity && entity.visibility === 'public');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns added/removed/modified entities and relationships between two snapshots.
|
||||
* @param {GraphStore} oldGraph
|
||||
* @param {GraphStore} newGraph
|
||||
* @returns {Object}
|
||||
*/
|
||||
static diffSnapshots(oldGraph, newGraph) {
|
||||
const diff = {
|
||||
entities: { added: [], removed: [], modified: [] },
|
||||
relationships: { added: [], removed: [] }
|
||||
};
|
||||
|
||||
// Diff Entities
|
||||
for (const [id, oldEntity] of oldGraph.nodes.entries()) {
|
||||
if (!newGraph.nodes.has(id)) {
|
||||
diff.entities.removed.push(oldEntity);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, newEntity] of newGraph.nodes.entries()) {
|
||||
const oldEntity = oldGraph.nodes.get(id);
|
||||
if (!oldEntity) {
|
||||
diff.entities.added.push(newEntity);
|
||||
} else {
|
||||
// Deterministic deep comparison: sort keys, compare canonical JSON
|
||||
const canonicalize = (obj) => JSON.stringify(obj, Object.keys(obj).filter(k => k !== '_file').sort());
|
||||
if (canonicalize(oldEntity) !== canonicalize(newEntity)) {
|
||||
diff.entities.modified.push({ old: oldEntity, new: newEntity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diff Relationships
|
||||
const edgeToString = (e) => `${e.type}:${e.source}->${e.target}`;
|
||||
const oldEdges = new Set(oldGraph.edges.map(edgeToString));
|
||||
const newEdges = new Set(newGraph.edges.map(edgeToString));
|
||||
|
||||
for (const e of newGraph.edges) {
|
||||
if (!oldEdges.has(edgeToString(e))) diff.relationships.added.push(e);
|
||||
}
|
||||
for (const e of oldGraph.edges) {
|
||||
if (!newEdges.has(edgeToString(e))) diff.relationships.removed.push(e);
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI handling
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === 'build') {
|
||||
const inputDir = args[1];
|
||||
const outputPath = args[2];
|
||||
|
||||
if (!inputDir || !outputPath) {
|
||||
console.error('Usage: node graph.js build <dir-of-json-files> <output-snapshot.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(inputDir).filter(f => f.endsWith('.json'));
|
||||
const extractResults = files.map(f => {
|
||||
const content = fs.readFileSync(path.join(inputDir, f), 'utf8');
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.error(`Error parsing ${f}:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
const graph = GraphStore.buildGraph(extractResults);
|
||||
GraphStore.saveSnapshot(graph, outputPath);
|
||||
console.log(`Built graph with ${graph.nodes.size} nodes and ${graph.edges.length} edges. Saved to ${outputPath}`);
|
||||
|
||||
} else if (command === 'query') {
|
||||
const snapshotPath = args[1];
|
||||
const entityId = args[2];
|
||||
|
||||
if (!snapshotPath || !entityId) {
|
||||
console.error('Usage: node graph.js query <snapshot.json> <entityId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const graph = GraphStore.loadSnapshot(snapshotPath);
|
||||
const result = GraphStore.query(graph, entityId);
|
||||
|
||||
if (!result) {
|
||||
console.log(`Entity ${entityId} not found.`);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
} else if (command === 'diff') {
|
||||
const oldSnapshotPath = args[1];
|
||||
const newSnapshotPath = args[2];
|
||||
|
||||
if (!oldSnapshotPath || !newSnapshotPath) {
|
||||
console.error('Usage: node graph.js diff <old-snapshot.json> <new-snapshot.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldGraph = GraphStore.loadSnapshot(oldSnapshotPath);
|
||||
const newGraph = GraphStore.loadSnapshot(newSnapshotPath);
|
||||
|
||||
const diff = GraphStore.diffSnapshots(oldGraph, newGraph);
|
||||
console.log(JSON.stringify(diff, null, 2));
|
||||
|
||||
} else {
|
||||
console.error('Unknown command. Available commands: build, query, diff');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GraphStore;
|
||||
291
namespace.js
Normal file
291
namespace.js
Normal file
@@ -0,0 +1,291 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GraphStore = require('./graph.js');
|
||||
|
||||
/**
|
||||
* Developer Intelligence Pipeline v2 - Cross-Repo Namespace Registry
|
||||
* Resolves cross-repo references using 3-tier matching.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
const SCRIPT_DIR = __dirname;
|
||||
|
||||
/**
|
||||
* Classify an entity into an artifact type for infrastructure-level matching.
|
||||
* Supports: rest-api, grpc-service, helm-chart, terraform-resource, config, code-module
|
||||
*/
|
||||
function classifyArtifact(entity) {
|
||||
if (entity.type === 'Config') {
|
||||
if (entity.kind === 'terraform' || entity.kind === 'hcl-block') return 'terraform-resource';
|
||||
if (entity.kind === 'yaml-config' || entity.kind === 'yaml-key') return 'config';
|
||||
return 'config';
|
||||
}
|
||||
if (entity.type === 'Class' && entity.kind === 'interface') return 'interface';
|
||||
if (entity.type === 'Class') return 'class';
|
||||
if (entity.type === 'Function') return 'code-module';
|
||||
if (entity.type === 'Module') return 'code-module';
|
||||
return 'code-module';
|
||||
}
|
||||
|
||||
class NamespaceRegistry {
|
||||
constructor() {
|
||||
this.byShortName = new Map(); // shortName -> [{repoId, entityId, type, kind}]
|
||||
this.byEntityId = new Map(); // entityId -> {repoId, shortName}
|
||||
this.overrides = new Map(); // localName -> {repoId, entityId}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build registry from multiple graph snapshots.
|
||||
* Collects public entities and indexes them for cross-repo resolution.
|
||||
* @param {Array<{repoId: string, snapshot: GraphStore}>} repos
|
||||
* @returns {NamespaceRegistry}
|
||||
*/
|
||||
static build(repos) {
|
||||
const reg = new NamespaceRegistry();
|
||||
|
||||
for (const { repoId, snapshot } of repos) {
|
||||
for (const [id, entity] of snapshot.nodes.entries()) {
|
||||
if (entity.visibility !== 'public') continue;
|
||||
if (entity.type === 'Dependency') continue;
|
||||
|
||||
const shortName = entity.name;
|
||||
const entry = {
|
||||
repoId,
|
||||
entityId: id,
|
||||
type: entity.type,
|
||||
kind: entity.kind,
|
||||
// Artifact classification for infrastructure matching
|
||||
artifact: classifyArtifact(entity),
|
||||
};
|
||||
|
||||
// byShortName
|
||||
if (!reg.byShortName.has(shortName)) {
|
||||
reg.byShortName.set(shortName, []);
|
||||
}
|
||||
reg.byShortName.get(shortName).push(entry);
|
||||
|
||||
// byEntityId (prefix with repoId for cross-repo uniqueness)
|
||||
reg.byEntityId.set(`${repoId}:${id}`, { repoId, shortName, artifact: entry.artifact });
|
||||
}
|
||||
}
|
||||
|
||||
return reg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load overrides from a JSON file.
|
||||
* @param {string} overridePath
|
||||
*/
|
||||
loadOverrides(overridePath) {
|
||||
if (!fs.existsSync(overridePath)) return;
|
||||
const data = JSON.parse(fs.readFileSync(overridePath, 'utf8'));
|
||||
for (const [localName, target] of Object.entries(data)) {
|
||||
const colonIdx = target.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
this.overrides.set(localName, {
|
||||
repoId: target.slice(0, colonIdx),
|
||||
entityId: target.slice(colonIdx + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a name using 3-tier matching.
|
||||
* @param {string} name - The unresolved target name
|
||||
* @param {string} [sourceRepoId] - The repo making the call (excluded from results)
|
||||
* @returns {{resolvedTo: {repoId, entityId}, tier: number, confidence: number} | null}
|
||||
*/
|
||||
resolve(name, sourceRepoId) {
|
||||
// Override always wins
|
||||
if (this.overrides.has(name)) {
|
||||
const target = this.overrides.get(name);
|
||||
return { resolvedTo: target, tier: 0, confidence: 1.0 };
|
||||
}
|
||||
|
||||
// Tier 1: Exact entity ID match
|
||||
for (const [key, val] of this.byEntityId.entries()) {
|
||||
const entityId = key.slice(key.indexOf(':') + 1);
|
||||
if (entityId === name && val.repoId !== sourceRepoId) {
|
||||
return { resolvedTo: { repoId: val.repoId, entityId }, tier: 1, confidence: 1.0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Normalized match (strip extensions, normalize paths)
|
||||
const normalized = name.replace(/\.(ts|js|tsx|jsx|py|java|go|sh)$/, '').replace(/\\/g, '/');
|
||||
for (const [key, val] of this.byEntityId.entries()) {
|
||||
const entityId = key.slice(key.indexOf(':') + 1);
|
||||
const normId = entityId.replace(/\.(ts|js|tsx|jsx|py|java|go|sh)/, '').replace(/\\/g, '/');
|
||||
if (normId === normalized && val.repoId !== sourceRepoId) {
|
||||
return { resolvedTo: { repoId: val.repoId, entityId }, tier: 2, confidence: 0.9 };
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: Name-only match
|
||||
const matches = (this.byShortName.get(name) || []).filter(e => e.repoId !== sourceRepoId);
|
||||
if (matches.length === 1) {
|
||||
return { resolvedTo: { repoId: matches[0].repoId, entityId: matches[0].entityId }, tier: 3, confidence: 0.7 };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
// Ambiguous — return first match with lower confidence
|
||||
return { resolvedTo: { repoId: matches[0].repoId, entityId: matches[0].entityId }, tier: 3, confidence: 0.5 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all unresolved CALLS edges in a graph.
|
||||
* @param {GraphStore} graph
|
||||
* @param {NamespaceRegistry} registry
|
||||
* @param {string} sourceRepoId
|
||||
* @returns {Array<{source, target, resolvedTo, tier, confidence}>}
|
||||
*/
|
||||
static resolveExternalCalls(graph, registry, sourceRepoId) {
|
||||
const results = [];
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.type !== 'CALLS') continue;
|
||||
// If target exists as a node, it's internal — skip
|
||||
if (graph.nodes.has(edge.target)) continue;
|
||||
|
||||
const resolution = registry.resolve(edge.target, sourceRepoId);
|
||||
if (resolution) {
|
||||
results.push({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
resolvedTo: resolution.resolvedTo,
|
||||
tier: resolution.tier,
|
||||
confidence: resolution.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize registry to JSON.
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
byShortName: Object.fromEntries(this.byShortName),
|
||||
byEntityId: Object.fromEntries(this.byEntityId),
|
||||
overrides: Object.fromEntries(this.overrides),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize registry from JSON.
|
||||
*/
|
||||
static fromJSON(data) {
|
||||
const reg = new NamespaceRegistry();
|
||||
for (const [k, v] of Object.entries(data.byShortName || {})) {
|
||||
reg.byShortName.set(k, v);
|
||||
}
|
||||
for (const [k, v] of Object.entries(data.byEntityId || {})) {
|
||||
reg.byEntityId.set(k, v);
|
||||
}
|
||||
for (const [k, v] of Object.entries(data.overrides || {})) {
|
||||
reg.overrides.set(k, v);
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a name in the registry.
|
||||
*/
|
||||
lookup(name) {
|
||||
const exact = this.byShortName.get(name) || [];
|
||||
// Also check entity IDs containing the name
|
||||
const byId = [];
|
||||
for (const [key, val] of this.byEntityId.entries()) {
|
||||
const entityId = key.slice(key.indexOf(':') + 1);
|
||||
if (entityId.includes(name)) {
|
||||
byId.push({ ...val, entityId });
|
||||
}
|
||||
}
|
||||
return { byName: exact, byId };
|
||||
}
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === 'build') {
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx >= 0 ? args[outputIdx + 1] : null;
|
||||
const snapshotPaths = args.slice(1).filter((_, i) => {
|
||||
const argIdx = i + 1;
|
||||
return argIdx !== outputIdx && argIdx !== outputIdx + 1;
|
||||
});
|
||||
|
||||
if (snapshotPaths.length === 0 || !outputPath) {
|
||||
console.error('Usage: node namespace.js build <snapshot1.json> [snapshot2.json ...] --output <registry.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repos = snapshotPaths.map((p, i) => {
|
||||
const snapshot = GraphStore.loadSnapshot(p);
|
||||
const repoId = path.basename(p, '.json');
|
||||
return { repoId, snapshot };
|
||||
});
|
||||
|
||||
const registry = NamespaceRegistry.build(repos);
|
||||
|
||||
// Load overrides if present
|
||||
const overridePath = path.join(path.dirname(outputPath), 'namespace-overrides.json');
|
||||
registry.loadOverrides(overridePath);
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(registry.toJSON(), null, 2), 'utf8');
|
||||
console.log(`Registry built: ${registry.byShortName.size} names, ${registry.byEntityId.size} entities from ${repos.length} repos. Saved to ${outputPath}`);
|
||||
|
||||
} else if (command === 'resolve') {
|
||||
const graphPath = args[1];
|
||||
const registryPath = args[2];
|
||||
|
||||
if (!graphPath || !registryPath) {
|
||||
console.error('Usage: node namespace.js resolve <graph-snapshot.json> <registry.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const graph = GraphStore.loadSnapshot(graphPath);
|
||||
const regData = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
const registry = NamespaceRegistry.fromJSON(regData);
|
||||
const sourceRepoId = path.basename(graphPath, '.json');
|
||||
|
||||
const results = NamespaceRegistry.resolveExternalCalls(graph, registry, sourceRepoId);
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log('No external calls resolved.');
|
||||
} else {
|
||||
console.log(`Resolved ${results.length} external call(s):`);
|
||||
for (const r of results) {
|
||||
console.log(` ${r.source} -> ${r.target} => ${r.resolvedTo.repoId}:${r.resolvedTo.entityId} (tier ${r.tier}, confidence ${r.confidence})`);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (command === 'lookup') {
|
||||
const registryPath = args[1];
|
||||
const name = args[2];
|
||||
|
||||
if (!registryPath || !name) {
|
||||
console.error('Usage: node namespace.js lookup <registry.json> <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const regData = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
const registry = NamespaceRegistry.fromJSON(regData);
|
||||
const result = registry.lookup(name);
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
} else {
|
||||
console.error('Unknown command. Available: build, resolve, lookup');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NamespaceRegistry;
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "dev-intel-v2",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tree-sitter-grammars/tree-sitter-hcl": "^1.2.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-bash": "^0.21.0",
|
||||
"tree-sitter-go": "^0.21.2",
|
||||
"tree-sitter-java": "^0.21.0",
|
||||
"tree-sitter-javascript": "^0.21.2",
|
||||
"tree-sitter-python": "^0.21.0",
|
||||
"tree-sitter-typescript": "^0.21.1",
|
||||
"tree-sitter-yaml": "^0.5.0",
|
||||
"web-tree-sitter": "^0.26.6"
|
||||
}
|
||||
}
|
||||
256
pipeline.js
Normal file
256
pipeline.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GraphStore = require('./graph.js');
|
||||
const { extract } = require('./extract.js');
|
||||
const { semanticDiff, formatSummary } = require('./semantic-diff.js');
|
||||
|
||||
/**
|
||||
* Developer Intelligence Pipeline v2 - Pipeline Orchestrator
|
||||
* Batch extraction, incremental diffing, and benchmarking.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
const SUPPORTED_EXTS = new Set([
|
||||
'.ts', '.tsx', '.js', '.jsx', '.py', '.java', '.go', '.sh', '.bash',
|
||||
'.yaml', '.yml', '.tf', '.hcl',
|
||||
]);
|
||||
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', '__pycache__', '.next',
|
||||
'.turbo', 'coverage', '.nyc_output', 'vendor',
|
||||
]);
|
||||
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const EXTRACT_JS = path.join(SCRIPT_DIR, 'extract.js');
|
||||
|
||||
/**
|
||||
* Recursively discover supported files.
|
||||
*/
|
||||
function discoverFiles(dir) {
|
||||
const results = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch { return results; }
|
||||
|
||||
for (const entry of entries) {
|
||||
if (IGNORE_DIRS.has(entry.name)) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...discoverFiles(fullPath));
|
||||
} else if (entry.isFile() && SUPPORTED_EXTS.has(path.extname(entry.name))) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single file using in-process extract(), no subprocess.
|
||||
*/
|
||||
function extractFile(filePath, repoRoot) {
|
||||
try {
|
||||
return extract(filePath, repoRoot);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch extract all files, build graph, save snapshot.
|
||||
*/
|
||||
function batchExtract(repoRoot, outputDir) {
|
||||
const files = discoverFiles(repoRoot);
|
||||
console.log(`Discovered ${files.length} supported files in ${repoRoot}`);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
let errors = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const result = extractFile(files[i], repoRoot);
|
||||
if (result && !result.error) {
|
||||
results.push(result);
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
if ((i + 1) % 100 === 0) {
|
||||
console.log(` Extracted ${i + 1}/${files.length}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const extractTime = Date.now() - startTime;
|
||||
console.log(`Extraction complete: ${results.length} succeeded, ${errors} failed (${extractTime}ms)`);
|
||||
|
||||
const graph = GraphStore.buildGraph(results);
|
||||
const snapshotPath = path.join(outputDir, 'snapshot.json');
|
||||
GraphStore.saveSnapshot(graph, snapshotPath);
|
||||
console.log(`Graph: ${graph.nodes.size} nodes, ${graph.edges.length} edges. Saved to ${snapshotPath}`);
|
||||
|
||||
// Save stats
|
||||
const stats = {
|
||||
repoRoot,
|
||||
filesDiscovered: files.length,
|
||||
filesExtracted: results.length,
|
||||
errors,
|
||||
nodes: graph.nodes.size,
|
||||
edges: graph.edges.length,
|
||||
extractionTimeMs: extractTime,
|
||||
avgTimePerFileMs: Math.round(extractTime / files.length),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(path.join(outputDir, 'stats.json'), JSON.stringify(stats, null, 2));
|
||||
console.log(`Stats saved. Avg ${stats.avgTimePerFileMs}ms/file`);
|
||||
|
||||
return { graph, snapshotPath, stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental run: extract files, diff against previous snapshot.
|
||||
*/
|
||||
function incrementalRun(repoRoot, files, prevSnapshotPath, outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const filesToExtract = files || discoverFiles(repoRoot);
|
||||
console.log(`Extracting ${filesToExtract.length} files...`);
|
||||
|
||||
const results = [];
|
||||
let errors = 0;
|
||||
|
||||
for (const f of filesToExtract) {
|
||||
const result = extractFile(f, repoRoot);
|
||||
if (result && !result.error) {
|
||||
results.push(result);
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
const newGraph = GraphStore.buildGraph(results);
|
||||
const newSnapshotPath = path.join(outputDir, 'snapshot.json');
|
||||
GraphStore.saveSnapshot(newGraph, newSnapshotPath);
|
||||
console.log(`New graph: ${newGraph.nodes.size} nodes, ${newGraph.edges.length} edges`);
|
||||
|
||||
if (prevSnapshotPath && fs.existsSync(prevSnapshotPath)) {
|
||||
const oldGraph = GraphStore.loadSnapshot(prevSnapshotPath);
|
||||
const diff = semanticDiff(oldGraph, newGraph);
|
||||
console.log(formatSummary(diff));
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, 'diff.json'), JSON.stringify({
|
||||
score: diff.score,
|
||||
severity: diff.severity,
|
||||
stats: diff.stats,
|
||||
categorized: diff.categorized,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
return { newSnapshotPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: extract N random files, report timing.
|
||||
*/
|
||||
function benchmark(repoRoot, sampleCount) {
|
||||
const allFiles = discoverFiles(repoRoot);
|
||||
console.log(`Total supported files: ${allFiles.length}`);
|
||||
|
||||
// Shuffle and pick N
|
||||
const shuffled = allFiles.sort(() => Math.random() - 0.5);
|
||||
const samples = shuffled.slice(0, Math.min(sampleCount, allFiles.length));
|
||||
console.log(`Benchmarking ${samples.length} files...\n`);
|
||||
|
||||
const timings = [];
|
||||
let totalEntities = 0;
|
||||
let totalRelationships = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const file of samples) {
|
||||
const start = Date.now();
|
||||
const result = extractFile(file, repoRoot);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
if (result && !result.error) {
|
||||
timings.push({ file: path.relative(repoRoot, file), timeMs: elapsed, entities: result.entities.length, relationships: result.relationships.length });
|
||||
totalEntities += result.entities.length;
|
||||
totalRelationships += result.relationships.length;
|
||||
} else {
|
||||
errors++;
|
||||
timings.push({ file: path.relative(repoRoot, file), timeMs: elapsed, entities: 0, relationships: 0, error: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time descending
|
||||
timings.sort((a, b) => b.timeMs - a.timeMs);
|
||||
|
||||
const totalTime = timings.reduce((s, t) => s + t.timeMs, 0);
|
||||
const avgTime = Math.round(totalTime / timings.length);
|
||||
const p50 = timings[Math.floor(timings.length * 0.5)]?.timeMs || 0;
|
||||
const p95 = timings[Math.floor(timings.length * 0.05)]?.timeMs || 0;
|
||||
|
||||
console.log('=== V2 Pipeline Benchmark ===');
|
||||
console.log(`Repo: ${repoRoot}`);
|
||||
console.log(`Files sampled: ${samples.length} / ${allFiles.length}`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
console.log(`Total entities: ${totalEntities}`);
|
||||
console.log(`Total relationships: ${totalRelationships}`);
|
||||
console.log(`Total time: ${totalTime}ms`);
|
||||
console.log(`Avg time/file: ${avgTime}ms`);
|
||||
console.log(`P50: ${p50}ms | P95: ${p95}ms`);
|
||||
console.log('');
|
||||
console.log('Top 5 slowest:');
|
||||
for (const t of timings.slice(0, 5)) {
|
||||
console.log(` ${t.timeMs}ms ${t.file} (${t.entities}E/${t.relationships}R)${t.error ? ' ERROR' : ''}`);
|
||||
}
|
||||
|
||||
return { totalFiles: allFiles.length, sampled: samples.length, errors, totalEntities, totalRelationships, totalTime, avgTime, p50, p95 };
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === 'batch') {
|
||||
const repoRoot = args[1];
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputDir = outputIdx >= 0 ? args[outputIdx + 1] : '/tmp/pipeline-output';
|
||||
|
||||
if (!repoRoot) {
|
||||
console.error('Usage: node pipeline.js batch <repo-root> --output <dir>');
|
||||
process.exit(1);
|
||||
}
|
||||
batchExtract(repoRoot, outputDir);
|
||||
|
||||
} else if (command === 'run') {
|
||||
const repoRoot = args[1];
|
||||
const snapshotIdx = args.indexOf('--snapshot');
|
||||
const prevSnapshot = snapshotIdx >= 0 ? args[snapshotIdx + 1] : null;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputDir = outputIdx >= 0 ? args[outputIdx + 1] : '/tmp/pipeline-output';
|
||||
|
||||
if (!repoRoot) {
|
||||
console.error('Usage: node pipeline.js run <repo-root> [--snapshot <prev.json>] [--output <dir>]');
|
||||
process.exit(1);
|
||||
}
|
||||
incrementalRun(repoRoot, null, prevSnapshot, outputDir);
|
||||
|
||||
} else if (command === 'benchmark') {
|
||||
const repoRoot = args[1];
|
||||
const samplesIdx = args.indexOf('--samples');
|
||||
const sampleCount = samplesIdx >= 0 ? parseInt(args[samplesIdx + 1], 10) : 10;
|
||||
|
||||
if (!repoRoot) {
|
||||
console.error('Usage: node pipeline.js benchmark <repo-root> --samples <N>');
|
||||
process.exit(1);
|
||||
}
|
||||
benchmark(repoRoot, sampleCount);
|
||||
|
||||
} else {
|
||||
console.error('Unknown command. Available: batch, run, benchmark');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { discoverFiles, extractFile, batchExtract, incrementalRun, benchmark };
|
||||
328
semantic-diff.js
Normal file
328
semantic-diff.js
Normal file
@@ -0,0 +1,328 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GraphStore = require('./graph.js');
|
||||
|
||||
/**
|
||||
* Developer Intelligence Pipeline v2 - Semantic Diff Engine
|
||||
* Compares two graph snapshots and produces categorized, scored diffs.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
const SEVERITY = [
|
||||
[0, 20, 'trivial'],
|
||||
[21, 40, 'low'],
|
||||
[41, 60, 'moderate'],
|
||||
[61, 80, 'high'],
|
||||
[81, 100, 'critical'],
|
||||
];
|
||||
|
||||
function severityLabel(score) {
|
||||
for (const [lo, hi, label] of SEVERITY) {
|
||||
if (score >= lo && score <= hi) return label;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize a single entity change by impact level.
|
||||
*/
|
||||
function categorizeEntityChange(changeType, entity, oldEntity) {
|
||||
const isPublic = (e) => e && e.visibility === 'public';
|
||||
|
||||
if (changeType === 'removed' && isPublic(entity)) return 'breaking';
|
||||
if (changeType === 'added' && isPublic(entity)) return 'significant';
|
||||
if (changeType === 'modified') {
|
||||
// Check if only line_range changed (cosmetic)
|
||||
if (oldEntity && entity) {
|
||||
const oKeys = Object.keys(oldEntity).filter(k => k !== '_file' && k !== 'line_range');
|
||||
const nKeys = Object.keys(entity).filter(k => k !== '_file' && k !== 'line_range');
|
||||
const sameSemantics = oKeys.length === nKeys.length &&
|
||||
oKeys.every(k => JSON.stringify(oldEntity[k]) === JSON.stringify(entity[k]));
|
||||
if (sameSemantics) return 'cosmetic';
|
||||
}
|
||||
if (isPublic(entity) || isPublic(oldEntity)) return 'significant';
|
||||
return 'internal';
|
||||
}
|
||||
if (changeType === 'added' || changeType === 'removed') return 'internal';
|
||||
return 'internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize a relationship change.
|
||||
*/
|
||||
function categorizeRelChange(changeType, rel, graph) {
|
||||
// Check if source or target is public
|
||||
const sourceNode = graph ? graph.nodes.get(rel.source) : null;
|
||||
const targetNode = graph ? graph.nodes.get(rel.target) : null;
|
||||
const involvesPublic = (sourceNode && sourceNode.visibility === 'public') ||
|
||||
(targetNode && targetNode.visibility === 'public');
|
||||
|
||||
if (changeType === 'removed' && involvesPublic) return 'breaking';
|
||||
if (involvesPublic) return 'significant';
|
||||
return 'internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute semantic diff between two graph snapshots.
|
||||
*/
|
||||
function semanticDiff(oldGraph, newGraph) {
|
||||
const rawDiff = GraphStore.diffSnapshots(oldGraph, newGraph);
|
||||
|
||||
const categorized = {
|
||||
breaking: [],
|
||||
significant: [],
|
||||
internal: [],
|
||||
cosmetic: [],
|
||||
};
|
||||
|
||||
// Categorize entity changes
|
||||
for (const entity of rawDiff.entities.added) {
|
||||
const cat = categorizeEntityChange('added', entity, null);
|
||||
categorized[cat].push({ change: 'added', entity });
|
||||
}
|
||||
for (const entity of rawDiff.entities.removed) {
|
||||
const cat = categorizeEntityChange('removed', entity, null);
|
||||
categorized[cat].push({ change: 'removed', entity });
|
||||
}
|
||||
for (const { old: oldE, new: newE } of rawDiff.entities.modified) {
|
||||
const cat = categorizeEntityChange('modified', newE, oldE);
|
||||
categorized[cat].push({ change: 'modified', old: oldE, new: newE });
|
||||
}
|
||||
|
||||
// Categorize relationship changes
|
||||
for (const rel of rawDiff.relationships.added) {
|
||||
const cat = categorizeRelChange('added', rel, newGraph);
|
||||
categorized[cat].push({ change: 'rel-added', rel });
|
||||
}
|
||||
for (const rel of rawDiff.relationships.removed) {
|
||||
const cat = categorizeRelChange('removed', rel, oldGraph);
|
||||
categorized[cat].push({ change: 'rel-removed', rel });
|
||||
}
|
||||
|
||||
// Impact score
|
||||
const score = computeScore(categorized);
|
||||
|
||||
// Stats
|
||||
const filesChanged = new Set();
|
||||
for (const e of [...rawDiff.entities.added, ...rawDiff.entities.removed]) {
|
||||
if (e._file) filesChanged.add(e._file);
|
||||
}
|
||||
for (const { old: o, new: n } of rawDiff.entities.modified) {
|
||||
if (o._file) filesChanged.add(o._file);
|
||||
if (n._file) filesChanged.add(n._file);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
filesChanged: filesChanged.size,
|
||||
entitiesAdded: rawDiff.entities.added.length,
|
||||
entitiesRemoved: rawDiff.entities.removed.length,
|
||||
entitiesModified: rawDiff.entities.modified.length,
|
||||
relationshipsAdded: rawDiff.relationships.added.length,
|
||||
relationshipsRemoved: rawDiff.relationships.removed.length,
|
||||
};
|
||||
|
||||
// Impact analysis: find callers of removed/modified entities
|
||||
const impactAnalysis = computeImpactAnalysis(categorized, oldGraph, newGraph);
|
||||
|
||||
return { categorized, score, severity: severityLabel(score), stats, impactAnalysis, rawDiff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute impact analysis: who calls the things that changed?
|
||||
*/
|
||||
function computeImpactAnalysis(categorized, oldGraph, newGraph) {
|
||||
const impacted = { callers: [], dependents: [] };
|
||||
|
||||
// For breaking/significant changes, find callers in the OLD graph
|
||||
const changedIds = new Set();
|
||||
for (const item of [...categorized.breaking, ...categorized.significant]) {
|
||||
if (item.entity) changedIds.add(item.entity.id);
|
||||
if (item.old) changedIds.add(item.old.id);
|
||||
if (item.new) changedIds.add(item.new.id);
|
||||
}
|
||||
|
||||
for (const id of changedIds) {
|
||||
// Find callers in old graph (who depends on this?)
|
||||
const callers = oldGraph.edges
|
||||
.filter(e => e.type === 'CALLS' && e.target === id)
|
||||
.map(e => e.source);
|
||||
if (callers.length > 0) {
|
||||
impacted.callers.push({ entityId: id, calledBy: [...new Set(callers)] });
|
||||
}
|
||||
|
||||
// Find dependents (who imports the module this belongs to?)
|
||||
const entity = oldGraph.nodes.get(id);
|
||||
if (entity && entity._file) {
|
||||
const moduleId = [...oldGraph.fileIndex.entries()]
|
||||
.find(([fp]) => fp === entity._file)?.[1];
|
||||
if (moduleId) {
|
||||
const deps = oldGraph.edges
|
||||
.filter(e => e.type === 'IMPORTS' && [...moduleId].includes(e.target.replace('dep:', '')))
|
||||
.map(e => e.source);
|
||||
if (deps.length > 0) {
|
||||
impacted.dependents.push({ entityId: id, importedBy: [...new Set(deps)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return impacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute impact score (0-100).
|
||||
* Additive weighted score, capped at 100.
|
||||
*/
|
||||
function computeScore(categorized) {
|
||||
const b = categorized.breaking.length;
|
||||
const s = categorized.significant.length;
|
||||
const i = categorized.internal.length;
|
||||
const c = categorized.cosmetic.length;
|
||||
|
||||
// Each change contributes its weight directly; cap at 100
|
||||
const raw = b * 40 + s * 30 + i * 20 + c * 10;
|
||||
return Math.min(100, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* File-scoped diff: only entities belonging to a specific file.
|
||||
*/
|
||||
function diffFiles(oldGraph, newGraph, filePath) {
|
||||
// Build scoped graphs containing only entities from the target file
|
||||
const scopeGraph = (graph) => {
|
||||
const scoped = new GraphStore();
|
||||
const entityIds = graph.fileIndex.get(filePath);
|
||||
if (!entityIds) return scoped;
|
||||
|
||||
for (const id of entityIds) {
|
||||
const entity = graph.nodes.get(id);
|
||||
if (entity) scoped.nodes.set(id, entity);
|
||||
}
|
||||
for (const edge of graph.edges) {
|
||||
if (entityIds.has(edge.source) || entityIds.has(edge.target)) {
|
||||
scoped.edges.push(edge);
|
||||
}
|
||||
}
|
||||
scoped.fileIndex.set(filePath, new Set(entityIds));
|
||||
return scoped;
|
||||
};
|
||||
|
||||
return semanticDiff(scopeGraph(oldGraph), scopeGraph(newGraph));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable summary.
|
||||
*/
|
||||
function formatSummary(diff) {
|
||||
const lines = [];
|
||||
lines.push(`=== Semantic Diff Summary ===`);
|
||||
lines.push(`Impact Score: ${diff.score}/100 (${diff.severity})`);
|
||||
lines.push(`Files Changed: ${diff.stats.filesChanged}`);
|
||||
lines.push(`Entities: +${diff.stats.entitiesAdded} -${diff.stats.entitiesRemoved} ~${diff.stats.entitiesModified}`);
|
||||
lines.push(`Relationships: +${diff.stats.relationshipsAdded} -${diff.stats.relationshipsRemoved}`);
|
||||
lines.push('');
|
||||
|
||||
if (diff.categorized.breaking.length > 0) {
|
||||
lines.push(`⛔ BREAKING CHANGES (${diff.categorized.breaking.length}):`);
|
||||
for (const item of diff.categorized.breaking) {
|
||||
if (item.entity) lines.push(` ${item.change}: ${item.entity.id} (${item.entity.type})`);
|
||||
if (item.rel) lines.push(` ${item.change}: ${item.rel.type} ${item.rel.source} -> ${item.rel.target}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.categorized.significant.length > 0) {
|
||||
lines.push(`⚠️ SIGNIFICANT CHANGES (${diff.categorized.significant.length}):`);
|
||||
for (const item of diff.categorized.significant) {
|
||||
if (item.entity) lines.push(` ${item.change}: ${item.entity.id} (${item.entity.type})`);
|
||||
if (item.old && item.new) lines.push(` modified: ${item.new.id} (${item.new.type})`);
|
||||
if (item.rel) lines.push(` ${item.change}: ${item.rel.type} ${item.rel.source} -> ${item.rel.target}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.categorized.internal.length > 0) {
|
||||
lines.push(`ℹ️ INTERNAL CHANGES (${diff.categorized.internal.length}):`);
|
||||
for (const item of diff.categorized.internal) {
|
||||
if (item.entity) lines.push(` ${item.change}: ${item.entity.id}`);
|
||||
if (item.old && item.new) lines.push(` modified: ${item.new.id}`);
|
||||
if (item.rel) lines.push(` ${item.change}: ${item.rel.type} ${item.rel.source} -> ${item.rel.target}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.categorized.cosmetic.length > 0) {
|
||||
lines.push(`💅 COSMETIC CHANGES (${diff.categorized.cosmetic.length}):`);
|
||||
for (const item of diff.categorized.cosmetic) {
|
||||
if (item.old && item.new) lines.push(` moved: ${item.new.id} (lines ${item.old.line_range} -> ${item.new.line_range})`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Impact analysis
|
||||
if (diff.impactAnalysis) {
|
||||
const { callers, dependents } = diff.impactAnalysis;
|
||||
if (callers.length > 0 || dependents.length > 0) {
|
||||
lines.push(`🔍 IMPACT ANALYSIS:`);
|
||||
for (const c of callers) {
|
||||
lines.push(` ${c.entityId} is called by: ${c.calledBy.join(', ')}`);
|
||||
}
|
||||
for (const d of dependents) {
|
||||
lines.push(` ${d.entityId} is imported by: ${d.importedBy.join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- CLI ---
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === 'diff') {
|
||||
const oldPath = args[1];
|
||||
const newPath = args[2];
|
||||
const fileIdx = args.indexOf('--file');
|
||||
const filePath = fileIdx >= 0 ? args[fileIdx + 1] : null;
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
console.error('Usage: node semantic-diff.js diff <old.json> <new.json> [--file <path>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldGraph = GraphStore.loadSnapshot(oldPath);
|
||||
const newGraph = GraphStore.loadSnapshot(newPath);
|
||||
|
||||
const diff = filePath
|
||||
? diffFiles(oldGraph, newGraph, filePath)
|
||||
: semanticDiff(oldGraph, newGraph);
|
||||
|
||||
console.log(formatSummary(diff));
|
||||
console.log('--- Raw JSON ---');
|
||||
console.log(JSON.stringify({ categorized: diff.categorized, score: diff.score, severity: diff.severity, stats: diff.stats }, null, 2));
|
||||
|
||||
} else if (command === 'score') {
|
||||
const oldPath = args[1];
|
||||
const newPath = args[2];
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
console.error('Usage: node semantic-diff.js score <old.json> <new.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldGraph = GraphStore.loadSnapshot(oldPath);
|
||||
const newGraph = GraphStore.loadSnapshot(newPath);
|
||||
const diff = semanticDiff(oldGraph, newGraph);
|
||||
|
||||
console.log(`${diff.score} (${diff.severity})`);
|
||||
|
||||
} else {
|
||||
console.error('Unknown command. Available: diff, score');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { semanticDiff, diffFiles, formatSummary, computeScore };
|
||||
92
test/ground-truth/bash-deploy.json
Normal file
92
test/ground-truth/bash-deploy.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"file": "/tmp/deploy.sh",
|
||||
"language": "bash",
|
||||
"entities": [
|
||||
{
|
||||
"id": "deploy.sh",
|
||||
"type": "Module",
|
||||
"name": "deploy.sh",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:./utils.sh",
|
||||
"type": "Dependency",
|
||||
"name": "./utils.sh",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
2,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deploy.sh:build_image",
|
||||
"type": "Function",
|
||||
"name": "build_image",
|
||||
"kind": "function",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
4,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deploy.sh:deploy_k8s",
|
||||
"type": "Function",
|
||||
"name": "deploy_k8s",
|
||||
"kind": "function",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
10,
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "deploy.sh",
|
||||
"target": "dep:./utils.sh"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "deploy.sh",
|
||||
"target": "deploy.sh:build_image"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "deploy.sh:build_image",
|
||||
"target": "docker"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "deploy.sh",
|
||||
"target": "deploy.sh:deploy_k8s"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "deploy.sh:deploy_k8s",
|
||||
"target": "kubectl"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "deploy.sh",
|
||||
"target": "echo"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "deploy.sh",
|
||||
"target": "build_image"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "deploy.sh",
|
||||
"target": "deploy_k8s"
|
||||
}
|
||||
]
|
||||
}
|
||||
98
test/ground-truth/go-server.json
Normal file
98
test/ground-truth/go-server.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"file": "/tmp/test_go.go",
|
||||
"language": "go",
|
||||
"entities": [
|
||||
{
|
||||
"id": "test_go.go",
|
||||
"type": "Module",
|
||||
"name": "test_go.go",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:fmt",
|
||||
"type": "Dependency",
|
||||
"name": "fmt",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
4,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:net/http",
|
||||
"type": "Dependency",
|
||||
"name": "net/http",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
5,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_go.go:Start",
|
||||
"type": "Function",
|
||||
"name": "Start",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
12,
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_go.go:main",
|
||||
"type": "Function",
|
||||
"name": "main",
|
||||
"kind": "function",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
17,
|
||||
20
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_go.go",
|
||||
"target": "dep:fmt"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_go.go",
|
||||
"target": "dep:net/http"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_go.go",
|
||||
"target": "test_go.go:Start"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_go.go:Start",
|
||||
"target": "fmt.Println"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_go.go:Start",
|
||||
"target": "http.ListenAndServe"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_go.go",
|
||||
"target": "test_go.go:main"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_go.go:main",
|
||||
"target": "s.Start"
|
||||
}
|
||||
]
|
||||
}
|
||||
130
test/ground-truth/java-service.json
Normal file
130
test/ground-truth/java-service.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"file": "/tmp/TestJava.java",
|
||||
"language": "java",
|
||||
"entities": [
|
||||
{
|
||||
"id": "TestJava.java",
|
||||
"type": "Module",
|
||||
"name": "TestJava.java",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
22
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:java.util.List",
|
||||
"type": "Dependency",
|
||||
"name": "java.util.List",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:org.springframework.stereotype.Service",
|
||||
"type": "Dependency",
|
||||
"name": "org.springframework.stereotype.Service",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
4,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TestJava.java:TenantService",
|
||||
"type": "Class",
|
||||
"name": "TenantService",
|
||||
"kind": "class",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
6,
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TestJava.java:TenantService:TenantService",
|
||||
"type": "Function",
|
||||
"name": "TenantService",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
10,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TestJava.java:TenantService:getTenants",
|
||||
"type": "Function",
|
||||
"name": "getTenants",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
14,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TestJava.java:TenantService:audit",
|
||||
"type": "Function",
|
||||
"name": "audit",
|
||||
"kind": "method",
|
||||
"visibility": "private",
|
||||
"line_range": [
|
||||
18,
|
||||
20
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "TestJava.java",
|
||||
"target": "dep:java.util.List"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "TestJava.java",
|
||||
"target": "dep:org.springframework.stereotype.Service"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "TestJava.java",
|
||||
"target": "TestJava.java:TenantService"
|
||||
},
|
||||
{
|
||||
"type": "IMPLEMENTS",
|
||||
"source": "TestJava.java:TenantService",
|
||||
"target": "BaseService"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "TestJava.java:TenantService",
|
||||
"target": "TestJava.java:TenantService:TenantService"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "TestJava.java:TenantService",
|
||||
"target": "TestJava.java:TenantService:getTenants"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "TestJava.java:TenantService:getTenants",
|
||||
"target": "this.db.query"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "TestJava.java:TenantService",
|
||||
"target": "TestJava.java:TenantService:audit"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "TestJava.java:TenantService:audit",
|
||||
"target": "Logger.log"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
test/ground-truth/mask-api-key.json
Normal file
45
test/ground-truth/mask-api-key.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"file": "/app/src/utils/mask-api-key.ts",
|
||||
"language": "typescript",
|
||||
"entities": [
|
||||
{
|
||||
"id": "utils/mask-api-key.ts",
|
||||
"type": "Module",
|
||||
"name": "utils/mask-api-key.ts",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "utils/mask-api-key.ts:maskApiKey",
|
||||
"type": "Function",
|
||||
"name": "maskApiKey",
|
||||
"kind": "function",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "utils/mask-api-key.ts",
|
||||
"target": "utils/mask-api-key.ts:maskApiKey"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "utils/mask-api-key.ts:maskApiKey",
|
||||
"target": "value.trim"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "utils/mask-api-key.ts:maskApiKey",
|
||||
"target": "trimmed.slice"
|
||||
}
|
||||
]
|
||||
}
|
||||
241
test/ground-truth/python-service.json
Normal file
241
test/ground-truth/python-service.json
Normal file
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"file": "/tmp/test_service.py",
|
||||
"language": "python",
|
||||
"entities": [
|
||||
{
|
||||
"id": "test_service.py",
|
||||
"type": "Module",
|
||||
"name": "test_service.py",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:os",
|
||||
"type": "Dependency",
|
||||
"name": "os",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:typing",
|
||||
"type": "Dependency",
|
||||
"name": "typing",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
2,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:dataclasses",
|
||||
"type": "Dependency",
|
||||
"name": "dataclasses",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:.config",
|
||||
"type": "Dependency",
|
||||
"name": ".config",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
4,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:.database",
|
||||
"type": "Dependency",
|
||||
"name": ".database",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
5,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantConfig",
|
||||
"type": "Class",
|
||||
"name": "TenantConfig",
|
||||
"kind": "class",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
8,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantService",
|
||||
"type": "Class",
|
||||
"name": "TenantService",
|
||||
"kind": "class",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
13,
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantService:__init__",
|
||||
"type": "Function",
|
||||
"name": "__init__",
|
||||
"kind": "method",
|
||||
"visibility": "private",
|
||||
"line_range": [
|
||||
14,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantService:get_tenant",
|
||||
"type": "Function",
|
||||
"name": "get_tenant",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
18,
|
||||
22
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantService:_enrich",
|
||||
"type": "Function",
|
||||
"name": "_enrich",
|
||||
"kind": "method",
|
||||
"visibility": "protected",
|
||||
"line_range": [
|
||||
24,
|
||||
26
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:TenantService:create_tenant",
|
||||
"type": "Function",
|
||||
"name": "create_tenant",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
28,
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_service.py:health_check",
|
||||
"type": "Function",
|
||||
"name": "health_check",
|
||||
"kind": "function",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
32,
|
||||
33
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_service.py",
|
||||
"target": "dep:os"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_service.py",
|
||||
"target": "dep:typing"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_service.py",
|
||||
"target": "dep:dataclasses"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_service.py",
|
||||
"target": "dep:.config"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "test_service.py",
|
||||
"target": "dep:.database"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py",
|
||||
"target": "test_service.py:TenantConfig"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py",
|
||||
"target": "test_service.py:TenantService"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py:TenantService",
|
||||
"target": "test_service.py:TenantService:__init__"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:__init__",
|
||||
"target": "load_config"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py:TenantService",
|
||||
"target": "test_service.py:TenantService:get_tenant"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:get_tenant",
|
||||
"target": "self.db.query"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:get_tenant",
|
||||
"target": "self._enrich"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py:TenantService",
|
||||
"target": "test_service.py:TenantService:_enrich"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:_enrich",
|
||||
"target": "self.config.get"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py:TenantService",
|
||||
"target": "test_service.py:TenantService:create_tenant"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:create_tenant",
|
||||
"target": "TenantConfig"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "test_service.py:TenantService:create_tenant",
|
||||
"target": "self.db.insert"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_service.py",
|
||||
"target": "test_service.py:health_check"
|
||||
}
|
||||
]
|
||||
}
|
||||
234
test/ground-truth/route.json
Normal file
234
test/ground-truth/route.json
Normal file
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"file": "/app/src/cli/route.ts",
|
||||
"language": "typescript",
|
||||
"entities": [
|
||||
{
|
||||
"id": "cli/route.ts",
|
||||
"type": "Module",
|
||||
"name": "cli/route.ts",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:infra/env.js",
|
||||
"type": "Dependency",
|
||||
"name": "infra/env.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:runtime.js",
|
||||
"type": "Dependency",
|
||||
"name": "runtime.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
2,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:version.js",
|
||||
"type": "Dependency",
|
||||
"name": "version.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:cli/argv.js",
|
||||
"type": "Dependency",
|
||||
"name": "cli/argv.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
4,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:cli/banner.js",
|
||||
"type": "Dependency",
|
||||
"name": "cli/banner.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
5,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:cli/plugin-registry.js",
|
||||
"type": "Dependency",
|
||||
"name": "cli/plugin-registry.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
6,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:cli/program/config-guard.js",
|
||||
"type": "Dependency",
|
||||
"name": "cli/program/config-guard.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
7,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:cli/program/routes.js",
|
||||
"type": "Dependency",
|
||||
"name": "cli/program/routes.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
8,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cli/route.ts:prepareRoutedCommand",
|
||||
"type": "Function",
|
||||
"name": "prepareRoutedCommand",
|
||||
"kind": "function",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
10,
|
||||
27
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cli/route.ts:tryRouteCli",
|
||||
"type": "Function",
|
||||
"name": "tryRouteCli",
|
||||
"kind": "function",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
29,
|
||||
47
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:infra/env.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:runtime.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:version.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:cli/argv.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:cli/banner.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:cli/plugin-registry.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:cli/program/config-guard.js"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "dep:cli/program/routes.js"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "cli/route.ts:prepareRoutedCommand"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:prepareRoutedCommand",
|
||||
"target": "hasFlag"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:prepareRoutedCommand",
|
||||
"target": "emitCliBanner"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:prepareRoutedCommand",
|
||||
"target": "ensureConfigReady"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:prepareRoutedCommand",
|
||||
"target": "params.loadPlugins"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:prepareRoutedCommand",
|
||||
"target": "ensurePluginRegistryLoaded"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "cli/route.ts",
|
||||
"target": "cli/route.ts:tryRouteCli"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "isTruthyEnvValue"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "hasHelpOrVersion"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "getCommandPathWithRootOptions"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "findRoutedCommand"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "prepareRoutedCommand"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "cli/route.ts:tryRouteCli",
|
||||
"target": "route.run"
|
||||
}
|
||||
]
|
||||
}
|
||||
579
test/ground-truth/session.json
Normal file
579
test/ground-truth/session.json
Normal file
@@ -0,0 +1,579 @@
|
||||
{
|
||||
"file": "/app/src/wizard/session.ts",
|
||||
"language": "typescript",
|
||||
"entities": [
|
||||
{
|
||||
"id": "wizard/session.ts",
|
||||
"type": "Module",
|
||||
"name": "wizard/session.ts",
|
||||
"kind": "module",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
265
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:node:crypto",
|
||||
"type": "Dependency",
|
||||
"name": "node:crypto",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dep:wizard/prompts.js",
|
||||
"type": "Dependency",
|
||||
"name": "wizard/prompts.js",
|
||||
"kind": "import",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
2,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:createDeferred",
|
||||
"type": "Function",
|
||||
"name": "createDeferred",
|
||||
"kind": "function",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
37,
|
||||
45
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter",
|
||||
"type": "Class",
|
||||
"name": "WizardSessionPrompter",
|
||||
"kind": "class",
|
||||
"visibility": "internal",
|
||||
"line_range": [
|
||||
47,
|
||||
161
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:constructor",
|
||||
"type": "Function",
|
||||
"name": "constructor",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
48,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:intro",
|
||||
"type": "Function",
|
||||
"name": "intro",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
50,
|
||||
57
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:outro",
|
||||
"type": "Function",
|
||||
"name": "outro",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
59,
|
||||
66
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:note",
|
||||
"type": "Function",
|
||||
"name": "note",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
68,
|
||||
70
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:select",
|
||||
"type": "Function",
|
||||
"name": "select",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
72,
|
||||
89
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:multiselect",
|
||||
"type": "Function",
|
||||
"name": "multiselect",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
91,
|
||||
108
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:text",
|
||||
"type": "Function",
|
||||
"name": "text",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
110,
|
||||
136
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:confirm",
|
||||
"type": "Function",
|
||||
"name": "confirm",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
138,
|
||||
146
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:progress",
|
||||
"type": "Function",
|
||||
"name": "progress",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
148,
|
||||
153
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSessionPrompter:prompt",
|
||||
"type": "Function",
|
||||
"name": "prompt",
|
||||
"kind": "method",
|
||||
"visibility": "private",
|
||||
"line_range": [
|
||||
155,
|
||||
160
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession",
|
||||
"type": "Class",
|
||||
"name": "WizardSession",
|
||||
"kind": "class",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
163,
|
||||
264
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:constructor",
|
||||
"type": "Function",
|
||||
"name": "constructor",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
170,
|
||||
173
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:next",
|
||||
"type": "Function",
|
||||
"name": "next",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
175,
|
||||
190
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:answer",
|
||||
"type": "Function",
|
||||
"name": "answer",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
192,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:cancel",
|
||||
"type": "Function",
|
||||
"name": "cancel",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
202,
|
||||
214
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:pushStep",
|
||||
"type": "Function",
|
||||
"name": "pushStep",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
216,
|
||||
219
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:run",
|
||||
"type": "Function",
|
||||
"name": "run",
|
||||
"kind": "method",
|
||||
"visibility": "private",
|
||||
"line_range": [
|
||||
221,
|
||||
236
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:awaitAnswer",
|
||||
"type": "Function",
|
||||
"name": "awaitAnswer",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
238,
|
||||
246
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:resolveStep",
|
||||
"type": "Function",
|
||||
"name": "resolveStep",
|
||||
"kind": "method",
|
||||
"visibility": "private",
|
||||
"line_range": [
|
||||
248,
|
||||
255
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:getStatus",
|
||||
"type": "Function",
|
||||
"name": "getStatus",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
257,
|
||||
259
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wizard/session.ts:WizardSession:getError",
|
||||
"type": "Function",
|
||||
"name": "getError",
|
||||
"kind": "method",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
261,
|
||||
263
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "wizard/session.ts",
|
||||
"target": "dep:node:crypto"
|
||||
},
|
||||
{
|
||||
"type": "IMPORTS",
|
||||
"source": "wizard/session.ts",
|
||||
"target": "dep:wizard/prompts.js"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts",
|
||||
"target": "wizard/session.ts:createDeferred"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter"
|
||||
},
|
||||
{
|
||||
"type": "IMPLEMENTS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "WizardPrompter"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:constructor"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:intro"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:intro",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:outro"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:outro",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:note"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:note",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:select"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:select",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:select",
|
||||
"target": "params.options.map"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:multiselect"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:multiselect",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:multiselect",
|
||||
"target": "params.options.map"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:multiselect",
|
||||
"target": "Array.isArray"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:text"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:text",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:text",
|
||||
"target": "String"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:text",
|
||||
"target": "params.validate"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:confirm"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:confirm",
|
||||
"target": "this.prompt"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:confirm",
|
||||
"target": "Boolean"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:progress"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter",
|
||||
"target": "wizard/session.ts:WizardSessionPrompter:prompt"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:prompt",
|
||||
"target": "this.session.awaitAnswer"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSessionPrompter:prompt",
|
||||
"target": "randomUUID"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts",
|
||||
"target": "wizard/session.ts:WizardSession"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:constructor"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:constructor",
|
||||
"target": "this.run"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:next"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:next",
|
||||
"target": "createDeferred"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:answer"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:answer",
|
||||
"target": "this.answerDeferred.get"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:answer",
|
||||
"target": "this.answerDeferred.delete"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:answer",
|
||||
"target": "deferred.resolve"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:cancel"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:cancel",
|
||||
"target": "deferred.reject"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:cancel",
|
||||
"target": "this.answerDeferred.clear"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:cancel",
|
||||
"target": "this.resolveStep"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:pushStep"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:pushStep",
|
||||
"target": "this.resolveStep"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:run"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:run",
|
||||
"target": "this.runner"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:run",
|
||||
"target": "String"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:run",
|
||||
"target": "this.resolveStep"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:awaitAnswer"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:awaitAnswer",
|
||||
"target": "this.pushStep"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:awaitAnswer",
|
||||
"target": "createDeferred"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:awaitAnswer",
|
||||
"target": "this.answerDeferred.set"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:resolveStep"
|
||||
},
|
||||
{
|
||||
"type": "CALLS",
|
||||
"source": "wizard/session.ts:WizardSession:resolveStep",
|
||||
"target": "deferred.resolve"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:getStatus"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "wizard/session.ts:WizardSession",
|
||||
"target": "wizard/session.ts:WizardSession:getError"
|
||||
}
|
||||
]
|
||||
}
|
||||
83
test/ground-truth/terraform-main.json
Normal file
83
test/ground-truth/terraform-main.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"file": "/tmp/main.tf",
|
||||
"language": "hcl",
|
||||
"entities": [
|
||||
{
|
||||
"id": "main.tf",
|
||||
"type": "Config",
|
||||
"name": "main.tf",
|
||||
"kind": "terraform",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
18
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "main.tf:provider.aws",
|
||||
"type": "Config",
|
||||
"name": "provider.aws",
|
||||
"kind": "hcl-block",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "main.tf:resource.aws_s3_bucket.b",
|
||||
"type": "Config",
|
||||
"name": "resource.aws_s3_bucket.b",
|
||||
"kind": "hcl-block",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
5,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "main.tf:module.vpc",
|
||||
"type": "Config",
|
||||
"name": "module.vpc",
|
||||
"kind": "hcl-block",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
9,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "main.tf:data.aws_iam_policy_document.assume_role",
|
||||
"type": "Config",
|
||||
"name": "data.aws_iam_policy_document.assume_role",
|
||||
"kind": "hcl-block",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
13,
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "main.tf",
|
||||
"target": "main.tf:provider.aws"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "main.tf",
|
||||
"target": "main.tf:resource.aws_s3_bucket.b"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "main.tf",
|
||||
"target": "main.tf:module.vpc"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "main.tf",
|
||||
"target": "main.tf:data.aws_iam_policy_document.assume_role"
|
||||
}
|
||||
]
|
||||
}
|
||||
83
test/ground-truth/yaml-deployment.json
Normal file
83
test/ground-truth/yaml-deployment.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"file": "/tmp/test_deployment.yaml",
|
||||
"language": "yaml",
|
||||
"entities": [
|
||||
{
|
||||
"id": "test_deployment.yaml",
|
||||
"type": "Config",
|
||||
"name": "test_deployment.yaml",
|
||||
"kind": "yaml-config",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_deployment.yaml:apiVersion",
|
||||
"type": "Config",
|
||||
"name": "apiVersion",
|
||||
"kind": "yaml-key",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_deployment.yaml:kind",
|
||||
"type": "Config",
|
||||
"name": "kind",
|
||||
"kind": "yaml-key",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_deployment.yaml:metadata",
|
||||
"type": "Config",
|
||||
"name": "metadata",
|
||||
"kind": "yaml-key",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_deployment.yaml:spec",
|
||||
"type": "Config",
|
||||
"name": "spec",
|
||||
"kind": "yaml-key",
|
||||
"visibility": "public",
|
||||
"line_range": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_deployment.yaml",
|
||||
"target": "test_deployment.yaml:apiVersion"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_deployment.yaml",
|
||||
"target": "test_deployment.yaml:kind"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_deployment.yaml",
|
||||
"target": "test_deployment.yaml:metadata"
|
||||
},
|
||||
{
|
||||
"type": "CONTAINS",
|
||||
"source": "test_deployment.yaml",
|
||||
"target": "test_deployment.yaml:spec"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
test/run-all.sh
Executable file
30
test/run-all.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
echo "=== Dev Intel v2 — Ground Truth Benchmark Suite ==="
|
||||
echo ""
|
||||
|
||||
for gt in test/ground-truth/*.json; do
|
||||
name=$(basename "$gt" .json)
|
||||
TOTAL=$((TOTAL + 1))
|
||||
result=$(node validate-ground-truth.js "$gt" 2>&1)
|
||||
if echo "$result" | grep -q "^PASS"; then
|
||||
echo "✅ PASS $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "❌ FAIL $name"
|
||||
echo "$result" | grep -E "^(Entities|Relationships|Missing)" | sed 's/^/ /'
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS/$TOTAL passed, $FAIL failed ==="
|
||||
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
323
test/test-graph.js
Normal file
323
test/test-graph.js
Normal file
@@ -0,0 +1,323 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const GraphStore = require('../graph');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function runTest(name, fn) {
|
||||
total++;
|
||||
try {
|
||||
fn();
|
||||
console.log(`✅ PASS ${name}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.log(`❌ FAIL ${name}: ${err.message}`);
|
||||
// console.error(err.stack); // uncomment if debugging is needed
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create mock extract results
|
||||
const mockFile1 = {
|
||||
file: 'file1.ts',
|
||||
language: 'typescript',
|
||||
entities: [
|
||||
{ id: 'file1.ts', type: 'Module', name: 'file1.ts', visibility: 'public' },
|
||||
{ id: 'func1', type: 'Function', name: 'func1', visibility: 'public' }
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'CONTAINS', source: 'file1.ts', target: 'func1' },
|
||||
{ type: 'CALLS', source: 'func1', target: 'func2' }
|
||||
]
|
||||
};
|
||||
|
||||
const mockFile2 = {
|
||||
file: 'file2.ts',
|
||||
language: 'typescript',
|
||||
entities: [
|
||||
{ id: 'file2.ts', type: 'Module', name: 'file2.ts', visibility: 'public' },
|
||||
{ id: 'func2', type: 'Function', name: 'func2', visibility: 'private' }
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'CONTAINS', source: 'file2.ts', target: 'func2' },
|
||||
{ type: 'IMPORTS', source: 'file2.ts', target: 'file1.ts' },
|
||||
{ type: 'CALLS', source: 'func2', target: 'func1' },
|
||||
// Duplicate edge from file1 to test deduplication
|
||||
{ type: 'CALLS', source: 'func1', target: 'func2' }
|
||||
]
|
||||
};
|
||||
|
||||
// --- 1. buildGraph() ---
|
||||
|
||||
runTest('buildGraph: Empty input (no results) -> empty graph', () => {
|
||||
const graph = GraphStore.buildGraph([]);
|
||||
assert.strictEqual(graph.nodes.size, 0);
|
||||
assert.strictEqual(graph.edges.length, 0);
|
||||
assert.strictEqual(graph.fileIndex.size, 0);
|
||||
});
|
||||
|
||||
runTest('buildGraph: Single file extraction -> correct node count, edge count', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1]);
|
||||
assert.strictEqual(graph.nodes.size, 2);
|
||||
assert.strictEqual(graph.edges.length, 2);
|
||||
});
|
||||
|
||||
runTest('buildGraph: Multiple file extractions -> merges correctly, no duplicate nodes', () => {
|
||||
// Pass mockFile1 twice to test node deduplication by ID Map
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2, mockFile1]);
|
||||
assert.strictEqual(graph.nodes.size, 4); // file1.ts, func1, file2.ts, func2
|
||||
});
|
||||
|
||||
runTest('buildGraph: Duplicate edges from multiple files are deduplicated', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
// Expected edges:
|
||||
// file1 CONTAINS func1
|
||||
// func1 CALLS func2
|
||||
// file2 CONTAINS func2
|
||||
// file2 IMPORTS file1
|
||||
// func2 CALLS func1
|
||||
// The second func1 CALLS func2 in mockFile2 should be ignored
|
||||
assert.strictEqual(graph.edges.length, 5);
|
||||
const callsFunc2 = graph.edges.filter(e => e.source === 'func1' && e.target === 'func2' && e.type === 'CALLS');
|
||||
assert.strictEqual(callsFunc2.length, 1);
|
||||
});
|
||||
|
||||
runTest('buildGraph: fileIndex is correctly populated', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
assert.strictEqual(graph.fileIndex.size, 2);
|
||||
assert.ok(graph.fileIndex.get('file1.ts').has('func1'));
|
||||
assert.ok(graph.fileIndex.get('file2.ts').has('func2'));
|
||||
});
|
||||
|
||||
// --- 2. saveSnapshot() / loadSnapshot() ---
|
||||
|
||||
const SNAPSHOT_PATH = path.join(__dirname, 'test-snapshot.json');
|
||||
|
||||
runTest('saveSnapshot/loadSnapshot: Round-trip -> verify nodes, edges, fileIndex match exactly', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
GraphStore.saveSnapshot(graph, SNAPSHOT_PATH);
|
||||
|
||||
const loaded = GraphStore.loadSnapshot(SNAPSHOT_PATH);
|
||||
assert.strictEqual(loaded.nodes.size, graph.nodes.size);
|
||||
assert.strictEqual(loaded.edges.length, graph.edges.length);
|
||||
assert.strictEqual(loaded.fileIndex.size, graph.fileIndex.size);
|
||||
|
||||
assert.deepStrictEqual(loaded.nodes.get('func1'), graph.nodes.get('func1'));
|
||||
assert.deepStrictEqual(loaded.edges, graph.edges);
|
||||
assert.deepStrictEqual(Array.from(loaded.fileIndex.get('file1.ts')), Array.from(graph.fileIndex.get('file1.ts')));
|
||||
|
||||
if (fs.existsSync(SNAPSHOT_PATH)) fs.unlinkSync(SNAPSHOT_PATH);
|
||||
});
|
||||
|
||||
runTest('saveSnapshot/loadSnapshot: Save creates valid JSON file', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1]);
|
||||
GraphStore.saveSnapshot(graph, SNAPSHOT_PATH);
|
||||
|
||||
const content = fs.readFileSync(SNAPSHOT_PATH, 'utf8');
|
||||
assert.doesNotThrow(() => JSON.parse(content));
|
||||
|
||||
if (fs.existsSync(SNAPSHOT_PATH)) fs.unlinkSync(SNAPSHOT_PATH);
|
||||
});
|
||||
|
||||
runTest('saveSnapshot/loadSnapshot: Load from non-existent file throws', () => {
|
||||
assert.throws(() => {
|
||||
GraphStore.loadSnapshot('does-not-exist.json');
|
||||
});
|
||||
});
|
||||
|
||||
// --- 3. query() ---
|
||||
|
||||
runTest('query: Query existing entity -> returns entity + correct incoming/outgoing edges', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const result = GraphStore.query(graph, 'func1');
|
||||
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.entity.id, 'func1');
|
||||
|
||||
// Incoming: file1 CONTAINS func1, func2 CALLS func1
|
||||
assert.strictEqual(result.incoming.length, 2);
|
||||
|
||||
// Outgoing: func1 CALLS func2
|
||||
assert.strictEqual(result.outgoing.length, 1);
|
||||
});
|
||||
|
||||
runTest('query: Query non-existent entity -> returns null', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1]);
|
||||
const result = GraphStore.query(graph, 'non-existent');
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
runTest('query: Entity with no edges -> returns entity with empty incoming/outgoing arrays', () => {
|
||||
const isolatedEntity = {
|
||||
file: 'iso.ts',
|
||||
entities: [{ id: 'iso', type: 'Module' }],
|
||||
relationships: []
|
||||
};
|
||||
const graph = GraphStore.buildGraph([isolatedEntity]);
|
||||
const result = GraphStore.query(graph, 'iso');
|
||||
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.incoming.length, 0);
|
||||
assert.strictEqual(result.outgoing.length, 0);
|
||||
});
|
||||
|
||||
// --- 4. findCallers() ---
|
||||
|
||||
runTest('findCallers: Function with multiple callers -> returns all', () => {
|
||||
const multiCall = {
|
||||
file: 'multi.ts',
|
||||
entities: [{ id: 'target' }, { id: 'c1' }, { id: 'c2' }],
|
||||
relationships: [
|
||||
{ type: 'CALLS', source: 'c1', target: 'target' },
|
||||
{ type: 'CALLS', source: 'c2', target: 'target' }
|
||||
]
|
||||
};
|
||||
const graph = GraphStore.buildGraph([multiCall]);
|
||||
const callers = GraphStore.findCallers(graph, 'target');
|
||||
assert.strictEqual(callers.length, 2);
|
||||
const ids = callers.map(c => c.id);
|
||||
assert.ok(ids.includes('c1') && ids.includes('c2'));
|
||||
});
|
||||
|
||||
runTest('findCallers: Function with no callers -> returns empty array', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1]);
|
||||
const callers = GraphStore.findCallers(graph, 'func1');
|
||||
assert.strictEqual(callers.length, 0);
|
||||
});
|
||||
|
||||
runTest('findCallers: Only returns CALLS edges, not CONTAINS or IMPORTS', () => {
|
||||
const mixedEdges = {
|
||||
file: 'mixed.ts',
|
||||
entities: [{ id: 'target' }, { id: 'c1' }, { id: 'c2' }],
|
||||
relationships: [
|
||||
{ type: 'CALLS', source: 'c1', target: 'target' },
|
||||
{ type: 'CONTAINS', source: 'c2', target: 'target' },
|
||||
{ type: 'IMPORTS', source: 'c2', target: 'target' }
|
||||
]
|
||||
};
|
||||
const graph = GraphStore.buildGraph([mixedEdges]);
|
||||
const callers = GraphStore.findCallers(graph, 'target');
|
||||
assert.strictEqual(callers.length, 1);
|
||||
assert.strictEqual(callers[0].id, 'c1');
|
||||
});
|
||||
|
||||
// --- 5. findDependents() ---
|
||||
|
||||
runTest('findDependents: Module with dependents -> returns all', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const dependents = GraphStore.findDependents(graph, 'file1.ts');
|
||||
assert.strictEqual(dependents.length, 1);
|
||||
assert.strictEqual(dependents[0].id, 'file2.ts');
|
||||
});
|
||||
|
||||
runTest('findDependents: Module with no dependents -> returns empty array', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const dependents = GraphStore.findDependents(graph, 'file2.ts');
|
||||
assert.strictEqual(dependents.length, 0);
|
||||
});
|
||||
|
||||
// --- 6. getExports() ---
|
||||
|
||||
runTest('getExports: File with mix of public/private entities -> returns only public', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const exportsFile1 = GraphStore.getExports(graph, 'file1.ts');
|
||||
assert.strictEqual(exportsFile1.length, 2); // Both file1.ts and func1 are public
|
||||
|
||||
const exportsFile2 = GraphStore.getExports(graph, 'file2.ts');
|
||||
assert.strictEqual(exportsFile2.length, 1); // Only file2.ts is public, func2 is private
|
||||
assert.strictEqual(exportsFile2[0].id, 'file2.ts');
|
||||
});
|
||||
|
||||
runTest('getExports: Non-existent file -> returns empty array', () => {
|
||||
const graph = GraphStore.buildGraph([mockFile1]);
|
||||
const exports = GraphStore.getExports(graph, 'missing.ts');
|
||||
assert.strictEqual(exports.length, 0);
|
||||
});
|
||||
|
||||
// --- 7. diffSnapshots() ---
|
||||
|
||||
runTest('diffSnapshots: Identical graphs -> empty diff', () => {
|
||||
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
assert.strictEqual(diff.entities.added.length, 0);
|
||||
assert.strictEqual(diff.entities.removed.length, 0);
|
||||
assert.strictEqual(diff.entities.modified.length, 0);
|
||||
assert.strictEqual(diff.relationships.added.length, 0);
|
||||
assert.strictEqual(diff.relationships.removed.length, 0);
|
||||
});
|
||||
|
||||
runTest('diffSnapshots: Added entities -> appear in diff.entities.added', () => {
|
||||
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
assert.strictEqual(diff.entities.added.length, 2); // file2.ts, func2
|
||||
const ids = diff.entities.added.map(e => e.id);
|
||||
assert.ok(ids.includes('file2.ts') && ids.includes('func2'));
|
||||
});
|
||||
|
||||
runTest('diffSnapshots: Removed entities -> appear in diff.entities.removed', () => {
|
||||
const graph1 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
assert.strictEqual(diff.entities.removed.length, 2);
|
||||
const ids = diff.entities.removed.map(e => e.id);
|
||||
assert.ok(ids.includes('file2.ts') && ids.includes('func2'));
|
||||
});
|
||||
|
||||
runTest('diffSnapshots: Modified entities (e.g. changed line_range) -> appear in diff.entities.modified', () => {
|
||||
const mockFile1Mod = JSON.parse(JSON.stringify(mockFile1));
|
||||
mockFile1Mod.entities[1].line_range = [10, 20];
|
||||
|
||||
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1Mod]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
assert.strictEqual(diff.entities.modified.length, 1);
|
||||
assert.strictEqual(diff.entities.modified[0].old.id, 'func1');
|
||||
});
|
||||
|
||||
runTest('diffSnapshots: Added relationships -> appear in diff.relationships.added', () => {
|
||||
const graph1 = GraphStore.buildGraph([mockFile1]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
// file2 relationships will be added (3 of them: CONTAINS, IMPORTS, CALLS)
|
||||
assert.strictEqual(diff.relationships.added.length, 3);
|
||||
});
|
||||
|
||||
runTest('diffSnapshots: Removed relationships -> appear in diff.relationships.removed', () => {
|
||||
const graph1 = GraphStore.buildGraph([mockFile1, mockFile2]);
|
||||
const graph2 = GraphStore.buildGraph([mockFile1]);
|
||||
const diff = GraphStore.diffSnapshots(graph1, graph2);
|
||||
|
||||
assert.strictEqual(diff.relationships.removed.length, 3);
|
||||
});
|
||||
|
||||
// --- 8. Integration test ---
|
||||
|
||||
runTest('Integration test: extract.js on mask-api-key.ts -> buildGraph -> query', () => {
|
||||
const extractCmd = `node ${path.join(__dirname, '../extract.js')} /app/src/utils/mask-api-key.ts /app/src`;
|
||||
const output = execSync(extractCmd, { encoding: 'utf8' });
|
||||
const resultObj = JSON.parse(output);
|
||||
|
||||
const graph = GraphStore.buildGraph([resultObj]);
|
||||
const entityId = 'utils/mask-api-key.ts:maskApiKey';
|
||||
|
||||
const queryResult = GraphStore.query(graph, entityId);
|
||||
assert.ok(queryResult);
|
||||
assert.strictEqual(queryResult.entity.name, 'maskApiKey');
|
||||
assert.strictEqual(queryResult.outgoing.length, 2); // CALLS value.trim, CALLS trimmed.slice
|
||||
|
||||
const callTargets = queryResult.outgoing.map(e => e.target);
|
||||
assert.ok(callTargets.includes('value.trim'));
|
||||
assert.ok(callTargets.includes('trimmed.slice'));
|
||||
});
|
||||
|
||||
console.log(`\n${passed}/${total} tests passed.`);
|
||||
if (passed !== total) process.exit(1);
|
||||
82
validate-ground-truth.js
Normal file
82
validate-ground-truth.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const groundTruthPath = process.argv[2];
|
||||
if (!groundTruthPath) {
|
||||
console.error("Usage: node validate-ground-truth.js <ground-truth-json>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gt = JSON.parse(fs.readFileSync(groundTruthPath, 'utf8'));
|
||||
const filePath = gt.file;
|
||||
|
||||
// Infer repo root from the ground truth: the module entity's ID is the relative path
|
||||
const moduleEntity = gt.entities.find(e => e.type === 'Module' || e.type === 'Config');
|
||||
let repoRoot = '/app/src';
|
||||
if (moduleEntity) {
|
||||
// filePath = /tmp/test_service.py, moduleEntity.id = test_service.py → repoRoot = /tmp
|
||||
// filePath = /app/src/cli/route.ts, moduleEntity.id = cli/route.ts → repoRoot = /app/src
|
||||
const expectedRelPath = moduleEntity.id;
|
||||
if (filePath.endsWith(expectedRelPath)) {
|
||||
repoRoot = filePath.slice(0, filePath.length - expectedRelPath.length);
|
||||
if (repoRoot.endsWith('/')) repoRoot = repoRoot.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
const scriptDir = __dirname;
|
||||
const out = execSync(`node ${path.join(scriptDir, 'extract.js')} "${filePath}" "${repoRoot}"`);
|
||||
const actual = JSON.parse(out);
|
||||
|
||||
// --- Entity Matching (by ID) ---
|
||||
let correctEntities = 0;
|
||||
const matchedActualEntities = new Set();
|
||||
for (const ge of gt.entities) {
|
||||
const match = actual.entities.find(ae => ae.id === ge.id);
|
||||
if (match) {
|
||||
correctEntities++;
|
||||
matchedActualEntities.add(match.id);
|
||||
} else {
|
||||
console.log(`Missing entity: ${ge.id}`);
|
||||
}
|
||||
}
|
||||
const extraEntities = actual.entities.filter(ae => !matchedActualEntities.has(ae.id));
|
||||
for (const e of extraEntities) {
|
||||
console.log(`Extra entity: ${e.id}`);
|
||||
}
|
||||
|
||||
const entityPrecision = correctEntities / (actual.entities.length || 1);
|
||||
const entityRecall = correctEntities / (gt.entities.length || 1);
|
||||
const entityF1 = (2 * entityPrecision * entityRecall) / (entityPrecision + entityRecall || 1);
|
||||
|
||||
// --- Relationship Matching ---
|
||||
let correctRelationships = 0;
|
||||
const matchedActualRels = new Set();
|
||||
for (const gr of gt.relationships) {
|
||||
const idx = actual.relationships.findIndex(ar => ar.type === gr.type && ar.source === gr.source && ar.target === gr.target);
|
||||
if (idx >= 0) {
|
||||
correctRelationships++;
|
||||
matchedActualRels.add(idx);
|
||||
} else {
|
||||
console.log(`Missing relationship: ${gr.type} ${gr.source} -> ${gr.target}`);
|
||||
}
|
||||
}
|
||||
const extraRels = actual.relationships.filter((_, i) => !matchedActualRels.has(i));
|
||||
for (const r of extraRels) {
|
||||
console.log(`Extra relationship: ${r.type} ${r.source} -> ${r.target}`);
|
||||
}
|
||||
|
||||
const relPrecision = correctRelationships / (actual.relationships.length || 1);
|
||||
const relRecall = correctRelationships / (gt.relationships.length || 1);
|
||||
const relF1 = (2 * relPrecision * relRecall) / (relPrecision + relRecall || 1);
|
||||
|
||||
console.log(`Entities: P=${entityPrecision.toFixed(2)}, R=${entityRecall.toFixed(2)}, F1=${entityF1.toFixed(2)}`);
|
||||
console.log(`Relationships: P=${relPrecision.toFixed(2)}, R=${relRecall.toFixed(2)}, F1=${relF1.toFixed(2)}`);
|
||||
|
||||
if (entityF1 >= 0.90 && relF1 >= 0.85) {
|
||||
console.log("PASS");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("FAIL");
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user