@optave/codegraph 3.1.3 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -19
- package/package.json +10 -7
- package/src/analysis/context.js +408 -0
- package/src/analysis/dependencies.js +341 -0
- package/src/analysis/exports.js +130 -0
- package/src/analysis/impact.js +463 -0
- package/src/analysis/module-map.js +322 -0
- package/src/analysis/roles.js +45 -0
- package/src/analysis/symbol-lookup.js +232 -0
- package/src/ast-analysis/shared.js +5 -4
- package/src/batch.js +2 -1
- package/src/builder/context.js +85 -0
- package/src/builder/helpers.js +218 -0
- package/src/builder/incremental.js +178 -0
- package/src/builder/pipeline.js +130 -0
- package/src/builder/stages/build-edges.js +297 -0
- package/src/builder/stages/build-structure.js +113 -0
- package/src/builder/stages/collect-files.js +44 -0
- package/src/builder/stages/detect-changes.js +413 -0
- package/src/builder/stages/finalize.js +139 -0
- package/src/builder/stages/insert-nodes.js +195 -0
- package/src/builder/stages/parse-files.js +28 -0
- package/src/builder/stages/resolve-imports.js +143 -0
- package/src/builder/stages/run-analyses.js +44 -0
- package/src/builder.js +10 -1485
- package/src/cfg.js +1 -2
- package/src/cli/commands/ast.js +26 -0
- package/src/cli/commands/audit.js +46 -0
- package/src/cli/commands/batch.js +68 -0
- package/src/cli/commands/branch-compare.js +21 -0
- package/src/cli/commands/build.js +26 -0
- package/src/cli/commands/cfg.js +30 -0
- package/src/cli/commands/check.js +79 -0
- package/src/cli/commands/children.js +31 -0
- package/src/cli/commands/co-change.js +65 -0
- package/src/cli/commands/communities.js +23 -0
- package/src/cli/commands/complexity.js +45 -0
- package/src/cli/commands/context.js +34 -0
- package/src/cli/commands/cycles.js +28 -0
- package/src/cli/commands/dataflow.js +32 -0
- package/src/cli/commands/deps.js +16 -0
- package/src/cli/commands/diff-impact.js +30 -0
- package/src/cli/commands/embed.js +30 -0
- package/src/cli/commands/export.js +75 -0
- package/src/cli/commands/exports.js +18 -0
- package/src/cli/commands/flow.js +36 -0
- package/src/cli/commands/fn-impact.js +30 -0
- package/src/cli/commands/impact.js +16 -0
- package/src/cli/commands/info.js +76 -0
- package/src/cli/commands/map.js +19 -0
- package/src/cli/commands/mcp.js +18 -0
- package/src/cli/commands/models.js +19 -0
- package/src/cli/commands/owners.js +25 -0
- package/src/cli/commands/path.js +36 -0
- package/src/cli/commands/plot.js +80 -0
- package/src/cli/commands/query.js +49 -0
- package/src/cli/commands/registry.js +100 -0
- package/src/cli/commands/roles.js +34 -0
- package/src/cli/commands/search.js +42 -0
- package/src/cli/commands/sequence.js +32 -0
- package/src/cli/commands/snapshot.js +61 -0
- package/src/cli/commands/stats.js +15 -0
- package/src/cli/commands/structure.js +32 -0
- package/src/cli/commands/triage.js +78 -0
- package/src/cli/commands/watch.js +12 -0
- package/src/cli/commands/where.js +24 -0
- package/src/cli/index.js +118 -0
- package/src/cli/shared/options.js +39 -0
- package/src/cli/shared/output.js +1 -0
- package/src/cli.js +11 -1522
- package/src/commands/check.js +5 -5
- package/src/commands/manifesto.js +3 -3
- package/src/commands/structure.js +1 -1
- package/src/communities.js +15 -87
- package/src/cycles.js +30 -85
- package/src/dataflow.js +1 -2
- package/src/db/connection.js +4 -4
- package/src/db/migrations.js +41 -0
- package/src/db/query-builder.js +6 -5
- package/src/db/repository/base.js +201 -0
- package/src/db/repository/graph-read.js +5 -2
- package/src/db/repository/in-memory-repository.js +584 -0
- package/src/db/repository/index.js +5 -1
- package/src/db/repository/nodes.js +63 -4
- package/src/db/repository/sqlite-repository.js +219 -0
- package/src/db.js +5 -0
- package/src/embeddings/generator.js +163 -0
- package/src/embeddings/index.js +13 -0
- package/src/embeddings/models.js +218 -0
- package/src/embeddings/search/cli-formatter.js +151 -0
- package/src/embeddings/search/filters.js +46 -0
- package/src/embeddings/search/hybrid.js +121 -0
- package/src/embeddings/search/keyword.js +68 -0
- package/src/embeddings/search/prepare.js +66 -0
- package/src/embeddings/search/semantic.js +145 -0
- package/src/embeddings/stores/fts5.js +27 -0
- package/src/embeddings/stores/sqlite-blob.js +24 -0
- package/src/embeddings/strategies/source.js +14 -0
- package/src/embeddings/strategies/structured.js +43 -0
- package/src/embeddings/strategies/text-utils.js +43 -0
- package/src/errors.js +78 -0
- package/src/export.js +217 -520
- package/src/extractors/csharp.js +10 -2
- package/src/extractors/go.js +3 -1
- package/src/extractors/helpers.js +71 -0
- package/src/extractors/java.js +9 -2
- package/src/extractors/javascript.js +38 -1
- package/src/extractors/php.js +3 -1
- package/src/extractors/python.js +14 -3
- package/src/extractors/rust.js +3 -1
- package/src/graph/algorithms/bfs.js +49 -0
- package/src/graph/algorithms/centrality.js +16 -0
- package/src/graph/algorithms/index.js +5 -0
- package/src/graph/algorithms/louvain.js +26 -0
- package/src/graph/algorithms/shortest-path.js +41 -0
- package/src/graph/algorithms/tarjan.js +49 -0
- package/src/graph/builders/dependency.js +91 -0
- package/src/graph/builders/index.js +3 -0
- package/src/graph/builders/structure.js +40 -0
- package/src/graph/builders/temporal.js +33 -0
- package/src/graph/classifiers/index.js +2 -0
- package/src/graph/classifiers/risk.js +85 -0
- package/src/graph/classifiers/roles.js +64 -0
- package/src/graph/index.js +13 -0
- package/src/graph/model.js +230 -0
- package/src/index.js +33 -210
- package/src/infrastructure/result-formatter.js +2 -21
- package/src/mcp/index.js +2 -0
- package/src/mcp/middleware.js +26 -0
- package/src/mcp/server.js +128 -0
- package/src/mcp/tool-registry.js +801 -0
- package/src/mcp/tools/ast-query.js +14 -0
- package/src/mcp/tools/audit.js +21 -0
- package/src/mcp/tools/batch-query.js +11 -0
- package/src/mcp/tools/branch-compare.js +10 -0
- package/src/mcp/tools/cfg.js +21 -0
- package/src/mcp/tools/check.js +43 -0
- package/src/mcp/tools/co-changes.js +20 -0
- package/src/mcp/tools/code-owners.js +12 -0
- package/src/mcp/tools/communities.js +15 -0
- package/src/mcp/tools/complexity.js +18 -0
- package/src/mcp/tools/context.js +17 -0
- package/src/mcp/tools/dataflow.js +26 -0
- package/src/mcp/tools/diff-impact.js +24 -0
- package/src/mcp/tools/execution-flow.js +26 -0
- package/src/mcp/tools/export-graph.js +57 -0
- package/src/mcp/tools/file-deps.js +12 -0
- package/src/mcp/tools/file-exports.js +13 -0
- package/src/mcp/tools/find-cycles.js +15 -0
- package/src/mcp/tools/fn-impact.js +15 -0
- package/src/mcp/tools/impact-analysis.js +12 -0
- package/src/mcp/tools/index.js +71 -0
- package/src/mcp/tools/list-functions.js +14 -0
- package/src/mcp/tools/list-repos.js +11 -0
- package/src/mcp/tools/module-map.js +6 -0
- package/src/mcp/tools/node-roles.js +14 -0
- package/src/mcp/tools/path.js +12 -0
- package/src/mcp/tools/query.js +30 -0
- package/src/mcp/tools/semantic-search.js +65 -0
- package/src/mcp/tools/sequence.js +17 -0
- package/src/mcp/tools/structure.js +15 -0
- package/src/mcp/tools/symbol-children.js +14 -0
- package/src/mcp/tools/triage.js +35 -0
- package/src/mcp/tools/where.js +13 -0
- package/src/mcp.js +2 -1470
- package/src/native.js +3 -1
- package/src/presentation/colors.js +44 -0
- package/src/presentation/export.js +444 -0
- package/src/presentation/result-formatter.js +21 -0
- package/src/presentation/sequence-renderer.js +43 -0
- package/src/presentation/table.js +47 -0
- package/src/presentation/viewer.js +634 -0
- package/src/queries.js +35 -2276
- package/src/resolve.js +1 -1
- package/src/sequence.js +2 -38
- package/src/shared/file-utils.js +153 -0
- package/src/shared/generators.js +125 -0
- package/src/shared/hierarchy.js +27 -0
- package/src/shared/normalize.js +59 -0
- package/src/snapshot.js +6 -5
- package/src/structure.js +15 -40
- package/src/triage.js +20 -72
- package/src/viewer.js +35 -656
- package/src/watcher.js +8 -148
- package/src/embedder.js +0 -1097
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { evaluateBoundaries } from '../boundaries.js';
|
|
5
|
+
import { coChangeForFiles } from '../cochange.js';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import {
|
|
8
|
+
findDbPath,
|
|
9
|
+
findDistinctCallers,
|
|
10
|
+
findFileNodes,
|
|
11
|
+
findImportDependents,
|
|
12
|
+
findNodeById,
|
|
13
|
+
openReadonlyOrFail,
|
|
14
|
+
} from '../db.js';
|
|
15
|
+
import { isTestFile } from '../infrastructure/test-filter.js';
|
|
16
|
+
import { ownersForFiles } from '../owners.js';
|
|
17
|
+
import { paginateResult } from '../paginate.js';
|
|
18
|
+
import { normalizeSymbol } from '../shared/normalize.js';
|
|
19
|
+
import { findMatchingNodes } from './symbol-lookup.js';
|
|
20
|
+
|
|
21
|
+
export function impactAnalysisData(file, customDbPath, opts = {}) {
|
|
22
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
23
|
+
try {
|
|
24
|
+
const noTests = opts.noTests || false;
|
|
25
|
+
const fileNodes = findFileNodes(db, `%${file}%`);
|
|
26
|
+
if (fileNodes.length === 0) {
|
|
27
|
+
return { file, sources: [], levels: {}, totalDependents: 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const visited = new Set();
|
|
31
|
+
const queue = [];
|
|
32
|
+
const levels = new Map();
|
|
33
|
+
|
|
34
|
+
for (const fn of fileNodes) {
|
|
35
|
+
visited.add(fn.id);
|
|
36
|
+
queue.push(fn.id);
|
|
37
|
+
levels.set(fn.id, 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const current = queue.shift();
|
|
42
|
+
const level = levels.get(current);
|
|
43
|
+
const dependents = findImportDependents(db, current);
|
|
44
|
+
for (const dep of dependents) {
|
|
45
|
+
if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
|
|
46
|
+
visited.add(dep.id);
|
|
47
|
+
queue.push(dep.id);
|
|
48
|
+
levels.set(dep.id, level + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const byLevel = {};
|
|
54
|
+
for (const [id, level] of levels) {
|
|
55
|
+
if (level === 0) continue;
|
|
56
|
+
if (!byLevel[level]) byLevel[level] = [];
|
|
57
|
+
const node = findNodeById(db, id);
|
|
58
|
+
if (node) byLevel[level].push({ file: node.file });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
file,
|
|
63
|
+
sources: fileNodes.map((f) => f.file),
|
|
64
|
+
levels: byLevel,
|
|
65
|
+
totalDependents: visited.size - fileNodes.length,
|
|
66
|
+
};
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function fnImpactData(name, customDbPath, opts = {}) {
|
|
73
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
74
|
+
try {
|
|
75
|
+
const maxDepth = opts.depth || 5;
|
|
76
|
+
const noTests = opts.noTests || false;
|
|
77
|
+
const hc = new Map();
|
|
78
|
+
|
|
79
|
+
const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
|
|
80
|
+
if (nodes.length === 0) {
|
|
81
|
+
return { name, results: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const results = nodes.map((node) => {
|
|
85
|
+
const visited = new Set([node.id]);
|
|
86
|
+
const levels = {};
|
|
87
|
+
let frontier = [node.id];
|
|
88
|
+
|
|
89
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
90
|
+
const nextFrontier = [];
|
|
91
|
+
for (const fid of frontier) {
|
|
92
|
+
const callers = findDistinctCallers(db, fid);
|
|
93
|
+
for (const c of callers) {
|
|
94
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
95
|
+
visited.add(c.id);
|
|
96
|
+
nextFrontier.push(c.id);
|
|
97
|
+
if (!levels[d]) levels[d] = [];
|
|
98
|
+
levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
frontier = nextFrontier;
|
|
103
|
+
if (frontier.length === 0) break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...normalizeSymbol(node, db, hc),
|
|
108
|
+
levels,
|
|
109
|
+
totalDependents: visited.size - 1,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const base = { name, results };
|
|
114
|
+
return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
|
|
115
|
+
} finally {
|
|
116
|
+
db.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fix #2: Shell injection vulnerability.
|
|
122
|
+
* Uses execFileSync instead of execSync to prevent shell interpretation of user input.
|
|
123
|
+
*/
|
|
124
|
+
export function diffImpactData(customDbPath, opts = {}) {
|
|
125
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
126
|
+
try {
|
|
127
|
+
const noTests = opts.noTests || false;
|
|
128
|
+
const maxDepth = opts.depth || 3;
|
|
129
|
+
|
|
130
|
+
const dbPath = findDbPath(customDbPath);
|
|
131
|
+
const repoRoot = path.resolve(path.dirname(dbPath), '..');
|
|
132
|
+
|
|
133
|
+
// Verify we're in a git repository before running git diff
|
|
134
|
+
let checkDir = repoRoot;
|
|
135
|
+
let isGitRepo = false;
|
|
136
|
+
while (checkDir) {
|
|
137
|
+
if (fs.existsSync(path.join(checkDir, '.git'))) {
|
|
138
|
+
isGitRepo = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const parent = path.dirname(checkDir);
|
|
142
|
+
if (parent === checkDir) break;
|
|
143
|
+
checkDir = parent;
|
|
144
|
+
}
|
|
145
|
+
if (!isGitRepo) {
|
|
146
|
+
return { error: `Not a git repository: ${repoRoot}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let diffOutput;
|
|
150
|
+
try {
|
|
151
|
+
const args = opts.staged
|
|
152
|
+
? ['diff', '--cached', '--unified=0', '--no-color']
|
|
153
|
+
: ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color'];
|
|
154
|
+
diffOutput = execFileSync('git', args, {
|
|
155
|
+
cwd: repoRoot,
|
|
156
|
+
encoding: 'utf-8',
|
|
157
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
158
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
159
|
+
});
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { error: `Failed to run git diff: ${e.message}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!diffOutput.trim()) {
|
|
165
|
+
return {
|
|
166
|
+
changedFiles: 0,
|
|
167
|
+
newFiles: [],
|
|
168
|
+
affectedFunctions: [],
|
|
169
|
+
affectedFiles: [],
|
|
170
|
+
summary: null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const changedRanges = new Map();
|
|
175
|
+
const newFiles = new Set();
|
|
176
|
+
let currentFile = null;
|
|
177
|
+
let prevIsDevNull = false;
|
|
178
|
+
for (const line of diffOutput.split('\n')) {
|
|
179
|
+
if (line.startsWith('--- /dev/null')) {
|
|
180
|
+
prevIsDevNull = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (line.startsWith('--- ')) {
|
|
184
|
+
prevIsDevNull = false;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
|
|
188
|
+
if (fileMatch) {
|
|
189
|
+
currentFile = fileMatch[1];
|
|
190
|
+
if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
|
|
191
|
+
if (prevIsDevNull) newFiles.add(currentFile);
|
|
192
|
+
prevIsDevNull = false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
|
|
196
|
+
if (hunkMatch && currentFile) {
|
|
197
|
+
const start = parseInt(hunkMatch[1], 10);
|
|
198
|
+
const count = parseInt(hunkMatch[2] || '1', 10);
|
|
199
|
+
changedRanges.get(currentFile).push({ start, end: start + count - 1 });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (changedRanges.size === 0) {
|
|
204
|
+
return {
|
|
205
|
+
changedFiles: 0,
|
|
206
|
+
newFiles: [],
|
|
207
|
+
affectedFunctions: [],
|
|
208
|
+
affectedFiles: [],
|
|
209
|
+
summary: null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const affectedFunctions = [];
|
|
214
|
+
for (const [file, ranges] of changedRanges) {
|
|
215
|
+
if (noTests && isTestFile(file)) continue;
|
|
216
|
+
const defs = db
|
|
217
|
+
.prepare(
|
|
218
|
+
`SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`,
|
|
219
|
+
)
|
|
220
|
+
.all(file);
|
|
221
|
+
for (let i = 0; i < defs.length; i++) {
|
|
222
|
+
const def = defs[i];
|
|
223
|
+
const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999);
|
|
224
|
+
for (const range of ranges) {
|
|
225
|
+
if (range.start <= endLine && range.end >= def.line) {
|
|
226
|
+
affectedFunctions.push(def);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const allAffected = new Set();
|
|
234
|
+
const functionResults = affectedFunctions.map((fn) => {
|
|
235
|
+
const visited = new Set([fn.id]);
|
|
236
|
+
let frontier = [fn.id];
|
|
237
|
+
let totalCallers = 0;
|
|
238
|
+
const levels = {};
|
|
239
|
+
const edges = [];
|
|
240
|
+
const idToKey = new Map();
|
|
241
|
+
idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
|
|
242
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
243
|
+
const nextFrontier = [];
|
|
244
|
+
for (const fid of frontier) {
|
|
245
|
+
const callers = findDistinctCallers(db, fid);
|
|
246
|
+
for (const c of callers) {
|
|
247
|
+
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
|
|
248
|
+
visited.add(c.id);
|
|
249
|
+
nextFrontier.push(c.id);
|
|
250
|
+
allAffected.add(`${c.file}:${c.name}`);
|
|
251
|
+
const callerKey = `${c.file}::${c.name}:${c.line}`;
|
|
252
|
+
idToKey.set(c.id, callerKey);
|
|
253
|
+
if (!levels[d]) levels[d] = [];
|
|
254
|
+
levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
|
|
255
|
+
edges.push({ from: idToKey.get(fid), to: callerKey });
|
|
256
|
+
totalCallers++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
frontier = nextFrontier;
|
|
261
|
+
if (frontier.length === 0) break;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
name: fn.name,
|
|
265
|
+
kind: fn.kind,
|
|
266
|
+
file: fn.file,
|
|
267
|
+
line: fn.line,
|
|
268
|
+
transitiveCallers: totalCallers,
|
|
269
|
+
levels,
|
|
270
|
+
edges,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const affectedFiles = new Set();
|
|
275
|
+
for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
|
|
276
|
+
|
|
277
|
+
// Look up historically coupled files from co-change data
|
|
278
|
+
let historicallyCoupled = [];
|
|
279
|
+
try {
|
|
280
|
+
db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
|
|
281
|
+
const changedFilesList = [...changedRanges.keys()];
|
|
282
|
+
const coResults = coChangeForFiles(changedFilesList, db, {
|
|
283
|
+
minJaccard: 0.3,
|
|
284
|
+
limit: 20,
|
|
285
|
+
noTests,
|
|
286
|
+
});
|
|
287
|
+
// Exclude files already found via static analysis
|
|
288
|
+
historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
|
|
289
|
+
} catch {
|
|
290
|
+
/* co_changes table doesn't exist — skip silently */
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Look up CODEOWNERS for changed + affected files
|
|
294
|
+
let ownership = null;
|
|
295
|
+
try {
|
|
296
|
+
const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
|
|
297
|
+
const ownerResult = ownersForFiles(allFilePaths, repoRoot);
|
|
298
|
+
if (ownerResult.affectedOwners.length > 0) {
|
|
299
|
+
ownership = {
|
|
300
|
+
owners: Object.fromEntries(ownerResult.owners),
|
|
301
|
+
affectedOwners: ownerResult.affectedOwners,
|
|
302
|
+
suggestedReviewers: ownerResult.suggestedReviewers,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
/* CODEOWNERS missing or unreadable — skip silently */
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check boundary violations scoped to changed files
|
|
310
|
+
let boundaryViolations = [];
|
|
311
|
+
let boundaryViolationCount = 0;
|
|
312
|
+
try {
|
|
313
|
+
const config = loadConfig(repoRoot);
|
|
314
|
+
const boundaryConfig = config.manifesto?.boundaries;
|
|
315
|
+
if (boundaryConfig) {
|
|
316
|
+
const result = evaluateBoundaries(db, boundaryConfig, {
|
|
317
|
+
scopeFiles: [...changedRanges.keys()],
|
|
318
|
+
noTests,
|
|
319
|
+
});
|
|
320
|
+
boundaryViolations = result.violations;
|
|
321
|
+
boundaryViolationCount = result.violationCount;
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
/* boundary check failed — skip silently */
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const base = {
|
|
328
|
+
changedFiles: changedRanges.size,
|
|
329
|
+
newFiles: [...newFiles],
|
|
330
|
+
affectedFunctions: functionResults,
|
|
331
|
+
affectedFiles: [...affectedFiles],
|
|
332
|
+
historicallyCoupled,
|
|
333
|
+
ownership,
|
|
334
|
+
boundaryViolations,
|
|
335
|
+
boundaryViolationCount,
|
|
336
|
+
summary: {
|
|
337
|
+
functionsChanged: affectedFunctions.length,
|
|
338
|
+
callersAffected: allAffected.size,
|
|
339
|
+
filesAffected: affectedFiles.size,
|
|
340
|
+
historicallyCoupledCount: historicallyCoupled.length,
|
|
341
|
+
ownersAffected: ownership ? ownership.affectedOwners.length : 0,
|
|
342
|
+
boundaryViolationCount,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function diffImpactMermaid(customDbPath, opts = {}) {
|
|
352
|
+
const data = diffImpactData(customDbPath, opts);
|
|
353
|
+
if (data.error) return data.error;
|
|
354
|
+
if (data.changedFiles === 0 || data.affectedFunctions.length === 0) {
|
|
355
|
+
return 'flowchart TB\n none["No impacted functions detected"]';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const newFileSet = new Set(data.newFiles || []);
|
|
359
|
+
const lines = ['flowchart TB'];
|
|
360
|
+
|
|
361
|
+
// Assign stable Mermaid node IDs
|
|
362
|
+
let nodeCounter = 0;
|
|
363
|
+
const nodeIdMap = new Map();
|
|
364
|
+
const nodeLabels = new Map();
|
|
365
|
+
function nodeId(key, label) {
|
|
366
|
+
if (!nodeIdMap.has(key)) {
|
|
367
|
+
nodeIdMap.set(key, `n${nodeCounter++}`);
|
|
368
|
+
if (label) nodeLabels.set(key, label);
|
|
369
|
+
}
|
|
370
|
+
return nodeIdMap.get(key);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Register all nodes (changed functions + their callers)
|
|
374
|
+
for (const fn of data.affectedFunctions) {
|
|
375
|
+
nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name);
|
|
376
|
+
for (const callers of Object.values(fn.levels || {})) {
|
|
377
|
+
for (const c of callers) {
|
|
378
|
+
nodeId(`${c.file}::${c.name}:${c.line}`, c.name);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Collect all edges and determine blast radius
|
|
384
|
+
const allEdges = new Set();
|
|
385
|
+
const edgeFromNodes = new Set();
|
|
386
|
+
const edgeToNodes = new Set();
|
|
387
|
+
const changedKeys = new Set();
|
|
388
|
+
|
|
389
|
+
for (const fn of data.affectedFunctions) {
|
|
390
|
+
changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`);
|
|
391
|
+
for (const edge of fn.edges || []) {
|
|
392
|
+
const edgeKey = `${edge.from}|${edge.to}`;
|
|
393
|
+
if (!allEdges.has(edgeKey)) {
|
|
394
|
+
allEdges.add(edgeKey);
|
|
395
|
+
edgeFromNodes.add(edge.from);
|
|
396
|
+
edgeToNodes.add(edge.to);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Blast radius: caller nodes that are never a source (leaf nodes of the impact tree)
|
|
402
|
+
const blastRadiusKeys = new Set();
|
|
403
|
+
for (const key of edgeToNodes) {
|
|
404
|
+
if (!edgeFromNodes.has(key) && !changedKeys.has(key)) {
|
|
405
|
+
blastRadiusKeys.add(key);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Intermediate callers: not changed, not blast radius
|
|
410
|
+
const intermediateKeys = new Set();
|
|
411
|
+
for (const key of edgeToNodes) {
|
|
412
|
+
if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) {
|
|
413
|
+
intermediateKeys.add(key);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Group changed functions by file
|
|
418
|
+
const fileGroups = new Map();
|
|
419
|
+
for (const fn of data.affectedFunctions) {
|
|
420
|
+
if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []);
|
|
421
|
+
fileGroups.get(fn.file).push(fn);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Emit changed-file subgraphs
|
|
425
|
+
let sgCounter = 0;
|
|
426
|
+
for (const [file, fns] of fileGroups) {
|
|
427
|
+
const isNew = newFileSet.has(file);
|
|
428
|
+
const tag = isNew ? 'new' : 'modified';
|
|
429
|
+
const sgId = `sg${sgCounter++}`;
|
|
430
|
+
lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`);
|
|
431
|
+
for (const fn of fns) {
|
|
432
|
+
const key = `${fn.file}::${fn.name}:${fn.line}`;
|
|
433
|
+
lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`);
|
|
434
|
+
}
|
|
435
|
+
lines.push(' end');
|
|
436
|
+
const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800';
|
|
437
|
+
lines.push(` style ${sgId} ${style}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Emit intermediate caller nodes (outside subgraphs)
|
|
441
|
+
for (const key of intermediateKeys) {
|
|
442
|
+
lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Emit blast radius subgraph
|
|
446
|
+
if (blastRadiusKeys.size > 0) {
|
|
447
|
+
const sgId = `sg${sgCounter++}`;
|
|
448
|
+
lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`);
|
|
449
|
+
for (const key of blastRadiusKeys) {
|
|
450
|
+
lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
|
|
451
|
+
}
|
|
452
|
+
lines.push(' end');
|
|
453
|
+
lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Emit edges (impact flows from changed fn toward callers)
|
|
457
|
+
for (const edgeKey of allEdges) {
|
|
458
|
+
const [from, to] = edgeKey.split('|');
|
|
459
|
+
lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return lines.join('\n');
|
|
463
|
+
}
|