292 lines
9.5 KiB
JavaScript
292 lines
9.5 KiB
JavaScript
|
|
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;
|