@syke1/mcp-server 1.4.17 → 1.4.19
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 +82 -0
- package/dist/ai/realtime-analyzer.js +1 -1
- package/dist/git/change-coupling.d.ts +41 -0
- package/dist/git/change-coupling.js +250 -0
- package/dist/graph/incremental.d.ts +35 -0
- package/dist/graph/incremental.js +319 -0
- package/dist/graph/memo-cache.d.ts +47 -0
- package/dist/graph/memo-cache.js +176 -0
- package/dist/graph/scc.d.ts +57 -0
- package/dist/graph/scc.js +206 -0
- package/dist/graph.d.ts +6 -0
- package/dist/graph.js +17 -1
- package/dist/index.js +151 -11
- package/dist/scoring/pagerank.d.ts +67 -0
- package/dist/scoring/pagerank.js +221 -0
- package/dist/scoring/risk-scorer.d.ts +99 -0
- package/dist/scoring/risk-scorer.js +623 -0
- package/dist/tools/analyze-impact.d.ts +36 -1
- package/dist/tools/analyze-impact.js +278 -2
- package/dist/tools/gate-build.d.ts +7 -2
- package/dist/tools/gate-build.js +179 -13
- package/dist/watcher/file-cache.d.ts +9 -0
- package/dist/watcher/file-cache.js +40 -0
- package/dist/web/server.js +20 -3
- package/package.json +1 -1
|
@@ -34,14 +34,198 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.analyzeImpact = analyzeImpact;
|
|
37
|
+
exports.getImpactMemoCache = getImpactMemoCache;
|
|
37
38
|
exports.classifyRisk = classifyRisk;
|
|
38
39
|
exports.getHubFiles = getHubFiles;
|
|
39
40
|
const path = __importStar(require("path"));
|
|
41
|
+
const risk_scorer_1 = require("../scoring/risk-scorer");
|
|
42
|
+
const change_coupling_1 = require("../git/change-coupling");
|
|
43
|
+
const memo_cache_1 = require("../graph/memo-cache");
|
|
40
44
|
/**
|
|
41
45
|
* BFS reverse traversal to find all files impacted by modifying `filePath`.
|
|
46
|
+
* When SCC data is available, uses the condensed DAG for more accurate
|
|
47
|
+
* cascade-level analysis and circular dependency detection.
|
|
48
|
+
*
|
|
49
|
+
* Optionally computes a composite risk score when `includeRiskScore` is true.
|
|
50
|
+
* Optionally computes historical change coupling when `includeCoupling` is true.
|
|
42
51
|
*/
|
|
43
|
-
function analyzeImpact(filePath, graph) {
|
|
52
|
+
async function analyzeImpact(filePath, graph, options) {
|
|
44
53
|
const normalized = path.normalize(filePath);
|
|
54
|
+
const toRelative = (f) => path.relative(graph.sourceDir, f).replace(/\\/g, "/");
|
|
55
|
+
// Check memo cache for a cached BFS result (fast path)
|
|
56
|
+
const memoCache = (0, memo_cache_1.getMemoCache)();
|
|
57
|
+
const cached = memoCache.get(normalized);
|
|
58
|
+
if (cached) {
|
|
59
|
+
// Reconstruct ImpactResult from cached MemoEntry
|
|
60
|
+
const directDependents = (graph.reverse.get(normalized) || []).map(toRelative);
|
|
61
|
+
const directSet = new Set(directDependents);
|
|
62
|
+
const transitiveDependents = cached.impactSet
|
|
63
|
+
.map(f => toRelative(f))
|
|
64
|
+
.filter(rel => !directSet.has(rel));
|
|
65
|
+
let result = {
|
|
66
|
+
filePath: normalized,
|
|
67
|
+
relativePath: toRelative(normalized),
|
|
68
|
+
riskLevel: cached.riskLevel,
|
|
69
|
+
directDependents,
|
|
70
|
+
transitiveDependents,
|
|
71
|
+
totalImpacted: cached.directCount + cached.transitiveCount,
|
|
72
|
+
cascadeLevels: cached.cascadeLevels,
|
|
73
|
+
fromCache: true,
|
|
74
|
+
};
|
|
75
|
+
// Still compute risk score and coupling on top of cached BFS result
|
|
76
|
+
if (options?.includeRiskScore && graph.files.has(normalized)) {
|
|
77
|
+
try {
|
|
78
|
+
(0, risk_scorer_1.computeProjectMetrics)(graph);
|
|
79
|
+
const riskScore = (0, risk_scorer_1.getRiskScore)(normalized, graph, options.fileContent ?? null);
|
|
80
|
+
result.riskScore = riskScore;
|
|
81
|
+
const compositeLevel = riskScore.riskLevel;
|
|
82
|
+
if (isHigherRisk(compositeLevel, result.riskLevel)) {
|
|
83
|
+
result.riskLevel = mapCompositeToLegacy(compositeLevel);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
console.error(`[syke:scoring] Failed to compute risk score for ${filePath}: ${err}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (options?.includeCoupling) {
|
|
91
|
+
try {
|
|
92
|
+
const coupledFiles = await computeCoupledFiles(normalized, graph, toRelative);
|
|
93
|
+
if (coupledFiles.length > 0) {
|
|
94
|
+
result.coupledFiles = coupledFiles;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`[syke:coupling] Failed to compute change coupling for ${filePath}: ${err}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
// Cache miss: run full BFS analysis
|
|
104
|
+
let result;
|
|
105
|
+
if (graph.scc) {
|
|
106
|
+
result = analyzeImpactWithSCC(normalized, graph, toRelative);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Fallback: original BFS on raw graph (backward compatible)
|
|
110
|
+
result = analyzeImpactBFS(normalized, graph, toRelative);
|
|
111
|
+
}
|
|
112
|
+
// Store BFS result in memo cache for future queries
|
|
113
|
+
const allImpactedAbsPaths = [
|
|
114
|
+
...result.directDependents,
|
|
115
|
+
...result.transitiveDependents,
|
|
116
|
+
].map(rel => path.normalize(path.join(graph.sourceDir, rel)));
|
|
117
|
+
memoCache.set(normalized, {
|
|
118
|
+
impactSet: allImpactedAbsPaths,
|
|
119
|
+
directCount: result.directDependents.length,
|
|
120
|
+
transitiveCount: result.transitiveDependents.length,
|
|
121
|
+
riskLevel: result.riskLevel,
|
|
122
|
+
cascadeLevels: result.cascadeLevels,
|
|
123
|
+
computedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
// Compute composite risk score if requested
|
|
126
|
+
if (options?.includeRiskScore && graph.files.has(normalized)) {
|
|
127
|
+
try {
|
|
128
|
+
// Ensure project metrics are computed for accurate normalization
|
|
129
|
+
(0, risk_scorer_1.computeProjectMetrics)(graph);
|
|
130
|
+
const riskScore = (0, risk_scorer_1.getRiskScore)(normalized, graph, options.fileContent ?? null);
|
|
131
|
+
result.riskScore = riskScore;
|
|
132
|
+
// Upgrade riskLevel if composite score yields a higher severity
|
|
133
|
+
const compositeLevel = riskScore.riskLevel;
|
|
134
|
+
if (isHigherRisk(compositeLevel, result.riskLevel)) {
|
|
135
|
+
result.riskLevel = mapCompositeToLegacy(compositeLevel);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// Non-critical: if scoring fails, the result is still valid without it
|
|
140
|
+
console.error(`[syke:scoring] Failed to compute risk score for ${filePath}: ${err}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Compute historical change coupling if requested
|
|
144
|
+
if (options?.includeCoupling) {
|
|
145
|
+
try {
|
|
146
|
+
const coupledFiles = await computeCoupledFiles(normalized, graph, toRelative);
|
|
147
|
+
if (coupledFiles.length > 0) {
|
|
148
|
+
result.coupledFiles = coupledFiles;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Non-critical: coupling analysis failure should not break impact analysis
|
|
153
|
+
console.error(`[syke:coupling] Failed to compute change coupling for ${filePath}: ${err}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Compute historical change coupling for a file, filtering to only show
|
|
160
|
+
* couplings that are NOT already in the dependency graph ("hidden" dependencies).
|
|
161
|
+
* Returns at most 5 coupled files, sorted by confidence.
|
|
162
|
+
*/
|
|
163
|
+
async function computeCoupledFiles(normalizedPath, graph, toRelative) {
|
|
164
|
+
const couplingResult = await (0, change_coupling_1.mineGitHistory)(graph.projectRoot);
|
|
165
|
+
if (couplingResult.totalCommitsAnalyzed === 0) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
// Convert file path to git-relative format (forward slashes, relative to project root)
|
|
169
|
+
const gitRelPath = path.relative(graph.projectRoot, normalizedPath).replace(/\\/g, "/");
|
|
170
|
+
const couplings = (0, change_coupling_1.getCoupledFiles)(gitRelPath, couplingResult);
|
|
171
|
+
if (couplings.length === 0) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
// Build a set of all files in the dependency graph (both direct and transitive)
|
|
175
|
+
const graphDeps = new Set();
|
|
176
|
+
// Forward dependencies of this file
|
|
177
|
+
const forwardDeps = graph.forward.get(normalizedPath) || [];
|
|
178
|
+
for (const d of forwardDeps) {
|
|
179
|
+
graphDeps.add(path.relative(graph.projectRoot, d).replace(/\\/g, "/"));
|
|
180
|
+
}
|
|
181
|
+
// Reverse dependents of this file
|
|
182
|
+
const reverseDeps = graph.reverse.get(normalizedPath) || [];
|
|
183
|
+
for (const d of reverseDeps) {
|
|
184
|
+
graphDeps.add(path.relative(graph.projectRoot, d).replace(/\\/g, "/"));
|
|
185
|
+
}
|
|
186
|
+
const results = [];
|
|
187
|
+
for (const coupling of couplings) {
|
|
188
|
+
// Determine which file is the "other" file in the pair
|
|
189
|
+
const otherFile = coupling.file1 === gitRelPath ? coupling.file2 : coupling.file1;
|
|
190
|
+
const inGraph = graphDeps.has(otherFile);
|
|
191
|
+
// Convert to source-dir-relative path for display
|
|
192
|
+
const otherAbsolute = path.normalize(path.join(graph.projectRoot, otherFile));
|
|
193
|
+
const displayPath = toRelative(otherAbsolute);
|
|
194
|
+
results.push({
|
|
195
|
+
relativePath: displayPath,
|
|
196
|
+
confidence: coupling.confidence,
|
|
197
|
+
coChangeCount: coupling.coChangeCount,
|
|
198
|
+
inDependencyGraph: inGraph,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Filter to only hidden dependencies (not in graph) and limit to top 5
|
|
202
|
+
const hidden = results.filter((r) => !r.inDependencyGraph);
|
|
203
|
+
return hidden.slice(0, 5);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Check if a composite risk level is higher than a legacy risk level.
|
|
207
|
+
*/
|
|
208
|
+
function isHigherRisk(composite, legacy) {
|
|
209
|
+
const compositeRank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, SAFE: 0 };
|
|
210
|
+
const legacyRank = { HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0 };
|
|
211
|
+
return (compositeRank[composite] || 0) > (legacyRank[legacy] || 0);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Map a composite risk level to the legacy RiskLevel type.
|
|
215
|
+
*/
|
|
216
|
+
function mapCompositeToLegacy(composite) {
|
|
217
|
+
switch (composite) {
|
|
218
|
+
case "CRITICAL": return "HIGH"; // Legacy type has no CRITICAL, map to HIGH
|
|
219
|
+
case "HIGH": return "HIGH";
|
|
220
|
+
case "MEDIUM": return "MEDIUM";
|
|
221
|
+
case "LOW": return "LOW";
|
|
222
|
+
case "SAFE": return "NONE";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Original BFS-based impact analysis (no SCC data).
|
|
227
|
+
*/
|
|
228
|
+
function analyzeImpactBFS(normalized, graph, toRelative) {
|
|
45
229
|
// Direct dependents (depth 1)
|
|
46
230
|
const directDependents = graph.reverse.get(normalized) || [];
|
|
47
231
|
// BFS for transitive dependents (all depths)
|
|
@@ -65,7 +249,6 @@ function analyzeImpact(filePath, graph) {
|
|
|
65
249
|
const directSet = new Set(directDependents);
|
|
66
250
|
const transitiveDependents = [...visited].filter((f) => !directSet.has(f));
|
|
67
251
|
const riskLevel = classifyRisk(totalImpacted);
|
|
68
|
-
const toRelative = (f) => path.relative(graph.sourceDir, f).replace(/\\/g, "/");
|
|
69
252
|
return {
|
|
70
253
|
filePath: normalized,
|
|
71
254
|
relativePath: toRelative(normalized),
|
|
@@ -75,6 +258,99 @@ function analyzeImpact(filePath, graph) {
|
|
|
75
258
|
totalImpacted,
|
|
76
259
|
};
|
|
77
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* SCC-enhanced impact analysis using the condensed DAG.
|
|
263
|
+
*
|
|
264
|
+
* 1. If the changed file is in a cyclic SCC (size > 1), ALL files in that SCC
|
|
265
|
+
* are immediately marked as affected at cascade level 0.
|
|
266
|
+
* 2. BFS on the condensed DAG (using reverse edges = dependents) to find
|
|
267
|
+
* all impacted SCCs with correct cascade levels.
|
|
268
|
+
* 3. Each file inherits the cascade level of its SCC.
|
|
269
|
+
*/
|
|
270
|
+
function analyzeImpactWithSCC(normalized, graph, toRelative) {
|
|
271
|
+
const scc = graph.scc;
|
|
272
|
+
const { nodeToComponent, condensed } = scc;
|
|
273
|
+
const sccIndex = nodeToComponent.get(normalized);
|
|
274
|
+
// File not found in SCC mapping — fall back to BFS
|
|
275
|
+
if (sccIndex === undefined) {
|
|
276
|
+
return analyzeImpactBFS(normalized, graph, toRelative);
|
|
277
|
+
}
|
|
278
|
+
const startNode = condensed.nodes[sccIndex];
|
|
279
|
+
// Collect circular cluster info
|
|
280
|
+
const circularCluster = startNode.isCyclic
|
|
281
|
+
? startNode.files.filter(f => f !== normalized).map(toRelative)
|
|
282
|
+
: undefined;
|
|
283
|
+
// BFS on condensed DAG reverse edges to find all impacted SCCs
|
|
284
|
+
// Level 0 = the SCC containing the changed file
|
|
285
|
+
// Level 1 = SCCs that directly depend on the changed SCC
|
|
286
|
+
// Level N = SCCs at distance N in the condensed DAG
|
|
287
|
+
const visitedSCCs = new Map(); // SCC index -> cascade level
|
|
288
|
+
visitedSCCs.set(sccIndex, 0);
|
|
289
|
+
const queue = [
|
|
290
|
+
{ sccIdx: sccIndex, level: 0 },
|
|
291
|
+
];
|
|
292
|
+
while (queue.length > 0) {
|
|
293
|
+
const { sccIdx, level } = queue.shift();
|
|
294
|
+
// Get SCCs that depend on this SCC (reverse edges in condensed DAG)
|
|
295
|
+
const dependentSCCs = condensed.reverse.get(sccIdx) || [];
|
|
296
|
+
for (const depSCC of dependentSCCs) {
|
|
297
|
+
if (!visitedSCCs.has(depSCC)) {
|
|
298
|
+
visitedSCCs.set(depSCC, level + 1);
|
|
299
|
+
queue.push({ sccIdx: depSCC, level: level + 1 });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Expand SCC indices back to individual files
|
|
304
|
+
const cascadeLevels = new Map();
|
|
305
|
+
const allImpactedFiles = new Set();
|
|
306
|
+
for (const [sccIdx, level] of visitedSCCs) {
|
|
307
|
+
const node = condensed.nodes[sccIdx];
|
|
308
|
+
for (const file of node.files) {
|
|
309
|
+
if (file === normalized)
|
|
310
|
+
continue; // Exclude the changed file itself
|
|
311
|
+
allImpactedFiles.add(file);
|
|
312
|
+
cascadeLevels.set(file, level);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Separate into direct (level 1 from raw graph) and transitive
|
|
316
|
+
const rawDirectDependents = graph.reverse.get(normalized) || [];
|
|
317
|
+
const directSet = new Set(rawDirectDependents);
|
|
318
|
+
// Files in the same cyclic SCC are also considered "direct" dependents
|
|
319
|
+
if (startNode.isCyclic) {
|
|
320
|
+
for (const f of startNode.files) {
|
|
321
|
+
if (f !== normalized) {
|
|
322
|
+
directSet.add(f);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const directDependents = [...allImpactedFiles].filter(f => directSet.has(f));
|
|
327
|
+
const transitiveDependents = [...allImpactedFiles].filter(f => !directSet.has(f));
|
|
328
|
+
const totalImpacted = allImpactedFiles.size;
|
|
329
|
+
const riskLevel = classifyRisk(totalImpacted);
|
|
330
|
+
// Convert cascade levels keys to relative paths
|
|
331
|
+
const relativeCascadeLevels = new Map();
|
|
332
|
+
for (const [file, level] of cascadeLevels) {
|
|
333
|
+
relativeCascadeLevels.set(toRelative(file), level);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
filePath: normalized,
|
|
337
|
+
relativePath: toRelative(normalized),
|
|
338
|
+
riskLevel,
|
|
339
|
+
directDependents: directDependents.map(toRelative),
|
|
340
|
+
transitiveDependents: transitiveDependents.map(toRelative),
|
|
341
|
+
totalImpacted,
|
|
342
|
+
cascadeLevels: relativeCascadeLevels,
|
|
343
|
+
circularCluster,
|
|
344
|
+
sccCount: condensed.nodes.length,
|
|
345
|
+
cyclicSCCs: condensed.nodes.filter(n => n.isCyclic).length,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Get the memo cache instance for diagnostics (cache stats, etc.).
|
|
350
|
+
*/
|
|
351
|
+
function getImpactMemoCache() {
|
|
352
|
+
return (0, memo_cache_1.getMemoCache)();
|
|
353
|
+
}
|
|
78
354
|
function classifyRisk(count) {
|
|
79
355
|
if (count >= 10)
|
|
80
356
|
return "HIGH";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DependencyGraph } from "../graph";
|
|
2
|
+
import { RiskScore } from "../scoring/risk-scorer";
|
|
2
3
|
export type GateVerdict = "PASS" | "WARN" | "FAIL";
|
|
3
4
|
export interface GateIssue {
|
|
4
5
|
file: string;
|
|
@@ -13,13 +14,17 @@ export interface GateResult {
|
|
|
13
14
|
stats: {
|
|
14
15
|
filesInGraph: number;
|
|
15
16
|
unresolvedWarnings: number;
|
|
17
|
+
sccCount?: number;
|
|
18
|
+
cyclicSCCs?: number;
|
|
16
19
|
};
|
|
17
20
|
autoAcknowledged: number;
|
|
21
|
+
/** Composite risk scores for specified files (when available) */
|
|
22
|
+
riskScores?: Map<string, RiskScore>;
|
|
18
23
|
}
|
|
19
24
|
/**
|
|
20
25
|
* Run the build gate check.
|
|
21
26
|
* If `specifiedFiles` is provided, only warnings for those files are considered.
|
|
22
|
-
* Cycle detection
|
|
27
|
+
* Cycle detection uses SCC data when available (faster and more complete).
|
|
23
28
|
*/
|
|
24
|
-
export declare function gateCheck(graph: DependencyGraph, specifiedFiles?: string[]): GateResult
|
|
29
|
+
export declare function gateCheck(graph: DependencyGraph, specifiedFiles?: string[]): Promise<GateResult>;
|
|
25
30
|
export declare function formatGateResult(result: GateResult): string;
|
package/dist/tools/gate-build.js
CHANGED
|
@@ -38,11 +38,69 @@ exports.formatGateResult = formatGateResult;
|
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const server_1 = require("../web/server");
|
|
40
40
|
const analyze_impact_1 = require("./analyze-impact");
|
|
41
|
+
const risk_scorer_1 = require("../scoring/risk-scorer");
|
|
42
|
+
const change_coupling_1 = require("../git/change-coupling");
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
* Detect circular dependencies using SCC data.
|
|
45
|
+
* Much faster and more complete than DFS-based cycle detection.
|
|
46
|
+
* Returns all cyclic SCCs that contain any of the specified files.
|
|
44
47
|
*/
|
|
45
|
-
function
|
|
48
|
+
function detectCyclesWithSCC(files, graph) {
|
|
49
|
+
const scc = graph.scc;
|
|
50
|
+
if (!scc) {
|
|
51
|
+
// Fallback to old DFS if SCC data not available
|
|
52
|
+
return detectCyclesForFilesDFS(files, graph, 10);
|
|
53
|
+
}
|
|
54
|
+
const cycles = [];
|
|
55
|
+
const reportedSCCs = new Set();
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
if (!graph.files.has(file))
|
|
58
|
+
continue;
|
|
59
|
+
const sccIndex = scc.nodeToComponent.get(file);
|
|
60
|
+
if (sccIndex === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
const node = scc.condensed.nodes[sccIndex];
|
|
63
|
+
if (!node.isCyclic)
|
|
64
|
+
continue;
|
|
65
|
+
// Only report each cyclic SCC once
|
|
66
|
+
if (reportedSCCs.has(sccIndex))
|
|
67
|
+
continue;
|
|
68
|
+
reportedSCCs.add(sccIndex);
|
|
69
|
+
// Build a cycle representation: all files in the SCC form a cycle
|
|
70
|
+
// For display, show the circular path by appending the first file at the end
|
|
71
|
+
const cyclePath = [...node.files, node.files[0]];
|
|
72
|
+
cycles.push({
|
|
73
|
+
cycle: cyclePath,
|
|
74
|
+
file,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return cycles;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Also scan the entire graph for cyclic SCCs (not just specified files).
|
|
81
|
+
* Returns summary info about all cycles in the project.
|
|
82
|
+
*/
|
|
83
|
+
function detectAllCycles(graph) {
|
|
84
|
+
const scc = graph.scc;
|
|
85
|
+
if (!scc)
|
|
86
|
+
return [];
|
|
87
|
+
const cycles = [];
|
|
88
|
+
for (const node of scc.condensed.nodes) {
|
|
89
|
+
if (!node.isCyclic)
|
|
90
|
+
continue;
|
|
91
|
+
const cyclePath = [...node.files, node.files[0]];
|
|
92
|
+
cycles.push({
|
|
93
|
+
cycle: cyclePath,
|
|
94
|
+
file: node.files[0],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return cycles;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Legacy DFS forward traversal from specified files to detect circular dependencies.
|
|
101
|
+
* Used as fallback when SCC data is not available.
|
|
102
|
+
*/
|
|
103
|
+
function detectCyclesForFilesDFS(files, graph, maxCycles = 10) {
|
|
46
104
|
const cycles = [];
|
|
47
105
|
const globalVisited = new Set();
|
|
48
106
|
for (const startFile of files) {
|
|
@@ -62,7 +120,7 @@ function detectCyclesForFiles(files, graph, maxCycles = 10) {
|
|
|
62
120
|
if (cycles.length >= maxCycles)
|
|
63
121
|
break;
|
|
64
122
|
if (stack.has(dep)) {
|
|
65
|
-
// Back-edge found
|
|
123
|
+
// Back-edge found: cycle
|
|
66
124
|
const idx = pathStack.indexOf(dep);
|
|
67
125
|
if (idx >= 0) {
|
|
68
126
|
cycles.push({
|
|
@@ -79,7 +137,6 @@ function detectCyclesForFiles(files, graph, maxCycles = 10) {
|
|
|
79
137
|
pathStack.pop();
|
|
80
138
|
}
|
|
81
139
|
dfs(startFile);
|
|
82
|
-
// Mark all visited from this start as globally visited
|
|
83
140
|
for (const f of stack)
|
|
84
141
|
globalVisited.add(f);
|
|
85
142
|
}
|
|
@@ -89,13 +146,17 @@ function detectCyclesForFiles(files, graph, maxCycles = 10) {
|
|
|
89
146
|
/**
|
|
90
147
|
* Run the build gate check.
|
|
91
148
|
* If `specifiedFiles` is provided, only warnings for those files are considered.
|
|
92
|
-
* Cycle detection
|
|
149
|
+
* Cycle detection uses SCC data when available (faster and more complete).
|
|
93
150
|
*/
|
|
94
|
-
function gateCheck(graph, specifiedFiles) {
|
|
151
|
+
async function gateCheck(graph, specifiedFiles) {
|
|
95
152
|
const allWarnings = (0, server_1.getUnacknowledgedWarnings)();
|
|
153
|
+
const sccCount = graph.scc?.condensed.nodes.length;
|
|
154
|
+
const cyclicSCCs = graph.scc?.condensed.nodes.filter(n => n.isCyclic).length;
|
|
96
155
|
const stats = {
|
|
97
156
|
filesInGraph: graph.files.size,
|
|
98
157
|
unresolvedWarnings: allWarnings.length,
|
|
158
|
+
sccCount,
|
|
159
|
+
cyclicSCCs,
|
|
99
160
|
};
|
|
100
161
|
// Filter warnings to specified files if provided
|
|
101
162
|
const warnings = specifiedFiles
|
|
@@ -118,26 +179,60 @@ function gateCheck(graph, specifiedFiles) {
|
|
|
118
179
|
});
|
|
119
180
|
}
|
|
120
181
|
}
|
|
121
|
-
// 2. Detect cycles
|
|
182
|
+
// 2. Detect cycles using SCC (or fallback to DFS)
|
|
122
183
|
const filesToCheck = specifiedFiles || [];
|
|
123
|
-
const cycles =
|
|
184
|
+
const cycles = detectCyclesWithSCC(filesToCheck, graph);
|
|
124
185
|
for (const c of cycles) {
|
|
125
186
|
const cyclePath = c.cycle
|
|
126
187
|
.map((f) => path.relative(graph.sourceDir, f).replace(/\\/g, "/"))
|
|
127
|
-
.join("
|
|
188
|
+
.join(" -> ");
|
|
128
189
|
issues.push({
|
|
129
190
|
file: path.relative(graph.sourceDir, c.file).replace(/\\/g, "/"),
|
|
130
191
|
severity: "CRITICAL",
|
|
131
|
-
description: `Circular dependency: ${cyclePath}`,
|
|
192
|
+
description: `Circular dependency detected (${c.cycle.length - 1} files): ${cyclePath}`,
|
|
132
193
|
});
|
|
133
194
|
}
|
|
134
|
-
// 3. Check if hub files were modified
|
|
195
|
+
// 3. Check if hub files were modified and compute composite risk scores
|
|
196
|
+
const riskScores = new Map();
|
|
135
197
|
if (specifiedFiles) {
|
|
198
|
+
// Compute project metrics for accurate normalization
|
|
199
|
+
try {
|
|
200
|
+
(0, risk_scorer_1.computeProjectMetrics)(graph);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Non-critical: proceed without project-wide normalization
|
|
204
|
+
}
|
|
136
205
|
const hubs = (0, analyze_impact_1.getHubFiles)(graph, 5);
|
|
137
206
|
const hubPaths = new Set(hubs.map((h) => h.relativePath));
|
|
138
207
|
for (const f of specifiedFiles) {
|
|
208
|
+
if (!graph.files.has(f))
|
|
209
|
+
continue;
|
|
139
210
|
const rel = path.relative(graph.sourceDir, f).replace(/\\/g, "/");
|
|
140
|
-
|
|
211
|
+
// Compute composite risk score for each specified file
|
|
212
|
+
try {
|
|
213
|
+
const score = (0, risk_scorer_1.getRiskScore)(f, graph);
|
|
214
|
+
riskScores.set(rel, score);
|
|
215
|
+
// CRITICAL: composite score >= 0.8 is critical regardless of dependent count
|
|
216
|
+
if (score.riskLevel === "CRITICAL") {
|
|
217
|
+
issues.push({
|
|
218
|
+
file: rel,
|
|
219
|
+
severity: "CRITICAL",
|
|
220
|
+
description: `Critical risk score ${score.composite.toFixed(2)} (fan-in: ${score.transitiveFanIn}, cascade: ${score.cascadeDepth} levels, complexity: ${score.complexity})`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else if (score.riskLevel === "HIGH") {
|
|
224
|
+
issues.push({
|
|
225
|
+
file: rel,
|
|
226
|
+
severity: "HIGH",
|
|
227
|
+
description: `High risk score ${score.composite.toFixed(2)} (fan-in: ${score.transitiveFanIn}, cascade: ${score.cascadeDepth} levels, complexity: ${score.complexity})`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Fall through to hub check below
|
|
233
|
+
}
|
|
234
|
+
// Legacy hub file check (only if not already flagged by composite score)
|
|
235
|
+
if (hubPaths.has(rel) && !riskScores.has(rel)) {
|
|
141
236
|
const hub = hubs.find((h) => h.relativePath === rel);
|
|
142
237
|
issues.push({
|
|
143
238
|
file: rel,
|
|
@@ -145,6 +240,61 @@ function gateCheck(graph, specifiedFiles) {
|
|
|
145
240
|
description: `Hub file modified (${hub.dependentCount} dependents, ${hub.riskLevel} risk)`,
|
|
146
241
|
});
|
|
147
242
|
}
|
|
243
|
+
else if (hubPaths.has(rel)) {
|
|
244
|
+
// Hub file already scored by composite — only add hub note if severity was LOW/MEDIUM
|
|
245
|
+
const score = riskScores.get(rel);
|
|
246
|
+
if (score && (score.riskLevel === "LOW" || score.riskLevel === "MEDIUM" || score.riskLevel === "SAFE")) {
|
|
247
|
+
const hub = hubs.find((h) => h.relativePath === rel);
|
|
248
|
+
issues.push({
|
|
249
|
+
file: rel,
|
|
250
|
+
severity: "MEDIUM",
|
|
251
|
+
description: `Hub file modified (${hub.dependentCount} dependents), composite score: ${score.composite.toFixed(2)}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 4. Check historical change coupling for modified files
|
|
257
|
+
try {
|
|
258
|
+
const couplingResult = await (0, change_coupling_1.mineGitHistory)(graph.projectRoot);
|
|
259
|
+
if (couplingResult.totalCommitsAnalyzed > 0) {
|
|
260
|
+
const modifiedSet = new Set(specifiedFiles.map((f) => path.relative(graph.projectRoot, f).replace(/\\/g, "/")));
|
|
261
|
+
for (const f of specifiedFiles) {
|
|
262
|
+
if (!graph.files.has(f))
|
|
263
|
+
continue;
|
|
264
|
+
const gitRelPath = path
|
|
265
|
+
.relative(graph.projectRoot, f)
|
|
266
|
+
.replace(/\\/g, "/");
|
|
267
|
+
const couplings = (0, change_coupling_1.getCoupledFiles)(gitRelPath, couplingResult);
|
|
268
|
+
for (const coupling of couplings) {
|
|
269
|
+
const otherFile = coupling.file1 === gitRelPath ? coupling.file2 : coupling.file1;
|
|
270
|
+
// Skip if the coupled file is already in the modified files list
|
|
271
|
+
if (modifiedSet.has(otherFile))
|
|
272
|
+
continue;
|
|
273
|
+
// Skip if the coupled file is already a known dependency
|
|
274
|
+
const otherAbsolute = path.normalize(path.join(graph.projectRoot, otherFile));
|
|
275
|
+
const forwardDeps = graph.forward.get(f) || [];
|
|
276
|
+
const reverseDeps = graph.reverse.get(f) || [];
|
|
277
|
+
const isInGraph = forwardDeps.includes(otherAbsolute) ||
|
|
278
|
+
reverseDeps.includes(otherAbsolute);
|
|
279
|
+
if (isInGraph)
|
|
280
|
+
continue;
|
|
281
|
+
// Only flag couplings with confidence >= 0.3
|
|
282
|
+
if (coupling.confidence < 0.3)
|
|
283
|
+
continue;
|
|
284
|
+
const severity = coupling.confidence >= 0.5 ? "MEDIUM" : "LOW";
|
|
285
|
+
const pct = Math.round(coupling.confidence * 100);
|
|
286
|
+
const rel = path.relative(graph.sourceDir, f).replace(/\\/g, "/");
|
|
287
|
+
issues.push({
|
|
288
|
+
file: rel,
|
|
289
|
+
severity,
|
|
290
|
+
description: `Historical coupling: ${otherFile} changes with this file in ${pct}% of commits (${coupling.coChangeCount} times) -- consider reviewing`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Non-critical: coupling analysis failure should not block build gate
|
|
148
298
|
}
|
|
149
299
|
}
|
|
150
300
|
// ── Determine verdict ──
|
|
@@ -191,6 +341,7 @@ function gateCheck(graph, specifiedFiles) {
|
|
|
191
341
|
recommendation,
|
|
192
342
|
stats,
|
|
193
343
|
autoAcknowledged,
|
|
344
|
+
riskScores: riskScores.size > 0 ? riskScores : undefined,
|
|
194
345
|
};
|
|
195
346
|
}
|
|
196
347
|
// ── Formatting ──
|
|
@@ -221,9 +372,24 @@ function formatGateResult(result) {
|
|
|
221
372
|
}
|
|
222
373
|
lines.push("", "### Recommendation", result.recommendation);
|
|
223
374
|
lines.push("", "### Stats", `- Files in graph: ${result.stats.filesInGraph}`, `- Unresolved warnings: ${result.stats.unresolvedWarnings}`);
|
|
375
|
+
if (result.stats.sccCount !== undefined) {
|
|
376
|
+
lines.push(`- Strongly Connected Components: ${result.stats.sccCount}`);
|
|
377
|
+
}
|
|
378
|
+
if (result.stats.cyclicSCCs !== undefined && result.stats.cyclicSCCs > 0) {
|
|
379
|
+
lines.push(`- Circular dependency clusters: ${result.stats.cyclicSCCs}`);
|
|
380
|
+
}
|
|
224
381
|
if (result.autoAcknowledged > 0) {
|
|
225
382
|
lines.push(`- Auto-acknowledged: ${result.autoAcknowledged} warning(s)`);
|
|
226
383
|
}
|
|
384
|
+
// Show composite risk scores for specified files
|
|
385
|
+
if (result.riskScores && result.riskScores.size > 0) {
|
|
386
|
+
lines.push("", "### Risk Scores");
|
|
387
|
+
for (const [file, score] of result.riskScores) {
|
|
388
|
+
lines.push(`**${file}:**`);
|
|
389
|
+
lines.push((0, risk_scorer_1.formatRiskScore)(score));
|
|
390
|
+
lines.push("");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
227
393
|
return lines.join("\n");
|
|
228
394
|
}
|
|
229
395
|
// ── Helpers ──
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
|
+
import { DependencyGraph } from "../graph";
|
|
2
3
|
export interface FileChange {
|
|
3
4
|
filePath: string;
|
|
4
5
|
relativePath: string;
|
|
@@ -27,9 +28,17 @@ export declare class FileCache extends EventEmitter {
|
|
|
27
28
|
private watcher;
|
|
28
29
|
private debounceTimers;
|
|
29
30
|
private readonly DEBOUNCE_MS;
|
|
31
|
+
private graph;
|
|
30
32
|
constructor(projectRoot: string);
|
|
31
33
|
/** Primary source directory (backward compat) */
|
|
32
34
|
get sourceDir(): string;
|
|
35
|
+
/**
|
|
36
|
+
* Set the dependency graph reference for incremental updates.
|
|
37
|
+
* When a graph is set, file changes will trigger incremental
|
|
38
|
+
* edge updates and memo cache invalidation instead of requiring
|
|
39
|
+
* a full graph rebuild.
|
|
40
|
+
*/
|
|
41
|
+
setGraph(graph: DependencyGraph): void;
|
|
33
42
|
/** Load ALL source files into memory on startup */
|
|
34
43
|
initialize(): {
|
|
35
44
|
fileCount: number;
|