ucn 3.8.13 → 3.8.14

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.
Files changed (43) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +13 -1
  3. package/README.md +1 -0
  4. package/cli/index.js +165 -246
  5. package/core/analysis.js +1400 -0
  6. package/core/build-worker.js +194 -0
  7. package/core/cache.js +105 -7
  8. package/core/callers.js +194 -64
  9. package/core/deadcode.js +22 -66
  10. package/core/discovery.js +9 -54
  11. package/core/execute.js +139 -54
  12. package/core/graph.js +615 -0
  13. package/core/output/analysis-ext.js +271 -0
  14. package/core/output/analysis.js +491 -0
  15. package/core/output/extraction.js +188 -0
  16. package/core/output/find.js +355 -0
  17. package/core/output/graph.js +399 -0
  18. package/core/output/refactoring.js +293 -0
  19. package/core/output/reporting.js +331 -0
  20. package/core/output/search.js +307 -0
  21. package/core/output/shared.js +271 -0
  22. package/core/output/tracing.js +416 -0
  23. package/core/output.js +15 -3293
  24. package/core/parallel-build.js +165 -0
  25. package/core/project.js +299 -3633
  26. package/core/registry.js +59 -0
  27. package/core/reporting.js +258 -0
  28. package/core/search.js +890 -0
  29. package/core/stacktrace.js +1 -1
  30. package/core/tracing.js +631 -0
  31. package/core/verify.js +10 -13
  32. package/eslint.config.js +43 -0
  33. package/jsconfig.json +10 -0
  34. package/languages/go.js +21 -2
  35. package/languages/html.js +8 -0
  36. package/languages/index.js +102 -40
  37. package/languages/java.js +13 -0
  38. package/languages/javascript.js +17 -1
  39. package/languages/python.js +14 -0
  40. package/languages/rust.js +13 -0
  41. package/languages/utils.js +1 -1
  42. package/mcp/server.js +45 -28
  43. package/package.json +8 -3
