ctxman 3.1.1 → 3.2.7

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 (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. package/package.json +1 -1
@@ -0,0 +1,712 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Scanner } from '../core/Scanner.js';
4
+ import { extractCodeUnits } from '../search/code-units.js';
5
+ import { buildRepoGraph } from '../map/graph.js';
6
+ import { normalizeSymbolName } from '../map/call-graph.js';
7
+ import { explainHistory } from '../why/index.js';
8
+ import { redactSecrets } from '../utils/secret-redactor.js';
9
+ import TokenUtils from '../utils/token-utils.js';
10
+ import { parseBudget } from '../task/index.js';
11
+ import {
12
+ resolveWorkspaceRepos,
13
+ enforceWorkspacePolicies,
14
+ buildWorkspaceGraph,
15
+ } from '../workspace/index.js';
16
+
17
+ export const DEFAULT_DIAGNOSE_BUDGET = 24000;
18
+
19
+ const toPosix = (p) => p.split(path.sep).join('/');
20
+
21
+ const escapeXml = (str) =>
22
+ String(str ?? '')
23
+ .replace(/&/g, '&')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;');
27
+
28
+ /**
29
+ * Computes intra-file helper closure starting from `symbolNames` inside `node`.
30
+ */
31
+ function intraFileSymbolSet(node, symbolNames) {
32
+ const closure = new Set();
33
+ for (const sym of symbolNames || []) {
34
+ if (!sym) continue;
35
+ closure.add(sym);
36
+ const short = normalizeSymbolName(sym);
37
+ if (short) closure.add(short);
38
+ const afterDot = String(sym).split(/[.:]+/).pop();
39
+ if (afterDot) closure.add(normalizeSymbolName(afterDot));
40
+ }
41
+ if (!node || !Array.isArray(node.methods) || closure.size === 0) return closure;
42
+
43
+ const byName = new Map();
44
+ for (const m of node.methods) {
45
+ const mShort = normalizeSymbolName(m.name);
46
+ if (!byName.has(mShort)) byName.set(mShort, []);
47
+ byName.get(mShort).push(m);
48
+ }
49
+
50
+ const queue = Array.from(closure);
51
+ while (queue.length > 0) {
52
+ const current = queue.shift();
53
+ for (const m of byName.get(current) || []) {
54
+ for (const called of m.calls || []) {
55
+ const cShort = normalizeSymbolName(called);
56
+ if (byName.has(cShort) && !closure.has(cShort)) {
57
+ closure.add(cShort);
58
+ queue.push(cShort);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return closure;
64
+ }
65
+
66
+ /**
67
+ * Parses stack trace lines across JavaScript, TypeScript, Python, Go, Rust, Java, C#, Vitest.
68
+ * @param {string} text
69
+ * @returns {Array<{raw: string, file: string, line: number, col: number|null, func: string|null}>}
70
+ */
71
+ export function parseStackFrames(text) {
72
+ if (typeof text !== 'string') return [];
73
+ const lines = text.split('\n');
74
+ const frames = [];
75
+
76
+ for (const line of lines) {
77
+ let match;
78
+
79
+ // 1. Python: File "...", line 42, in func
80
+ if ((match = /File\s+["']([^"']+)["'],\s+line\s+(\d+)(?:,\s+in\s+([^\n\r]+))?/.exec(line))) {
81
+ frames.push({
82
+ raw: line.trim(),
83
+ file: match[1].trim(),
84
+ line: parseInt(match[2], 10),
85
+ col: null,
86
+ func: match[3]?.trim() || null,
87
+ });
88
+ continue;
89
+ }
90
+
91
+ // 2. JS / TS: at fn (/path/to/file.js:42:15) or at (/path/to/file.js:42:15) or at /path/to/file.js:42:15
92
+ if (
93
+ (match =
94
+ /at\s+(?:async\s+)?(?:([^\s(]+)\s+)?\(?(?:file:\/\/)?([^:()\s]+):(\d+)(?::(\d+))?\)?/.exec(
95
+ line
96
+ ))
97
+ ) {
98
+ const cleanFile = match[2].replace(/^[\s(]+/, '').replace(/[\s)]+$/, '');
99
+ frames.push({
100
+ raw: line.trim(),
101
+ file: cleanFile,
102
+ line: parseInt(match[3], 10),
103
+ col: match[4] ? parseInt(match[4], 10) : null,
104
+ func: match[1] || null,
105
+ });
106
+ continue;
107
+ }
108
+
109
+ // 3. Rust / compiler: --> src/main.rs:42:13 or at src/main.rs:42
110
+ if ((match = /(?:-->|at)\s+([^\s:]+\.[a-zA-Z0-9]+):(\d+)(?::(\d+))?/.exec(line))) {
111
+ frames.push({
112
+ raw: line.trim(),
113
+ file: match[1].trim(),
114
+ line: parseInt(match[2], 10),
115
+ col: match[3] ? parseInt(match[3], 10) : null,
116
+ func: null,
117
+ });
118
+ continue;
119
+ }
120
+
121
+ // 4. C# / Java stack lines: in /path/to/file.cs:line 42
122
+ if ((match = /in\s+([^:]+\.[a-zA-Z0-9]+):line\s+(\d+)/.exec(line))) {
123
+ frames.push({
124
+ raw: line.trim(),
125
+ file: match[1].trim(),
126
+ line: parseInt(match[2], 10),
127
+ col: null,
128
+ func: null,
129
+ });
130
+ continue;
131
+ }
132
+
133
+ // 5. Vitest / generic runner: ❯ path/to/file.ext:42:15 or path/to/file.ext:42:15
134
+ if ((match = /(?:❯\s+)?([^\s:]+\.[a-zA-Z0-9]+):(\d+)(?::(\d+))?/.exec(line))) {
135
+ frames.push({
136
+ raw: line.trim(),
137
+ file: match[1].trim(),
138
+ line: parseInt(match[2], 10),
139
+ col: match[3] ? parseInt(match[3], 10) : null,
140
+ func: null,
141
+ });
142
+ continue;
143
+ }
144
+ }
145
+
146
+ return frames;
147
+ }
148
+
149
+ /**
150
+ * Resolves an implicated file safely inside the project root.
151
+ * Returns posix relative path if inside root and exists; null otherwise.
152
+ */
153
+ function resolveInRepo(projectRoot, filePath) {
154
+ const cleaned = String(filePath ?? '')
155
+ .trim()
156
+ .replace(/^[\s(]+/, '')
157
+ .replace(/[\s)]+$/, '')
158
+ .replace(/^file:\/\//, '');
159
+
160
+ if (path.isAbsolute(cleaned)) {
161
+ let rel = path.relative(projectRoot, cleaned);
162
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
163
+ const full = path.join(projectRoot, rel);
164
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
165
+ return toPosix(rel);
166
+ }
167
+ }
168
+ try {
169
+ const realP = fs.realpathSync(projectRoot);
170
+ const realC = fs.realpathSync(cleaned);
171
+ rel = path.relative(realP, realC);
172
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
173
+ return toPosix(rel);
174
+ }
175
+ } catch {
176
+ // ignore
177
+ }
178
+ } else {
179
+ const full = path.resolve(projectRoot, cleaned);
180
+ const rel = path.relative(projectRoot, full);
181
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
182
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
183
+ return toPosix(rel);
184
+ }
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Resolves an implicated file across multiple workspace repositories.
192
+ * Supports `<alias>/<relPath>`, absolute paths inside any repo, or repo-relative paths.
193
+ */
194
+ function resolveInWorkspaceRepos(resolvedRepos, filePath) {
195
+ const cleaned = String(filePath ?? '')
196
+ .trim()
197
+ .replace(/^[\s(]+/, '')
198
+ .replace(/[\s)]+$/, '')
199
+ .replace(/^file:\/\//, '');
200
+ if (!cleaned) return null;
201
+
202
+ const cleanPosix = toPosix(cleaned).replace(/^\.\//, '');
203
+
204
+ // 1. Check `<alias>/<path>` prefix
205
+ for (const repo of resolvedRepos) {
206
+ if (cleanPosix.startsWith(`${repo.alias}/`)) {
207
+ const sub = cleanPosix.slice(repo.alias.length + 1);
208
+ const rel = resolveInRepo(repo.root, sub);
209
+ if (rel) {
210
+ return {
211
+ repo,
212
+ relativePath: rel,
213
+ workspacePath: `${repo.alias}/${rel}`,
214
+ };
215
+ }
216
+ }
217
+ }
218
+
219
+ // 2. Check absolute path or relative path across repos (longest matching root wins for absolute)
220
+ if (path.isAbsolute(cleaned)) {
221
+ let best = null;
222
+ for (const repo of resolvedRepos) {
223
+ const rel = resolveInRepo(repo.root, cleaned);
224
+ if (rel && (!best || repo.root.length > best.repo.root.length)) {
225
+ best = {
226
+ repo,
227
+ relativePath: rel,
228
+ workspacePath: `${repo.alias}/${rel}`,
229
+ };
230
+ }
231
+ }
232
+ return best;
233
+ }
234
+
235
+ for (const repo of resolvedRepos) {
236
+ const rel = resolveInRepo(repo.root, cleaned);
237
+ if (rel) {
238
+ return {
239
+ repo,
240
+ relativePath: rel,
241
+ workspacePath: `${repo.alias}/${rel}`,
242
+ };
243
+ }
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Diagnoses a stack trace or failure log:
251
+ * - Parses frames across multi-language stack traces
252
+ * - Resolves implicated files safely inside project root (or across multiple workspace repos)
253
+ * - Enforces policy deny rules and secret redaction
254
+ * - Extracts code window + enclosing method/class for each frame via extractCodeUnits
255
+ * - Collects 1-hop graph neighbors (prioritizing cross-file AST symbol call graph edges)
256
+ * - Integrates recent git history (explainHistory) for root cause context
257
+ * - Strictly bounded within budget (default 24000)
258
+ */
259
+ export async function diagnoseFailure(root, traceOrLog, options = {}) {
260
+ const input = typeof traceOrLog === 'string' ? traceOrLog.trim() : '';
261
+ if (!input) {
262
+ throw new Error('A failure log or stack trace is required');
263
+ }
264
+
265
+ const projectRoot = path.resolve(root);
266
+ const budget = parseBudget(options.budget, DEFAULT_DIAGNOSE_BUDGET);
267
+ const format = options.format ? String(options.format).toLowerCase() : 'markdown';
268
+ const redact = options.redact !== false;
269
+ const useCallGraph = options.callGraph !== false;
270
+ const windowLines = Number.isInteger(options.windowLines) ? options.windowLines : 12;
271
+
272
+ const extraRepos = Array.isArray(options.repos) ? options.repos.filter(Boolean) : [];
273
+ const isMultiRepo = extraRepos.length > 0;
274
+
275
+ let resolvedRepos = null;
276
+ let repoByAlias = null;
277
+ let scannersByAlias = null;
278
+ let scanner = null;
279
+
280
+ if (isMultiRepo) {
281
+ resolvedRepos = resolveWorkspaceRepos([projectRoot, ...extraRepos]);
282
+ enforceWorkspacePolicies(resolvedRepos, {
283
+ filesByRepo: new Map(),
284
+ tokens: 0,
285
+ redact,
286
+ });
287
+ repoByAlias = new Map(resolvedRepos.map((r) => [r.alias, r]));
288
+ scannersByAlias = new Map(
289
+ resolvedRepos.map((r) => {
290
+ const s = new Scanner(r.root);
291
+ s.getPolicy();
292
+ return [r.alias, s];
293
+ })
294
+ );
295
+ } else {
296
+ scanner = new Scanner(projectRoot);
297
+ scanner.getPolicy();
298
+ }
299
+
300
+ const isPathDenied = (graphPath) => {
301
+ const posix = toPosix(graphPath);
302
+ if (!isMultiRepo) return scanner.isDenied(posix);
303
+ const slashIdx = posix.indexOf('/');
304
+ const alias = slashIdx > 0 ? posix.slice(0, slashIdx) : null;
305
+ const repoScanner = alias ? scannersByAlias.get(alias) : null;
306
+ if (!repoScanner) return false;
307
+ return repoScanner.isDenied(posix.slice(alias.length + 1));
308
+ };
309
+
310
+ const resolveDiskPath = (graphPath) => {
311
+ const posix = toPosix(graphPath);
312
+ if (!isMultiRepo) {
313
+ return {
314
+ fullPath: path.join(projectRoot, posix),
315
+ repoRoot: projectRoot,
316
+ repo: null,
317
+ relativePath: posix,
318
+ };
319
+ }
320
+ const slashIdx = posix.indexOf('/');
321
+ const alias = slashIdx > 0 ? posix.slice(0, slashIdx) : null;
322
+ const repo = alias ? repoByAlias.get(alias) : null;
323
+ if (!repo) return { fullPath: null, repoRoot: null, repo: null, relativePath: posix };
324
+ const relativePath = posix.slice(alias.length + 1);
325
+ return {
326
+ fullPath: path.join(repo.root, relativePath),
327
+ repoRoot: repo.root,
328
+ repo: alias,
329
+ relativePath,
330
+ };
331
+ };
332
+
333
+ let totalRedactions = 0;
334
+
335
+ let processedInput = input;
336
+ if (redact) {
337
+ const red = redactSecrets(processedInput);
338
+ processedInput = red.text;
339
+ totalRedactions += red.findings.length;
340
+ }
341
+
342
+ // 1. Parse stack frames
343
+ const allParsedFrames = parseStackFrames(processedInput);
344
+ const resolvedFrames = [];
345
+ const implicatedFilesSet = new Set();
346
+ const implicatedSymbolsByFile = new Map();
347
+ const deniedFiles = [];
348
+
349
+ const recordImplicatedSymbol = (file, sym) => {
350
+ if (!file || !sym) return;
351
+ if (!implicatedSymbolsByFile.has(file)) implicatedSymbolsByFile.set(file, new Set());
352
+ implicatedSymbolsByFile.get(file).add(sym);
353
+ };
354
+
355
+ for (const f of allParsedFrames) {
356
+ if (isMultiRepo) {
357
+ const match = resolveInWorkspaceRepos(resolvedRepos, f.file);
358
+ if (!match) continue;
359
+ if (isPathDenied(match.workspacePath)) {
360
+ deniedFiles.push(match.workspacePath);
361
+ continue;
362
+ }
363
+ implicatedFilesSet.add(match.workspacePath);
364
+ if (f.func) recordImplicatedSymbol(match.workspacePath, f.func);
365
+ resolvedFrames.push({
366
+ ...f,
367
+ inRepoFile: match.workspacePath,
368
+ repo: match.repo.alias,
369
+ relativePath: match.relativePath,
370
+ });
371
+ } else {
372
+ const inRepo = resolveInRepo(projectRoot, f.file);
373
+ if (!inRepo) continue;
374
+
375
+ if (scanner.isDenied(inRepo)) {
376
+ deniedFiles.push(inRepo);
377
+ continue;
378
+ }
379
+
380
+ implicatedFilesSet.add(inRepo);
381
+ if (f.func) recordImplicatedSymbol(inRepo, f.func);
382
+ resolvedFrames.push({
383
+ ...f,
384
+ inRepoFile: inRepo,
385
+ });
386
+ }
387
+ }
388
+
389
+ const implicatedFiles = Array.from(implicatedFilesSet);
390
+
391
+ // 2. Extract code windows and exact enclosing methods/classes for implicated files
392
+ const snippetBudget = Math.max(200, Math.floor(budget * 0.55));
393
+ let usedSnippetTokens = 0;
394
+ const fileSnippets = [];
395
+
396
+ for (const file of implicatedFiles) {
397
+ const { fullPath, repo, relativePath } = resolveDiskPath(file);
398
+ if (!fullPath) continue;
399
+ try {
400
+ const content = fs.readFileSync(fullPath, 'utf-8');
401
+ const lines = content.split('\n');
402
+ const totalLines = lines.length;
403
+ const units = extractCodeUnits(content, relativePath);
404
+
405
+ const fileFrames = resolvedFrames.filter((rf) => rf.inRepoFile === file);
406
+ const seenLines = new Set();
407
+
408
+ for (const ff of fileFrames) {
409
+ const lineNum = ff.line;
410
+ if (seenLines.has(lineNum)) continue;
411
+ seenLines.add(lineNum);
412
+
413
+ // Pick innermost enclosing code unit (prefer non-container method/function over class)
414
+ const matchingUnits = units.filter((u) => u.startLine <= lineNum && u.endLine >= lineNum);
415
+ matchingUnits.sort((a, b) => a.endLine - a.startLine - (b.endLine - b.startLine));
416
+ const enclosing = matchingUnits[0] ?? null;
417
+ const enclosingMethod = enclosing ? enclosing.name : ff.func || null;
418
+ if (enclosingMethod) {
419
+ recordImplicatedSymbol(file, enclosingMethod);
420
+ }
421
+
422
+ const start = Math.max(1, lineNum - windowLines);
423
+ const end = Math.min(totalLines, lineNum + windowLines);
424
+
425
+ const snippetLines = [];
426
+ for (let l = start; l <= end; l++) {
427
+ const prefix = l === lineNum ? '> ' : ' ';
428
+ const lineStr = String(l).padStart(4, ' ');
429
+ snippetLines.push(`${prefix}${lineStr} | ${lines[l - 1] ?? ''}`);
430
+ }
431
+
432
+ let snippetText = snippetLines.join('\n');
433
+ if (redact) {
434
+ const r = redactSecrets(snippetText);
435
+ snippetText = r.text;
436
+ totalRedactions += r.findings.length;
437
+ }
438
+
439
+ const tokens = TokenUtils.calculate(snippetText, file);
440
+ if (fileSnippets.length > 0 && usedSnippetTokens + tokens > snippetBudget) {
441
+ break;
442
+ }
443
+ usedSnippetTokens += tokens;
444
+ fileSnippets.push({
445
+ file,
446
+ ...(repo ? { repo } : {}),
447
+ line: lineNum,
448
+ enclosingMethod,
449
+ startLine: start,
450
+ endLine: end,
451
+ snippet: snippetText,
452
+ tokens,
453
+ });
454
+ }
455
+ } catch {
456
+ // Skip unreadable files
457
+ }
458
+ }
459
+
460
+ // 3. Extract 1-hop graph neighbors (prioritizing cross-file AST symbol call graph edges)
461
+ const graph = isMultiRepo
462
+ ? buildWorkspaceGraph(resolvedRepos, { redact })
463
+ : buildRepoGraph(projectRoot, { redact });
464
+ const nodeByPath = new Map(graph.nodes.map((n) => [n.path, n]));
465
+ const callEdges = useCallGraph && Array.isArray(graph.callEdges) ? graph.callEdges : [];
466
+ const neighborSet = new Set();
467
+
468
+ if (useCallGraph && callEdges.length > 0) {
469
+ const expandedSymbolsByFile = new Map();
470
+ for (const file of implicatedFiles) {
471
+ const node = nodeByPath.get(file);
472
+ const rawSyms = implicatedSymbolsByFile.get(file) || new Set();
473
+ expandedSymbolsByFile.set(file, intraFileSymbolSet(node, rawSyms));
474
+ }
475
+
476
+ // 3a. Symbol-level call graph neighbors for implicated methods/functions (outgoing callees first, then incoming callers)
477
+ for (const ce of callEdges) {
478
+ if (implicatedFilesSet.has(ce.from) && !implicatedFilesSet.has(ce.to)) {
479
+ const syms = expandedSymbolsByFile.get(ce.from);
480
+ if (
481
+ syms &&
482
+ (syms.has(ce.caller) || syms.has(normalizeSymbolName(ce.caller))) &&
483
+ !isPathDenied(ce.to)
484
+ ) {
485
+ neighborSet.add(ce.to);
486
+ }
487
+ }
488
+ }
489
+ for (const ce of callEdges) {
490
+ if (implicatedFilesSet.has(ce.to) && !implicatedFilesSet.has(ce.from)) {
491
+ const syms = expandedSymbolsByFile.get(ce.to);
492
+ if (
493
+ syms &&
494
+ (syms.has(ce.callee) || syms.has(normalizeSymbolName(ce.callee))) &&
495
+ !isPathDenied(ce.from)
496
+ ) {
497
+ neighborSet.add(ce.from);
498
+ }
499
+ }
500
+ }
501
+
502
+ // 3b. File-level call graph neighbors for implicated files
503
+ for (const ce of callEdges) {
504
+ if (implicatedFilesSet.has(ce.from) && !implicatedFilesSet.has(ce.to)) {
505
+ if (!isPathDenied(ce.to)) neighborSet.add(ce.to);
506
+ }
507
+ if (implicatedFilesSet.has(ce.to) && !implicatedFilesSet.has(ce.from)) {
508
+ if (!isPathDenied(ce.from)) neighborSet.add(ce.from);
509
+ }
510
+ }
511
+ }
512
+
513
+ // 3c. Module import graph neighbors
514
+ for (const edge of graph.edges) {
515
+ if (implicatedFilesSet.has(edge.from) && !implicatedFilesSet.has(edge.to)) {
516
+ if (!isPathDenied(edge.to)) neighborSet.add(edge.to);
517
+ }
518
+ if (implicatedFilesSet.has(edge.to) && !implicatedFilesSet.has(edge.from)) {
519
+ if (!isPathDenied(edge.from)) neighborSet.add(edge.from);
520
+ }
521
+ }
522
+ const neighbors = Array.from(neighborSet).slice(0, 10);
523
+ const selectedNeighborSet = new Set(neighbors);
524
+ const relevantCallEdges = useCallGraph
525
+ ? callEdges.filter(
526
+ (ce) =>
527
+ !isPathDenied(ce.from) &&
528
+ !isPathDenied(ce.to) &&
529
+ ((implicatedFilesSet.has(ce.from) &&
530
+ (implicatedFilesSet.has(ce.to) || selectedNeighborSet.has(ce.to))) ||
531
+ (implicatedFilesSet.has(ce.to) && selectedNeighborSet.has(ce.from)))
532
+ )
533
+ : [];
534
+
535
+ // 4. Git history for implicated files bounded to remaining budget
536
+ const historyFiles = implicatedFiles.slice(0, 3);
537
+ const envelopeReserve = format === 'json' ? Math.min(450, Math.floor(budget * 0.22)) : 220;
538
+ const remainingForHistory = Math.max(0, budget - usedSnippetTokens - envelopeReserve);
539
+ const perFileHistoryBudget =
540
+ historyFiles.length > 0 ? Math.floor(remainingForHistory / historyFiles.length) : 0;
541
+
542
+ const historySummaries = [];
543
+ if (perFileHistoryBudget >= 180) {
544
+ for (const file of historyFiles) {
545
+ const { repoRoot, repo, relativePath } = resolveDiskPath(file);
546
+ if (!repoRoot) continue;
547
+ try {
548
+ const histResult = await explainHistory(repoRoot, relativePath, {
549
+ limit: 3,
550
+ budget: Math.min(3000, perFileHistoryBudget),
551
+ redact,
552
+ });
553
+ historySummaries.push({
554
+ file,
555
+ ...(repo ? { repo } : {}),
556
+ text: histResult.text,
557
+ tokens: histResult.tokens,
558
+ });
559
+ } catch {
560
+ // Git history might not be available
561
+ }
562
+ }
563
+ }
564
+
565
+ const workspaceReposMeta = isMultiRepo
566
+ ? resolvedRepos.map(({ alias, root: rRoot }) => ({ alias, root: rRoot }))
567
+ : null;
568
+
569
+ // 5. Render output and trim trailing history entries if serialization pushed past budget
570
+ const targetExt = format === 'json' ? 'json' : format === 'xml' ? 'xml' : 'md';
571
+ const renderOutput = (histList) => {
572
+ if (format === 'json') {
573
+ const doc = {
574
+ budget,
575
+ ...(workspaceReposMeta ? { repos: workspaceReposMeta } : {}),
576
+ errorLog: processedInput,
577
+ implicatedFiles,
578
+ frames: resolvedFrames,
579
+ snippets: fileSnippets,
580
+ neighbors,
581
+ callEdges: relevantCallEdges,
582
+ history: histList,
583
+ redactions: { total: totalRedactions },
584
+ deniedFiles,
585
+ };
586
+ return JSON.stringify(doc, null, 2);
587
+ }
588
+
589
+ if (format === 'xml') {
590
+ const xml = ['<diagnosis_report>'];
591
+ if (workspaceReposMeta) {
592
+ xml.push(' <repositories>');
593
+ for (const r of workspaceReposMeta) {
594
+ xml.push(` <repository alias="${escapeXml(r.alias)}" root="${escapeXml(r.root)}" />`);
595
+ }
596
+ xml.push(' </repositories>');
597
+ }
598
+ xml.push(' <implicated_frames>');
599
+ for (const rf of resolvedFrames) {
600
+ xml.push(
601
+ ` <frame file="${escapeXml(rf.inRepoFile)}" line="${rf.line}"${rf.func ? ` function="${escapeXml(rf.func)}"` : ''} />`
602
+ );
603
+ }
604
+ xml.push(' </implicated_frames>');
605
+ if (fileSnippets.length > 0) {
606
+ xml.push(' <snippets>');
607
+ for (const snip of fileSnippets) {
608
+ xml.push(
609
+ ` <snippet file="${escapeXml(snip.file)}" line="${snip.line}"${snip.enclosingMethod ? ` method="${escapeXml(snip.enclosingMethod)}"` : ''} start="${snip.startLine}" end="${snip.endLine}">\n${escapeXml(snip.snippet)}\n </snippet>`
610
+ );
611
+ }
612
+ xml.push(' </snippets>');
613
+ }
614
+ if (histList.length > 0) {
615
+ xml.push(' <history>');
616
+ for (const h of histList) {
617
+ xml.push(
618
+ ` <file_history file="${escapeXml(h.file)}">\n${escapeXml(h.text.trim())}\n </file_history>`
619
+ );
620
+ }
621
+ xml.push(' </history>');
622
+ }
623
+ if (neighbors.length > 0) {
624
+ xml.push(' <neighbors>');
625
+ for (const n of neighbors) {
626
+ xml.push(` <neighbor path="${escapeXml(n)}" />`);
627
+ }
628
+ xml.push(' </neighbors>');
629
+ }
630
+ xml.push('</diagnosis_report>');
631
+ return xml.join('\n');
632
+ }
633
+
634
+ const sections = [];
635
+ sections.push('# Failure Diagnosis Report\n');
636
+
637
+ sections.push('## Implicated Frames');
638
+ if (resolvedFrames.length > 0) {
639
+ for (const rf of resolvedFrames) {
640
+ sections.push(`- \`${rf.inRepoFile}:${rf.line}\`${rf.func ? ` in \`${rf.func}\`` : ''}`);
641
+ }
642
+ } else {
643
+ sections.push('*(No in-repo files matched directly in the stack trace)*');
644
+ }
645
+
646
+ if (fileSnippets.length > 0) {
647
+ sections.push('\n## Enclosing Method & Code Context');
648
+ for (const snip of fileSnippets) {
649
+ const methodInfo = snip.enclosingMethod ? ` · method \`${snip.enclosingMethod}\`` : '';
650
+ sections.push(
651
+ `\n### \`${snip.file}:${snip.line}\`${methodInfo} (lines ${snip.startLine}-${snip.endLine})`
652
+ );
653
+ const ext = path.extname(snip.file).slice(1) || '';
654
+ sections.push(`\`\`\`${ext}\n${snip.snippet}\n\`\`\``);
655
+ }
656
+ }
657
+
658
+ if (histList.length > 0) {
659
+ sections.push('\n## Recent Git History (Root-Cause Context)');
660
+ for (const h of histList) {
661
+ sections.push(`\n### History for \`${h.file}\`\n\n${h.text.trim()}`);
662
+ }
663
+ }
664
+
665
+ if (neighbors.length > 0) {
666
+ sections.push('\n## Related 1-Hop Neighborhood');
667
+ for (const n of neighbors) {
668
+ const nCalls = relevantCallEdges.filter((ce) => ce.from === n || ce.to === n);
669
+ if (nCalls.length > 0) {
670
+ const summary = nCalls
671
+ .slice(0, 3)
672
+ .map((ce) => `${ce.from}:${ce.caller} -> ${ce.to}:${ce.callee}`)
673
+ .join(', ');
674
+ sections.push(`- \`${n}\` (${summary})`);
675
+ } else {
676
+ sections.push(`- \`${n}\``);
677
+ }
678
+ }
679
+ }
680
+
681
+ return sections.join('\n');
682
+ };
683
+
684
+ let renderedText = renderOutput(historySummaries);
685
+ let finalTokens = TokenUtils.calculate(renderedText, `diagnose.${targetExt}`);
686
+ while (finalTokens > budget && historySummaries.length > 0) {
687
+ historySummaries.pop();
688
+ renderedText = renderOutput(historySummaries);
689
+ finalTokens = TokenUtils.calculate(renderedText, `diagnose.${targetExt}`);
690
+ }
691
+
692
+ if (options.output) {
693
+ const outPath = path.resolve(options.output);
694
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
695
+ fs.writeFileSync(outPath, renderedText, 'utf-8');
696
+ }
697
+
698
+ return {
699
+ frames: resolvedFrames,
700
+ implicatedFiles,
701
+ ...(workspaceReposMeta ? { repos: workspaceReposMeta } : {}),
702
+ totalTokens: finalTokens,
703
+ budget,
704
+ text: renderedText,
705
+ history: historySummaries,
706
+ neighbors,
707
+ callEdges: relevantCallEdges,
708
+ redactions: { total: totalRedactions },
709
+ };
710
+ }
711
+
712
+ export default diagnoseFailure;