@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.
@@ -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 also starts from those files.
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;
@@ -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
- * DFS forward traversal from specified files to detect circular dependencies.
43
- * Returns at most `maxCycles` cycles for performance.
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 detectCyclesForFiles(files, graph, maxCycles = 10) {
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 cycle
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 also starts from those files.
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 for specified files (or skip full-graph scan for perf)
182
+ // 2. Detect cycles using SCC (or fallback to DFS)
122
183
  const filesToCheck = specifiedFiles || [];
123
- const cycles = detectCyclesForFiles(filesToCheck, graph, 10);
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
- if (hubPaths.has(rel)) {
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;