ctxman 3.1.0 → 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.
- package/.ctxman/llm-profiles.json +6 -6
- package/README.md +17 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +74 -70
- package/bin/cm-audit.js +80 -7
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/examples/github-actions/ctxman-check.yml +1 -1
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +713 -14
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +22 -2
- package/lib/policy/index.js +6 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +291 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/logger.js +5 -2
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/lib/workspace/index.js +1274 -0
- package/package.json +2 -2
|
@@ -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, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"');
|
|
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;
|