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 [--file ]'); 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 '); 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 };