@@ -0,0 +1,271 @@
1
+ /**
2
+ * core/output/shared.js - Shared utility functions used by other formatters
3
+ *
4
+ * KEY PRINCIPLE: Never truncate critical information.
5
+ * Full expressions, full signatures, full context.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const { langTraits } = require('../../languages');
10
+
11
+ /**
12
+ * Format dynamic imports note with language-appropriate terminology.
13
+ * Go doesn't have "dynamic imports" — uses "blank/dot imports" instead.
14
+ */
15
+ function dynamicImportsNote(count, meta) {
16
+ if (!count) return null;
17
+ if (meta?.projectLanguage && !langTraits(meta.projectLanguage)?.hasDynamicImports) {
18
+ return `${count} blank/dot import(s)`;
19
+ }
20
+ return `${count} dynamic import(s)`;
21
+ }
22
+
23
+ const FILE_ERROR_MESSAGES = {
24
+ 'file-not-found': 'File not found in project',
25
+ 'file-ambiguous': 'Ambiguous file match'
26
+ };
27
+
28
+ function formatFileError(errorObj, fallbackPath) {
29
+ const msg = FILE_ERROR_MESSAGES[errorObj.error] || errorObj.error;
30
+ const file = errorObj.filePath || fallbackPath || '';
31
+ return `Error: ${msg}: ${file}`;
32
+ }
33
+
34
+ /**
35
+ * Normalize parameters for display
36
+ * Collapses multiline params to single line
37
+ * @param {string} params - Raw params string
38
+ * @returns {string} - Normalized params (NO truncation)
39
+ */
40
+ function normalizeParams(params) {
41
+ if (!params || params === '...') return params || '...';
42
+ // Collapse whitespace (newlines, tabs, multiple spaces) to single space
43
+ return params.replace(/\s+/g, ' ').trim();
44
+ }
45
+
46
+ /**
47
+ * Format a line number for display
48
+ * @param {number} line - 1-indexed line number
49
+ * @param {number} width - Padding width
50
+ * @returns {string}
51
+ */
52
+ function lineNum(line, width = 4) {
53
+ return String(line).padStart(width);
54
+ }
55
+
56
+ /**
57
+ * Format a line range
58
+ * @param {number} start - 1-indexed start line
59
+ * @param {number} end - 1-indexed end line
60
+ * @returns {string}
61
+ */
62
+ function lineRange(start, end) {
63
+ return `[${lineNum(start)}-${lineNum(end)}]`;
64
+ }
65
+
66
+ /**
67
+ * Format a single line location
68
+ * @param {number} line - 1-indexed line number
69
+ * @returns {string}
70
+ */
71
+ function lineLoc(line) {
72
+ return `[${lineNum(line)}]`;
73
+ }
74
+
75
+ /**
76
+ * Format function signature for TOC display
77
+ * @param {object} fn - Function definition
78
+ * @returns {string}
79
+ */
80
+ function formatFunctionSignature(fn) {
81
+ const prefix = [];
82
+
83
+ // Modifiers
84
+ if (fn.modifiers && fn.modifiers.length > 0) {
85
+ prefix.push(fn.modifiers.join(' '));
86
+ }
87
+
88
+ // Generator marker
89
+ if (fn.isGenerator) prefix.push('*');
90
+
91
+ // Name + generics + params (concatenated without spaces)
92
+ let sig = fn.name;
93
+ if (fn.generics) sig += fn.generics;
94
+ const params = normalizeParams(fn.params);
95
+ sig += `(${params})`;
96
+
97
+ // Return type
98
+ if (fn.returnType) sig += `: ${fn.returnType}`;
99
+
100
+ // Arrow indicator
101
+ if (fn.isArrow) sig += ' =>';
102
+
103
+ if (prefix.length > 0) {
104
+ return prefix.join(' ') + ' ' + sig;
105
+ }
106
+ return sig;
107
+ }
108
+
109
+ /**
110
+ * Format class/type signature for TOC display
111
+ */
112
+ function formatClassSignature(cls) {
113
+ const parts = [cls.type, cls.name];
114
+
115
+ if (cls.generics) parts.push(cls.generics);
116
+ if (cls.extends) parts.push(`extends ${cls.extends}`);
117
+ if (cls.implements && cls.implements.length > 0) {
118
+ parts.push(`implements ${cls.implements.join(', ')}`);
119
+ }
120
+
121
+ return parts.join(' ');
122
+ }
123
+
124
+ /**
125
+ * Format class member for TOC display
126
+ */
127
+ function formatMemberSignature(member) {
128
+ const parts = [];
129
+
130
+ // Member type (static, get, set, private, etc.)
131
+ if (member.memberType && member.memberType !== 'method') {
132
+ parts.push(member.memberType);
133
+ }
134
+
135
+ // Async
136
+ if (member.isAsync) parts.push('async');
137
+
138
+ // Generator
139
+ if (member.isGenerator) parts.push('*');
140
+
141
+ // Name + Parameters (no space between name and parens)
142
+ if (member.params !== undefined) {
143
+ const params = normalizeParams(member.params);
144
+ parts.push(`${member.name}(${params})`);
145
+ } else {
146
+ parts.push(member.name);
147
+ }
148
+
149
+ // Return type
150
+ if (member.returnType) parts.push(`: ${member.returnType}`);
151
+
152
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
153
+ }
154
+
155
+ /**
156
+ * Compact display of line numbers, collapsing consecutive ranges
157
+ */
158
+ function formatLineRanges(lineNums) {
159
+ if (lineNums.length === 0) return '';
160
+ const sorted = [...lineNums].sort((a, b) => a - b);
161
+ const ranges = [];
162
+ let start = sorted[0], end = sorted[0];
163
+ for (let i = 1; i < sorted.length; i++) {
164
+ if (sorted[i] === end + 1) {
165
+ end = sorted[i];
166
+ } else {
167
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
168
+ start = end = sorted[i];
169
+ }
170
+ }
171
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
172
+ return ranges.join(', ');
173
+ }
174
+
175
+ /**
176
+ * Detect common double-escaping patterns in regex search terms.
177
+ * When MCP/JSON transport is involved, agents often write \\. when they mean \. (literal dot).
178
+ * Returns a hint string if double-escaping is suspected, empty string otherwise.
179
+ */
180
+ function detectDoubleEscaping(term) {
181
+ // Look for \\. \\d \\w \\s \\b \\D \\W \\S \\B \\( \\) \\[ \\] — common double-escaped sequences
182
+ const doubleEscaped = term.match(/\\\\[.dDwWsSbB()\[\]*+?^${}|]/g); // eslint-disable-line no-useless-escape
183
+ if (!doubleEscaped) return '';
184
+ const examples = [...new Set(doubleEscaped)].slice(0, 3);
185
+ const fixed = examples.map(e => e.slice(1)); // remove one backslash
186
+ return `\nHint: Pattern contains ${examples.join(', ')} which matches literal backslash(es). If you meant ${fixed.join(', ')}, use a single backslash (MCP/JSON parameters are already raw strings).`;
187
+ }
188
+
189
+ /**
190
+ * Count depth of nested generic brackets.
191
+ */
192
+ function countNestedGenerics(str) {
193
+ let maxDepth = 0;
194
+ let depth = 0;
195
+ for (const char of str) {
196
+ if (char === '<') {
197
+ depth++;
198
+ maxDepth = Math.max(maxDepth, depth);
199
+ } else if (char === '>') {
200
+ depth--;
201
+ }
202
+ }
203
+ return maxDepth;
204
+ }
205
+
206
+ /**
207
+ * Compute confidence level for a symbol match.
208
+ * @returns {{ level: 'high'|'medium'|'low', reasons: string[] }}
209
+ */
210
+ function computeConfidence(symbol) {
211
+ const reasons = [];
212
+ let score = 100;
213
+
214
+ const span = (symbol.endLine || symbol.startLine) - symbol.startLine;
215
+ if (span > 500) {
216
+ score -= 30;
217
+ reasons.push('very long function (>500 lines)');
218
+ } else if (span > 200) {
219
+ score -= 15;
220
+ reasons.push('long function (>200 lines)');
221
+ }
222
+
223
+ const params = Array.isArray(symbol.params) ? symbol.params : [];
224
+ const signature = params.map(p => p.type || '').join(' ') + (symbol.returnType || '');
225
+ const genericDepth = countNestedGenerics(signature);
226
+ if (genericDepth > 3) {
227
+ score -= 20;
228
+ reasons.push('complex nested generics');
229
+ } else if (genericDepth > 2) {
230
+ score -= 10;
231
+ reasons.push('nested generics');
232
+ }
233
+
234
+ if (symbol.file) {
235
+ try {
236
+ const stats = fs.statSync(symbol.file);
237
+ const sizeKB = stats.size / 1024;
238
+ if (sizeKB > 500) {
239
+ score -= 20;
240
+ reasons.push('very large file (>500KB)');
241
+ } else if (sizeKB > 200) {
242
+ score -= 10;
243
+ reasons.push('large file (>200KB)');
244
+ }
245
+ } catch (e) {
246
+ // Skip file size check on error
247
+ }
248
+ }
249
+
250
+ let level = 'high';
251
+ if (score < 50) level = 'low';
252
+ else if (score < 80) level = 'medium';
253
+
254
+ return { level, reasons };
255
+ }
256
+
257
+ module.exports = {
258
+ dynamicImportsNote,
259
+ formatFileError,
260
+ normalizeParams,
261
+ lineNum,
262
+ lineRange,
263
+ lineLoc,
264
+ formatFunctionSignature,
265
+ formatClassSignature,
266
+ formatMemberSignature,
267
+ formatLineRanges,
268
+ detectDoubleEscaping,
269
+ countNestedGenerics,
270
+ computeConfidence,
271
+ };
@@ -0,0 +1,416 @@
1
+ /**
2
+ * core/output/tracing.js - Trace/blast/reverse tree formatters
3
+ */
4
+
5
+ /**
6
+ * Format trace command output - text
7
+ * Shows call tree visualization
8
+ */
9
+ function formatTrace(trace, options = {}) {
10
+ if (!trace) {
11
+ return 'Function not found.';
12
+ }
13
+
14
+ const lines = [];
15
+
16
+ // Header
17
+ lines.push(`Call tree for ${trace.root}`);
18
+ lines.push('═'.repeat(60));
19
+ lines.push(`${trace.file}:${trace.line}`);
20
+ lines.push(`Direction: ${trace.direction}, Max depth: ${trace.maxDepth}`);
21
+
22
+ if (trace.warnings && trace.warnings.length > 0) {
23
+ for (const w of trace.warnings) {
24
+ lines.push(`Note: ${w.message}`);
25
+ }
26
+ }
27
+
28
+ lines.push('');
29
+
30
+ // Render tree
31
+ let hasTruncation = false;
32
+ const renderNode = (node, prefix = '', isLast = true) => {
33
+ const connector = isLast ? '└── ' : '├── ';
34
+ const extension = isLast ? ' ' : '│ ';
35
+
36
+ let label = node.name;
37
+ if (node.external) {
38
+ label += ' [external]';
39
+ } else if (node.file) {
40
+ label += ` (${node.file}:${node.line})`;
41
+ }
42
+ if (node.weight && node.weight !== 'normal') {
43
+ label += ` [${node.weight}]`;
44
+ }
45
+ if (node.callCount) {
46
+ label += ` ${node.callCount}x`;
47
+ }
48
+ if (node.alreadyShown) {
49
+ label += ' (see above)';
50
+ }
51
+
52
+ lines.push(prefix + connector + label);
53
+
54
+ if (node.children && !node.alreadyShown) {
55
+ const hasMore = node.truncatedChildren > 0;
56
+ for (let i = 0; i < node.children.length; i++) {
57
+ const isChildLast = !hasMore && i === node.children.length - 1;
58
+ renderNode(node.children[i], prefix + extension, isChildLast);
59
+ }
60
+ if (hasMore) {
61
+ hasTruncation = true;
62
+ lines.push(prefix + extension + `└── ... and ${node.truncatedChildren} more callees`);
63
+ }
64
+ }
65
+ };
66
+
67
+ // Root node
68
+ lines.push(trace.root);
69
+ if (trace.tree && trace.tree.children) {
70
+ const rootHasMore = trace.tree.truncatedChildren > 0;
71
+ for (let i = 0; i < trace.tree.children.length; i++) {
72
+ const isLast = !rootHasMore && i === trace.tree.children.length - 1;
73
+ renderNode(trace.tree.children[i], '', isLast);
74
+ }
75
+ if (rootHasMore) {
76
+ hasTruncation = true;
77
+ lines.push(`└── ... and ${trace.tree.truncatedChildren} more callees`);
78
+ }
79
+ }
80
+
81
+ // Callers section
82
+ if (trace.callers && trace.callers.length > 0) {
83
+ lines.push('');
84
+ lines.push('CALLED BY:');
85
+ for (const c of trace.callers) {
86
+ lines.push(` ${c.name} - ${c.file}:${c.line}`);
87
+ lines.push(` ${c.expression}`);
88
+ }
89
+ if (trace.truncatedCallers) {
90
+ hasTruncation = true;
91
+ lines.push(` ... and ${trace.truncatedCallers} more callers`);
92
+ }
93
+ }
94
+
95
+ if (hasTruncation) {
96
+ const allHint = options.allHint || 'Use --all to show all.';
97
+ lines.push(`\nSome results truncated. ${allHint}`);
98
+ }
99
+
100
+ if (trace.includeMethods === false) {
101
+ const methodsHint = options.methodsHint || 'Note: obj.method() calls excluded — use --include-methods to include them';
102
+ lines.push(`\n${methodsHint}`);
103
+ }
104
+
105
+ return lines.join('\n');
106
+ }
107
+
108
+ /**
109
+ * Format trace command output - JSON
110
+ */
111
+ function formatTraceJson(trace) {
112
+ if (!trace) {
113
+ return JSON.stringify({ found: false, error: 'Function not found' }, null, 2);
114
+ }
115
+ return JSON.stringify(trace, null, 2);
116
+ }
117
+
118
+ /**
119
+ * Format blast command output - text
120
+ * Shows transitive blast radius (callers of callers)
121
+ */
122
+ function formatBlast(blast, options = {}) {
123
+ if (!blast) {
124
+ return 'Function not found.';
125
+ }
126
+
127
+ const lines = [];
128
+
129
+ // Header
130
+ lines.push(`Blast radius for ${blast.root}`);
131
+ lines.push('═'.repeat(60));
132
+ lines.push(`${blast.file}:${blast.line}`);
133
+ lines.push(`Depth: ${blast.maxDepth}`);
134
+
135
+ if (blast.warnings && blast.warnings.length > 0) {
136
+ for (const w of blast.warnings) {
137
+ lines.push(`Note: ${w.message}`);
138
+ }
139
+ }
140
+
141
+ lines.push('');
142
+
143
+ // Render tree (same structure as trace but showing callers)
144
+ let hasTruncation = false;
145
+ const renderNode = (node, prefix = '', isLast = true) => {
146
+ const connector = isLast ? '└── ' : '├── ';
147
+ const extension = isLast ? ' ' : '│ ';
148
+
149
+ let label = node.name;
150
+ if (node.file) {
151
+ label += ` (${node.file}:${node.line})`;
152
+ }
153
+ if (node.callSites && node.callSites > 1) {
154
+ label += ` ${node.callSites}x`;
155
+ }
156
+ if (node.alreadyShown) {
157
+ label += ' (see above)';
158
+ }
159
+
160
+ lines.push(prefix + connector + label);
161
+
162
+ if (node.children && !node.alreadyShown) {
163
+ const hasMore = node.truncatedChildren > 0;
164
+ for (let i = 0; i < node.children.length; i++) {
165
+ const isChildLast = !hasMore && i === node.children.length - 1;
166
+ renderNode(node.children[i], prefix + extension, isChildLast);
167
+ }
168
+ if (hasMore) {
169
+ hasTruncation = true;
170
+ lines.push(prefix + extension + `└── ... and ${node.truncatedChildren} more callers`);
171
+ }
172
+ }
173
+ };
174
+
175
+ // Root node
176
+ lines.push(blast.root);
177
+ if (blast.tree && blast.tree.children) {
178
+ const rootHasMore = blast.tree.truncatedChildren > 0;
179
+ for (let i = 0; i < blast.tree.children.length; i++) {
180
+ const isLast = !rootHasMore && i === blast.tree.children.length - 1;
181
+ renderNode(blast.tree.children[i], '', isLast);
182
+ }
183
+ if (rootHasMore) {
184
+ hasTruncation = true;
185
+ lines.push(`└── ... and ${blast.tree.truncatedChildren} more callers`);
186
+ }
187
+ }
188
+
189
+ // Summary
190
+ if (blast.summary) {
191
+ lines.push('');
192
+ const { totalAffected, totalFiles } = blast.summary;
193
+ if (totalAffected > 0) {
194
+ lines.push(`Summary: 1 function changed → ${totalAffected} function${totalAffected !== 1 ? 's' : ''} affected across ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`);
195
+ } else {
196
+ lines.push('Summary: No callers found — this function is a root/entry point.');
197
+ }
198
+ }
199
+
200
+ if (hasTruncation) {
201
+ const allHint = options.allHint || 'Use --all to show all.';
202
+ lines.push(`\nSome results truncated. ${allHint}`);
203
+ }
204
+
205
+ if (blast.includeMethods === false) {
206
+ lines.push('\nNote: obj.method() calls excluded. Use --include-methods to include them.');
207
+ }
208
+
209
+ return lines.join('\n');
210
+ }
211
+
212
+ /**
213
+ * Format blast command output - JSON
214
+ */
215
+ function formatBlastJson(blast) {
216
+ if (!blast) {
217
+ return JSON.stringify({ found: false, error: 'Function not found' }, null, 2);
218
+ }
219
+ return JSON.stringify(blast, null, 2);
220
+ }
221
+
222
+ /**
223
+ * Format reverse-trace command output - text
224
+ * Shows upward call chain to entry points
225
+ */
226
+ function formatReverseTrace(result, options = {}) {
227
+ if (!result) {
228
+ return 'Function not found.';
229
+ }
230
+
231
+ const lines = [];
232
+
233
+ // Header
234
+ lines.push(`Reverse trace for ${result.root}`);
235
+ lines.push('═'.repeat(60));
236
+ lines.push(`${result.file}:${result.line}`);
237
+ lines.push(`Depth: ${result.maxDepth}`);
238
+
239
+ if (result.warnings && result.warnings.length > 0) {
240
+ for (const w of result.warnings) {
241
+ lines.push(`Note: ${w.message}`);
242
+ }
243
+ }
244
+
245
+ lines.push('');
246
+
247
+ // Render tree
248
+ let hasTruncation = false;
249
+ const renderNode = (node, prefix = '', isLast = true) => {
250
+ const connector = isLast ? '└── ' : '├── ';
251
+ const extension = isLast ? ' ' : '│ ';
252
+
253
+ let label = node.name;
254
+ if (node.file) {
255
+ label += ` (${node.file}:${node.line})`;
256
+ }
257
+ if (node.callSites && node.callSites > 1) {
258
+ label += ` ${node.callSites}x`;
259
+ }
260
+ if (node.entryPoint) {
261
+ label += ' ★ entry point';
262
+ }
263
+ if (node.alreadyShown) {
264
+ label += ' (see above)';
265
+ }
266
+
267
+ lines.push(prefix + connector + label);
268
+
269
+ if (node.children && !node.alreadyShown) {
270
+ const hasMore = node.truncatedChildren > 0;
271
+ for (let i = 0; i < node.children.length; i++) {
272
+ const isChildLast = !hasMore && i === node.children.length - 1;
273
+ renderNode(node.children[i], prefix + extension, isChildLast);
274
+ }
275
+ if (hasMore) {
276
+ hasTruncation = true;
277
+ lines.push(prefix + extension + `└── ... and ${node.truncatedChildren} more callers`);
278
+ }
279
+ }
280
+ };
281
+
282
+ // Root node
283
+ let rootLabel = result.root;
284
+ if (result.tree && result.tree.entryPoint) {
285
+ rootLabel += ' ★ entry point (no callers)';
286
+ }
287
+ lines.push(rootLabel);
288
+ if (result.tree && result.tree.children) {
289
+ const rootHasMore = result.tree.truncatedChildren > 0;
290
+ for (let i = 0; i < result.tree.children.length; i++) {
291
+ const isLast = !rootHasMore && i === result.tree.children.length - 1;
292
+ renderNode(result.tree.children[i], '', isLast);
293
+ }
294
+ if (rootHasMore) {
295
+ hasTruncation = true;
296
+ lines.push(`└── ... and ${result.tree.truncatedChildren} more callers`);
297
+ }
298
+ }
299
+
300
+ // Entry points summary
301
+ if (result.entryPoints && result.entryPoints.length > 0) {
302
+ lines.push('');
303
+ lines.push(`Entry points (${result.entryPoints.length}):`);
304
+ for (const ep of result.entryPoints) {
305
+ lines.push(` ★ ${ep.name} (${ep.file}:${ep.line})`);
306
+ }
307
+ }
308
+
309
+ // Summary
310
+ if (result.summary) {
311
+ lines.push('');
312
+ const { totalEntryPoints, totalFunctions } = result.summary;
313
+ if (totalFunctions > 0) {
314
+ lines.push(`Summary: ${totalEntryPoints} entry point${totalEntryPoints !== 1 ? 's' : ''} reach${totalEntryPoints === 1 ? 'es' : ''} ${result.root} through ${totalFunctions} intermediate function${totalFunctions !== 1 ? 's' : ''}`);
315
+ } else {
316
+ lines.push('Summary: No callers found — this function is itself an entry point.');
317
+ }
318
+ }
319
+
320
+ if (hasTruncation) {
321
+ const allHint = options.allHint || 'Use --all to show all.';
322
+ lines.push(`\nSome results truncated. ${allHint}`);
323
+ }
324
+
325
+ if (result.includeMethods === false) {
326
+ lines.push('\nNote: obj.method() calls excluded. Use --include-methods to include them.');
327
+ }
328
+
329
+ return lines.join('\n');
330
+ }
331
+
332
+ /**
333
+ * Format reverse-trace command output - JSON
334
+ */
335
+ function formatReverseTraceJson(result) {
336
+ if (!result) {
337
+ return JSON.stringify({ found: false, error: 'Function not found' }, null, 2);
338
+ }
339
+ return JSON.stringify(result, null, 2);
340
+ }
341
+
342
+ /**
343
+ * Format affected-tests command output - text
344
+ */
345
+ function formatAffectedTests(result, options = {}) {
346
+ if (!result) return 'Function not found.';
347
+
348
+ const lines = [];
349
+ const { summary } = result;
350
+
351
+ lines.push(`affected-tests: ${result.root}`);
352
+ lines.push('═'.repeat(60));
353
+ lines.push(`${result.file}:${result.line}`);
354
+ lines.push(`1 function changed → ${summary.totalAffected} functions affected (depth ${result.depth})`);
355
+ lines.push('');
356
+
357
+ if (result.testFiles.length === 0) {
358
+ lines.push('No test files found for any affected function.');
359
+ } else {
360
+ const MAX_TEST_FILES = options.all ? Infinity : 30;
361
+ const displayFiles = result.testFiles.slice(0, MAX_TEST_FILES);
362
+ const truncatedFiles = result.testFiles.length - displayFiles.length;
363
+ lines.push(`Test files to run (${summary.totalTestFiles}):`);
364
+ lines.push('');
365
+ for (const tf of displayFiles) {
366
+ lines.push(` ${tf.file} (covers: ${tf.coveredFunctions.join(', ')})`);
367
+ // Show up to 5 key matches per file
368
+ const keyMatches = tf.matches
369
+ .filter(m => m.matchType === 'call' || m.matchType === 'test-case')
370
+ .slice(0, 5);
371
+ for (const m of keyMatches) {
372
+ lines.push(` L${m.line}: ${m.content} [${m.matchType}]`);
373
+ }
374
+ }
375
+ if (truncatedFiles > 0) {
376
+ lines.push(`\n ... ${truncatedFiles} more test files (use file= and exclude= to narrow scope)`);
377
+ }
378
+ }
379
+
380
+ if (result.uncovered.length > 0) {
381
+ lines.push('');
382
+ lines.push(`Uncovered (${result.uncovered.length}): ${result.uncovered.join(', ')}`);
383
+ lines.push(' ⚠ These affected functions have no test references');
384
+ }
385
+
386
+ lines.push('');
387
+ const pct = summary.totalAffected > 0
388
+ ? Math.round(summary.coveredFunctions / summary.totalAffected * 100)
389
+ : 0;
390
+ lines.push(`Summary: ${summary.totalAffected} affected → ${summary.totalTestFiles} test files, ${summary.coveredFunctions}/${summary.totalAffected} functions covered (${pct}%)`);
391
+
392
+ if (result.warnings?.length > 0) {
393
+ lines.push('');
394
+ for (const w of result.warnings) lines.push(`Note: ${w.message}`);
395
+ }
396
+
397
+ return lines.join('\n');
398
+ }
399
+
400
+ function formatAffectedTestsJson(result) {
401
+ if (!result) {
402
+ return JSON.stringify({ found: false, error: 'Function not found' }, null, 2);
403
+ }
404
+ return JSON.stringify(result, null, 2);
405
+ }
406
+
407
+ module.exports = {
408
+ formatTrace,
409
+ formatTraceJson,
410
+ formatBlast,
411
+ formatBlastJson,
412
+ formatReverseTrace,
413
+ formatReverseTraceJson,
414
+ formatAffectedTests,
415
+ formatAffectedTestsJson,
416
+ };