skannr 0.1.2 → 0.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/.eslintrc.js +22 -22
- package/README.md +260 -151
- package/dist/agent-cli.js +25 -25
- package/dist/agent.js +4 -4
- package/dist/blast-radius.d.ts +80 -0
- package/dist/blast-radius.d.ts.map +1 -0
- package/dist/blast-radius.js +545 -0
- package/dist/blast-radius.js.map +1 -0
- package/dist/cli.js +55 -17
- package/dist/cli.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +98 -24
- package/dist/mcp-server.js.map +1 -1
- package/dist/telemetry.js +12 -12
- package/gemini-extension/GEMINI.md +26 -26
- package/gemini-extension/gemini-extension.json +12 -12
- package/gemini-extension/package.json +14 -14
- package/gemini-extension/src/server.ts +69 -69
- package/gemini-extension/tsconfig.json +14 -14
- package/jest.config.js +4 -4
- package/package.json +54 -53
- package/src/agent-cli.ts +397 -397
- package/src/agent.ts +344 -344
- package/src/benchmark.ts +389 -389
- package/src/blast-radius.ts +661 -0
- package/src/cache.ts +317 -317
- package/src/cli.ts +461 -415
- package/src/config.ts +22 -22
- package/src/formatter.ts +105 -105
- package/src/index.ts +223 -223
- package/src/languages/GenericAdapter.ts +44 -44
- package/src/languages/LanguageAdapter.ts +21 -21
- package/src/languages/PythonAdapter.ts +285 -285
- package/src/languages/TypeScriptAdapter.ts +453 -453
- package/src/languages/registry.ts +49 -49
- package/src/mapper.ts +455 -455
- package/src/mcp-cli.ts +8 -8
- package/src/mcp-server.ts +151 -103
- package/src/ranker-enhanced.ts +514 -514
- package/src/ranker.ts +110 -110
- package/src/scanner.ts +226 -226
- package/src/skeletonizer.ts +22 -22
- package/src/telemetry.ts +146 -146
- package/src/tokenizer.ts +20 -20
- package/src/types.ts +92 -92
- package/src/watcher.ts +119 -119
- package/src/why.ts +88 -88
- package/tests/agent.tools.test.ts +81 -81
- package/tests/benchmark.test.ts +30 -30
- package/tests/blast-radius.test.ts +290 -0
- package/tests/fixtures/sample.py +17 -17
- package/tests/fixtures/sample.ts +13 -13
- package/tests/fixtures/src/api/routes.ts +1 -1
- package/tests/fixtures/src/auth/permission.ts +3 -3
- package/tests/python-adapter.test.ts +31 -31
- package/tests/ranker-enhanced.test.ts +70 -70
- package/tests/ranker.test.ts +79 -79
- package/tests/scanner.scope.test.ts +66 -66
- package/tests/setup-fixtures.js +28 -28
- package/tests/skeletonizer.test.ts +149 -149
- package/tsconfig.json +21 -21
- package/uca-landing/index.html +17 -17
- package/uca-landing/package.json +23 -23
- package/uca-landing/postcss.config.js +6 -6
- package/uca-landing/src/App.jsx +43 -43
- package/uca-landing/src/components/AgentMode.jsx +45 -45
- package/uca-landing/src/components/CliReference.jsx +58 -58
- package/uca-landing/src/components/Features.jsx +82 -82
- package/uca-landing/src/components/Footer.jsx +35 -35
- package/uca-landing/src/components/Hero.jsx +125 -125
- package/uca-landing/src/components/HowItWorks.jsx +60 -60
- package/uca-landing/src/components/Install.jsx +103 -103
- package/uca-landing/src/components/LanguageSupport.jsx +63 -63
- package/uca-landing/src/components/Navbar.jsx +86 -86
- package/uca-landing/src/components/Problem.jsx +51 -51
- package/uca-landing/src/components/Reveal.jsx +40 -40
- package/uca-landing/src/components/WorksWith.jsx +59 -59
- package/uca-landing/src/hooks/useScrollNav.js +13 -13
- package/uca-landing/src/hooks/useTypewriter.js +41 -41
- package/uca-landing/src/index.css +13 -13
- package/uca-landing/src/main.jsx +10 -10
- package/uca-landing/tailwind.config.js +68 -68
- package/uca-landing/vercel.json +3 -3
- package/uca-landing/vite.config.js +6 -6
- package/dist/rocket-chat-scope.d.ts +0 -7
- package/dist/rocket-chat-scope.d.ts.map +0 -1
- package/dist/rocket-chat-scope.js +0 -95
- package/dist/rocket-chat-scope.js.map +0 -1
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blast Radius: determines downstream impact and risk of a git diff.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: parse diff → identify changed files → traverse reverse dependency
|
|
5
|
+
* graph N hops → score affected nodes by centrality → flag untested nodes →
|
|
6
|
+
* compute aggregate risk score.
|
|
7
|
+
*
|
|
8
|
+
* Limitation (v1): graph traversal is file-level, not symbol-level.
|
|
9
|
+
* A changed file is treated as a unit; individual function-call edges are not
|
|
10
|
+
* tracked. Symbol extraction from diffs is used for reporting only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import parseDiff from 'parse-diff';
|
|
17
|
+
import { scanFiles, readFileContent } from './scanner';
|
|
18
|
+
import { analyzeDependencyGraph } from './ranker-enhanced';
|
|
19
|
+
import { getAdapter } from './languages/registry';
|
|
20
|
+
import { loadConfig } from './config';
|
|
21
|
+
import type { Symbol } from './languages/LanguageAdapter';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Risk formula weights (sum = 10). Shift toward WEIGHT_UNTESTED because
|
|
25
|
+
// untested affected code is the strongest signal that a merge is risky.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const WEIGHT_AFFECTED_COUNT = 2.5;
|
|
28
|
+
const WEIGHT_CENTRALITY = 2.5;
|
|
29
|
+
const WEIGHT_UNTESTED = 3.5;
|
|
30
|
+
const WEIGHT_HOP_SPREAD = 1.5;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Types
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** A symbol touched by the diff within a single file. */
|
|
37
|
+
export interface ChangedSymbol {
|
|
38
|
+
name: string;
|
|
39
|
+
kind: Symbol['kind'];
|
|
40
|
+
file: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A downstream file affected by the change, with metadata. */
|
|
44
|
+
export interface AffectedNode {
|
|
45
|
+
/** Path relative to project root. */
|
|
46
|
+
file: string;
|
|
47
|
+
/** Number of hops from a changed file (1 = direct importer). */
|
|
48
|
+
hopDistance: number;
|
|
49
|
+
/** Normalized centrality score [0,1] — how many files depend on this one. */
|
|
50
|
+
centrality: number;
|
|
51
|
+
/** Whether a corresponding test file was found for this file. */
|
|
52
|
+
hasTest: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Full blast-radius analysis result (shared between CLI text and JSON output). */
|
|
56
|
+
export interface BlastRadiusResult {
|
|
57
|
+
/** Aggregate risk score (0–10). */
|
|
58
|
+
riskScore: number;
|
|
59
|
+
/** One-line human-readable summary. */
|
|
60
|
+
summary: string;
|
|
61
|
+
/** Files directly modified by the diff (relative paths). */
|
|
62
|
+
changedFiles: string[];
|
|
63
|
+
/** Symbols identified as changed within those files. */
|
|
64
|
+
changedSymbols: ChangedSymbol[];
|
|
65
|
+
/** All downstream affected files grouped by hop distance. */
|
|
66
|
+
affectedNodes: AffectedNode[];
|
|
67
|
+
/** Breakdown of the risk formula inputs (for transparency). */
|
|
68
|
+
formulaInputs: {
|
|
69
|
+
normalizedAffectedCount: number;
|
|
70
|
+
avgCentrality: number;
|
|
71
|
+
untestedRatio: number;
|
|
72
|
+
normalizedMaxHopSpread: number;
|
|
73
|
+
};
|
|
74
|
+
/** Max hops used for this analysis. */
|
|
75
|
+
hops: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options accepted by computeBlastRadius. */
|
|
79
|
+
export interface BlastRadiusOptions {
|
|
80
|
+
/** Absolute path to project root. */
|
|
81
|
+
root: string;
|
|
82
|
+
/** Raw unified diff content. If omitted, working-tree diff against HEAD is used. */
|
|
83
|
+
diffContent?: string;
|
|
84
|
+
/** Path to a diff file on disk. Takes precedence over diffContent if both given. */
|
|
85
|
+
diffPath?: string;
|
|
86
|
+
/** Maximum hop distance for traversal (default: 2). */
|
|
87
|
+
hops?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Public entry point
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Compute the blast radius for a diff against a project. */
|
|
95
|
+
export function computeBlastRadius(options: BlastRadiusOptions): BlastRadiusResult {
|
|
96
|
+
const { root, hops = 2 } = options;
|
|
97
|
+
const diff = resolveDiff(options);
|
|
98
|
+
const parsedFiles = parseDiff(diff);
|
|
99
|
+
|
|
100
|
+
// 1. Extract changed file paths (relative to root).
|
|
101
|
+
const changedFiles = extractChangedFiles(parsedFiles, root);
|
|
102
|
+
|
|
103
|
+
if (changedFiles.length === 0) {
|
|
104
|
+
return emptyResult(hops);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Extract changed symbols for reporting.
|
|
108
|
+
const changedSymbols = extractChangedSymbolsFromDiff(parsedFiles, root);
|
|
109
|
+
|
|
110
|
+
// 3. Build reverse dependency graph for the project.
|
|
111
|
+
const allFiles = scanAllProjectFiles(root);
|
|
112
|
+
const forwardGraph = analyzeDependencyGraph(allFiles, root);
|
|
113
|
+
const reverseGraph = buildReverseGraph(forwardGraph);
|
|
114
|
+
|
|
115
|
+
// 4. BFS N hops outward on reverse graph from changed files.
|
|
116
|
+
const affectedMap = traverseAffected(changedFiles, reverseGraph, hops);
|
|
117
|
+
|
|
118
|
+
// 5. Compute centrality for each affected node.
|
|
119
|
+
const centralityMap = computeCentralityMap(reverseGraph, allFiles, root);
|
|
120
|
+
|
|
121
|
+
// 6. Detect test files.
|
|
122
|
+
const testFileSet = detectTestFiles(allFiles, root);
|
|
123
|
+
|
|
124
|
+
// 7. Assemble AffectedNode list.
|
|
125
|
+
const affectedNodes = buildAffectedNodes(affectedMap, centralityMap, testFileSet);
|
|
126
|
+
|
|
127
|
+
// 8. Compute risk score.
|
|
128
|
+
const formulaInputs = computeFormulaInputs(affectedNodes, allFiles.length, hops);
|
|
129
|
+
const riskScore = computeRisk(formulaInputs);
|
|
130
|
+
const summary = buildSummary(riskScore, changedFiles, affectedNodes);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
riskScore,
|
|
134
|
+
summary,
|
|
135
|
+
changedFiles,
|
|
136
|
+
changedSymbols,
|
|
137
|
+
affectedNodes,
|
|
138
|
+
formulaInputs,
|
|
139
|
+
hops,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Diff resolution
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Resolve the diff content from options: file path > explicit string > git working tree. */
|
|
148
|
+
function resolveDiff(options: BlastRadiusOptions): string {
|
|
149
|
+
if (options.diffPath) {
|
|
150
|
+
const absPath = path.resolve(options.root, options.diffPath);
|
|
151
|
+
if (!fs.existsSync(absPath)) {
|
|
152
|
+
throw new Error(`Diff file not found: ${absPath}`);
|
|
153
|
+
}
|
|
154
|
+
return fs.readFileSync(absPath, 'utf-8');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.diffContent !== undefined) {
|
|
158
|
+
return options.diffContent;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Default: working tree diff against HEAD.
|
|
162
|
+
try {
|
|
163
|
+
return execSync('git diff HEAD', {
|
|
164
|
+
cwd: options.root,
|
|
165
|
+
encoding: 'utf-8',
|
|
166
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Failed to run "git diff HEAD" in ${options.root}: ${err instanceof Error ? err.message : String(err)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Changed file extraction
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Map diff file entries to relative paths within the project root. */
|
|
180
|
+
function extractChangedFiles(parsedFiles: parseDiff.File[], root: string): string[] {
|
|
181
|
+
const files: string[] = [];
|
|
182
|
+
for (const file of parsedFiles) {
|
|
183
|
+
// parse-diff gives paths like "a/src/foo.ts" or "b/src/foo.ts"
|
|
184
|
+
const raw = file.to ?? file.from;
|
|
185
|
+
if (!raw || raw === '/dev/null') continue;
|
|
186
|
+
// Strip the leading a/ or b/ prefix that git uses.
|
|
187
|
+
const rel = raw.replace(/^[ab]\//, '');
|
|
188
|
+
// Only include if the file exists in the project.
|
|
189
|
+
if (fs.existsSync(path.join(root, rel))) {
|
|
190
|
+
files.push(rel);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return [...new Set(files)];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Changed symbol extraction (reporting only — traversal is file-level)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/** Identify which symbols fall within changed line ranges in the diff. */
|
|
201
|
+
function extractChangedSymbolsFromDiff(
|
|
202
|
+
parsedFiles: parseDiff.File[],
|
|
203
|
+
root: string,
|
|
204
|
+
): ChangedSymbol[] {
|
|
205
|
+
const symbols: ChangedSymbol[] = [];
|
|
206
|
+
|
|
207
|
+
for (const file of parsedFiles) {
|
|
208
|
+
const raw = file.to ?? file.from;
|
|
209
|
+
if (!raw || raw === '/dev/null') continue;
|
|
210
|
+
const rel = raw.replace(/^[ab]\//, '');
|
|
211
|
+
const absPath = path.join(root, rel);
|
|
212
|
+
if (!fs.existsSync(absPath)) continue;
|
|
213
|
+
|
|
214
|
+
const content = readFileContent(absPath);
|
|
215
|
+
if (!content) continue;
|
|
216
|
+
|
|
217
|
+
const adapter = getAdapter(absPath);
|
|
218
|
+
const fileSymbols = adapter.extractSymbols(content);
|
|
219
|
+
|
|
220
|
+
// Collect changed line numbers from the diff.
|
|
221
|
+
const changedLines = new Set<number>();
|
|
222
|
+
for (const chunk of file.chunks) {
|
|
223
|
+
for (const change of chunk.changes) {
|
|
224
|
+
if (change.type === 'add' || change.type === 'del') {
|
|
225
|
+
// 'ln' for add, 'ln' for del (line number in respective file)
|
|
226
|
+
const lineNum = 'ln' in change ? (change as any).ln : undefined;
|
|
227
|
+
if (typeof lineNum === 'number') {
|
|
228
|
+
changedLines.add(lineNum);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Match symbols whose line overlaps a changed line.
|
|
235
|
+
for (const sym of fileSymbols) {
|
|
236
|
+
if (sym.kind === 'export') continue; // skip synthetic export markers
|
|
237
|
+
if (changedLines.has(sym.line)) {
|
|
238
|
+
symbols.push({ name: sym.name, kind: sym.kind, file: rel });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return symbols;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Reverse dependency graph
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Invert the forward graph (file → imports) into a reverse graph
|
|
252
|
+
* (file → files that import it).
|
|
253
|
+
*/
|
|
254
|
+
export function buildReverseGraph(
|
|
255
|
+
forwardGraph: Map<string, string[]>,
|
|
256
|
+
): Map<string, string[]> {
|
|
257
|
+
const reverse = new Map<string, string[]>();
|
|
258
|
+
|
|
259
|
+
for (const [file, imports] of forwardGraph.entries()) {
|
|
260
|
+
for (const imp of imports) {
|
|
261
|
+
// The forward graph stores resolved relative paths (potentially without ext).
|
|
262
|
+
// Match by checking if the import target's basename matches any known file.
|
|
263
|
+
const importers = reverse.get(imp) ?? [];
|
|
264
|
+
importers.push(file);
|
|
265
|
+
reverse.set(imp, importers);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return reverse;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// BFS traversal
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* BFS from changed files outward on the reverse graph, up to maxHops.
|
|
278
|
+
* Returns a map of affected file → hop distance (excluding the changed files
|
|
279
|
+
* themselves).
|
|
280
|
+
*/
|
|
281
|
+
export function traverseAffected(
|
|
282
|
+
changedFiles: string[],
|
|
283
|
+
reverseGraph: Map<string, string[]>,
|
|
284
|
+
maxHops: number,
|
|
285
|
+
): Map<string, number> {
|
|
286
|
+
const visited = new Map<string, number>();
|
|
287
|
+
const queue: Array<{ file: string; hop: number }> = [];
|
|
288
|
+
|
|
289
|
+
// Seed with changed files at hop 0 (they won't appear in output).
|
|
290
|
+
for (const file of changedFiles) {
|
|
291
|
+
visited.set(file, 0);
|
|
292
|
+
queue.push({ file, hop: 0 });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
while (queue.length > 0) {
|
|
296
|
+
const { file, hop } = queue.shift()!;
|
|
297
|
+
if (hop >= maxHops) continue;
|
|
298
|
+
|
|
299
|
+
const importers = findImporters(file, reverseGraph);
|
|
300
|
+
for (const importer of importers) {
|
|
301
|
+
if (!visited.has(importer)) {
|
|
302
|
+
const nextHop = hop + 1;
|
|
303
|
+
visited.set(importer, nextHop);
|
|
304
|
+
queue.push({ file: importer, hop: nextHop });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Remove the seed changed files from the result.
|
|
310
|
+
for (const file of changedFiles) {
|
|
311
|
+
visited.delete(file);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return visited;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Find files that import the target, handling partial path matching.
|
|
319
|
+
* The reverse graph keys may be partial (e.g. "src/utils" without extension)
|
|
320
|
+
* and use OS-specific separators, so we normalize and match flexibly.
|
|
321
|
+
*/
|
|
322
|
+
function findImporters(target: string, reverseGraph: Map<string, string[]>): string[] {
|
|
323
|
+
const normalize = (p: string) => p.split(path.sep).join('/');
|
|
324
|
+
const normalizedTarget = normalize(target);
|
|
325
|
+
const targetNoExt = normalizedTarget.replace(/\.[^.]+$/, '');
|
|
326
|
+
const targetBase = path.basename(target, path.extname(target));
|
|
327
|
+
|
|
328
|
+
// Direct lookup (normalized).
|
|
329
|
+
for (const [key, files] of reverseGraph.entries()) {
|
|
330
|
+
const normalizedKey = normalize(key);
|
|
331
|
+
if (normalizedKey === normalizedTarget || normalizedKey === targetNoExt) {
|
|
332
|
+
return files;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Fuzzy match: target is "src/utils.ts", graph key might be "src/utils".
|
|
337
|
+
const importers: string[] = [];
|
|
338
|
+
for (const [key, files] of reverseGraph.entries()) {
|
|
339
|
+
const normalizedKey = normalize(key);
|
|
340
|
+
if (normalizedKey.endsWith('/' + targetBase) || normalizedKey === targetBase) {
|
|
341
|
+
importers.push(...files);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return importers;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Centrality computation
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Compute normalized in-degree centrality for all project files.
|
|
354
|
+
* Centrality = (number of files that import this file) / max(in-degrees).
|
|
355
|
+
* This reuses the reverse graph already built from analyzeDependencyGraph output.
|
|
356
|
+
*/
|
|
357
|
+
function computeCentralityMap(
|
|
358
|
+
reverseGraph: Map<string, string[]>,
|
|
359
|
+
allFiles: string[],
|
|
360
|
+
root: string,
|
|
361
|
+
): Map<string, number> {
|
|
362
|
+
// Count in-degree for each file (by relative path).
|
|
363
|
+
const inDegree = new Map<string, number>();
|
|
364
|
+
for (const file of allFiles) {
|
|
365
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
366
|
+
inDegree.set(rel, 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const [_, importers] of reverseGraph.entries()) {
|
|
370
|
+
// Each entry's importers list tells us who imports the key.
|
|
371
|
+
// The key itself gains in-degree equal to importers.length — but that's
|
|
372
|
+
// backwards. The importers are the files that import the key.
|
|
373
|
+
// So the key's in-degree (how many depend on it) = importers.length.
|
|
374
|
+
// But we want: for each affected file, how many files depend on IT.
|
|
375
|
+
// So we need reverse-of-reverse = forward. Let's just count directly.
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Simpler approach: count how many times each file appears as an import target.
|
|
379
|
+
// That's exactly what the reverse graph encodes: key = import target, value = importers.
|
|
380
|
+
for (const [target, importers] of reverseGraph.entries()) {
|
|
381
|
+
const existing = inDegree.get(target);
|
|
382
|
+
if (existing !== undefined) {
|
|
383
|
+
inDegree.set(target, importers.length);
|
|
384
|
+
} else {
|
|
385
|
+
// target may be a partial path — try to match to an allFiles entry
|
|
386
|
+
for (const file of allFiles) {
|
|
387
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
388
|
+
const relNoExt = rel.replace(/\.[^.]+$/, '');
|
|
389
|
+
if (rel === target || relNoExt === target || relNoExt.endsWith('/' + target)) {
|
|
390
|
+
inDegree.set(rel, (inDegree.get(rel) ?? 0) + importers.length);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Normalize to [0,1].
|
|
397
|
+
const maxDegree = Math.max(1, ...inDegree.values());
|
|
398
|
+
const centralityMap = new Map<string, number>();
|
|
399
|
+
for (const [file, degree] of inDegree.entries()) {
|
|
400
|
+
centralityMap.set(file, degree / maxDegree);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return centralityMap;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Test file detection
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Detect test files using the conventions present in this repo:
|
|
412
|
+
* - Files in tests/ directory with .test.ts suffix
|
|
413
|
+
* - Files matching *.test.ts, *.spec.ts anywhere
|
|
414
|
+
* - Files inside __tests__/ directories
|
|
415
|
+
*
|
|
416
|
+
* Returns a set of source-file relative paths that have at least one test file.
|
|
417
|
+
*/
|
|
418
|
+
function detectTestFiles(allFiles: string[], root: string): Set<string> {
|
|
419
|
+
const testPatterns = /\.(test|spec)\.(ts|tsx|js|jsx)$|[/\\]__tests__[/\\]/;
|
|
420
|
+
const testedSources = new Set<string>();
|
|
421
|
+
|
|
422
|
+
// Collect all test files.
|
|
423
|
+
const testFiles: string[] = [];
|
|
424
|
+
for (const file of allFiles) {
|
|
425
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
426
|
+
if (testPatterns.test(rel)) {
|
|
427
|
+
testFiles.push(rel);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Also scan the tests/ directory specifically (may not be in allFiles if not
|
|
432
|
+
// under a scanned module).
|
|
433
|
+
const testsDir = path.join(root, 'tests');
|
|
434
|
+
if (fs.existsSync(testsDir)) {
|
|
435
|
+
const entries = fs.readdirSync(testsDir, { recursive: true });
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
const rel = 'tests/' + String(entry).split(path.sep).join('/');
|
|
438
|
+
if (testPatterns.test(rel)) {
|
|
439
|
+
testFiles.push(rel);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// For each test file, infer which source file it covers.
|
|
445
|
+
// Convention: tests/ranker.test.ts covers src/ranker.ts
|
|
446
|
+
// src/__tests__/foo.test.ts covers src/foo.ts
|
|
447
|
+
for (const testFile of testFiles) {
|
|
448
|
+
const base = path.basename(testFile)
|
|
449
|
+
.replace(/\.(test|spec)\.(ts|tsx|js|jsx)$/, '');
|
|
450
|
+
|
|
451
|
+
// Match against all known source-relative paths.
|
|
452
|
+
for (const file of allFiles) {
|
|
453
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
454
|
+
if (testPatterns.test(rel)) continue; // skip other test files
|
|
455
|
+
const srcBase = path.basename(rel, path.extname(rel));
|
|
456
|
+
if (srcBase === base) {
|
|
457
|
+
testedSources.add(rel);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return testedSources;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Assemble affected nodes
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
function buildAffectedNodes(
|
|
470
|
+
affectedMap: Map<string, number>,
|
|
471
|
+
centralityMap: Map<string, number>,
|
|
472
|
+
testFileSet: Set<string>,
|
|
473
|
+
): AffectedNode[] {
|
|
474
|
+
const nodes: AffectedNode[] = [];
|
|
475
|
+
for (const [file, hopDistance] of affectedMap.entries()) {
|
|
476
|
+
nodes.push({
|
|
477
|
+
file,
|
|
478
|
+
hopDistance,
|
|
479
|
+
centrality: centralityMap.get(file) ?? 0,
|
|
480
|
+
hasTest: testFileSet.has(file),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Sort: higher centrality first within each hop group.
|
|
484
|
+
nodes.sort((a, b) => a.hopDistance - b.hopDistance || b.centrality - a.centrality);
|
|
485
|
+
return nodes;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Risk score computation
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Risk score formula (0–10):
|
|
494
|
+
*
|
|
495
|
+
* risk = WEIGHT_AFFECTED_COUNT * normalizedAffectedCount
|
|
496
|
+
* + WEIGHT_CENTRALITY * avgCentrality
|
|
497
|
+
* + WEIGHT_UNTESTED * untestedRatio
|
|
498
|
+
* + WEIGHT_HOP_SPREAD * normalizedMaxHopSpread
|
|
499
|
+
*
|
|
500
|
+
* Where:
|
|
501
|
+
* normalizedAffectedCount = min(affectedCount / totalProjectFiles, 1)
|
|
502
|
+
* avgCentrality = mean centrality of affected nodes [0,1]
|
|
503
|
+
* untestedRatio = fraction of affected nodes with no test [0,1]
|
|
504
|
+
* normalizedMaxHopSpread = maxHopReached / maxHopsConfigured [0,1]
|
|
505
|
+
*
|
|
506
|
+
* Weights: 2.5 + 2.5 + 3.5 + 1.5 = 10
|
|
507
|
+
*/
|
|
508
|
+
function computeFormulaInputs(
|
|
509
|
+
affectedNodes: AffectedNode[],
|
|
510
|
+
totalFiles: number,
|
|
511
|
+
maxHops: number,
|
|
512
|
+
): BlastRadiusResult['formulaInputs'] {
|
|
513
|
+
if (affectedNodes.length === 0) {
|
|
514
|
+
return {
|
|
515
|
+
normalizedAffectedCount: 0,
|
|
516
|
+
avgCentrality: 0,
|
|
517
|
+
untestedRatio: 0,
|
|
518
|
+
normalizedMaxHopSpread: 0,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const normalizedAffectedCount = Math.min(affectedNodes.length / Math.max(totalFiles, 1), 1);
|
|
523
|
+
const avgCentrality =
|
|
524
|
+
affectedNodes.reduce((sum, n) => sum + n.centrality, 0) / affectedNodes.length;
|
|
525
|
+
const untestedCount = affectedNodes.filter((n) => !n.hasTest).length;
|
|
526
|
+
const untestedRatio = untestedCount / affectedNodes.length;
|
|
527
|
+
const maxHopReached = Math.max(...affectedNodes.map((n) => n.hopDistance));
|
|
528
|
+
const normalizedMaxHopSpread = maxHopReached / Math.max(maxHops, 1);
|
|
529
|
+
|
|
530
|
+
return { normalizedAffectedCount, avgCentrality, untestedRatio, normalizedMaxHopSpread };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function computeRisk(inputs: BlastRadiusResult['formulaInputs']): number {
|
|
534
|
+
const raw =
|
|
535
|
+
WEIGHT_AFFECTED_COUNT * inputs.normalizedAffectedCount +
|
|
536
|
+
WEIGHT_CENTRALITY * inputs.avgCentrality +
|
|
537
|
+
WEIGHT_UNTESTED * inputs.untestedRatio +
|
|
538
|
+
WEIGHT_HOP_SPREAD * inputs.normalizedMaxHopSpread;
|
|
539
|
+
|
|
540
|
+
return Math.round(raw * 10) / 10; // one decimal place
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Summary
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
function buildSummary(
|
|
548
|
+
riskScore: number,
|
|
549
|
+
changedFiles: string[],
|
|
550
|
+
affectedNodes: AffectedNode[],
|
|
551
|
+
): string {
|
|
552
|
+
const untested = affectedNodes.filter((n) => !n.hasTest).length;
|
|
553
|
+
const riskLabel =
|
|
554
|
+
riskScore <= 2 ? 'low' : riskScore <= 5 ? 'moderate' : riskScore <= 7.5 ? 'high' : 'critical';
|
|
555
|
+
return (
|
|
556
|
+
`Risk ${riskScore}/10 (${riskLabel}): ` +
|
|
557
|
+
`${changedFiles.length} file(s) changed, ` +
|
|
558
|
+
`${affectedNodes.length} downstream affected, ` +
|
|
559
|
+
`${untested} untested.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Output formatters
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
/** Plain-text report for terminal output. */
|
|
568
|
+
export function formatBlastRadiusText(result: BlastRadiusResult): string {
|
|
569
|
+
const lines: string[] = [];
|
|
570
|
+
|
|
571
|
+
lines.push('');
|
|
572
|
+
lines.push(` Blast Radius Analysis (${result.hops}-hop traversal)`);
|
|
573
|
+
lines.push(' ' + '─'.repeat(50));
|
|
574
|
+
lines.push('');
|
|
575
|
+
lines.push(` Risk Score: ${result.riskScore}/10`);
|
|
576
|
+
lines.push(` ${result.summary}`);
|
|
577
|
+
lines.push('');
|
|
578
|
+
|
|
579
|
+
// Changed files.
|
|
580
|
+
lines.push(' Changed files:');
|
|
581
|
+
for (const f of result.changedFiles) {
|
|
582
|
+
lines.push(` ${f}`);
|
|
583
|
+
}
|
|
584
|
+
lines.push('');
|
|
585
|
+
|
|
586
|
+
// Changed symbols.
|
|
587
|
+
if (result.changedSymbols.length > 0) {
|
|
588
|
+
lines.push(' Changed symbols:');
|
|
589
|
+
for (const s of result.changedSymbols) {
|
|
590
|
+
lines.push(` ${s.kind} ${s.name} (${s.file})`);
|
|
591
|
+
}
|
|
592
|
+
lines.push('');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Affected nodes grouped by hop.
|
|
596
|
+
if (result.affectedNodes.length > 0) {
|
|
597
|
+
const maxHop = Math.max(...result.affectedNodes.map((n) => n.hopDistance));
|
|
598
|
+
for (let hop = 1; hop <= maxHop; hop++) {
|
|
599
|
+
const atHop = result.affectedNodes.filter((n) => n.hopDistance === hop);
|
|
600
|
+
if (atHop.length === 0) continue;
|
|
601
|
+
lines.push(` Hop ${hop} (${atHop.length} file${atHop.length > 1 ? 's' : ''}):`);
|
|
602
|
+
for (const node of atHop) {
|
|
603
|
+
const testTag = node.hasTest ? '' : ' [NO TEST]';
|
|
604
|
+
lines.push(
|
|
605
|
+
` ${node.file} centrality=${node.centrality.toFixed(2)}${testTag}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
lines.push('');
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
lines.push(' No downstream files affected.');
|
|
612
|
+
lines.push('');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Formula transparency.
|
|
616
|
+
const fi = result.formulaInputs;
|
|
617
|
+
lines.push(' Formula inputs:');
|
|
618
|
+
lines.push(` affected_count (normalized): ${fi.normalizedAffectedCount.toFixed(3)}`);
|
|
619
|
+
lines.push(` avg_centrality: ${fi.avgCentrality.toFixed(3)}`);
|
|
620
|
+
lines.push(` untested_ratio: ${fi.untestedRatio.toFixed(3)}`);
|
|
621
|
+
lines.push(` hop_spread (normalized): ${fi.normalizedMaxHopSpread.toFixed(3)}`);
|
|
622
|
+
lines.push('');
|
|
623
|
+
|
|
624
|
+
return lines.join('\n');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** JSON output (for --json flag and MCP). */
|
|
628
|
+
export function formatBlastRadiusJson(result: BlastRadiusResult): string {
|
|
629
|
+
return JSON.stringify(result, null, 2);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// Helpers
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
/** Scan all project source files using existing scanner infrastructure. */
|
|
637
|
+
function scanAllProjectFiles(root: string): string[] {
|
|
638
|
+
const config = loadConfig(root);
|
|
639
|
+
return scanFiles(root, {
|
|
640
|
+
extensions: config.extensions,
|
|
641
|
+
exclude: config.exclude,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Return an empty result when no files were changed. */
|
|
646
|
+
function emptyResult(hops: number): BlastRadiusResult {
|
|
647
|
+
return {
|
|
648
|
+
riskScore: 0,
|
|
649
|
+
summary: 'No changed files detected in diff.',
|
|
650
|
+
changedFiles: [],
|
|
651
|
+
changedSymbols: [],
|
|
652
|
+
affectedNodes: [],
|
|
653
|
+
formulaInputs: {
|
|
654
|
+
normalizedAffectedCount: 0,
|
|
655
|
+
avgCentrality: 0,
|
|
656
|
+
untestedRatio: 0,
|
|
657
|
+
normalizedMaxHopSpread: 0,
|
|
658
|
+
},
|
|
659
|
+
hops,
|
|
660
|
+
};
|
|
661
|
+
}
|