reprobe 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/PLAYBOOK.md +109 -0
- package/README.md +119 -2
- package/bin/reprobe.mjs +6 -0
- package/dist/agent.d.ts +13 -0
- package/dist/agent.js +123 -0
- package/dist/case.d.ts +11 -0
- package/dist/case.js +98 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +280 -0
- package/dist/config.d.ts +100 -0
- package/dist/config.js +149 -0
- package/dist/detectors.d.ts +8 -0
- package/dist/detectors.js +136 -0
- package/dist/diagnosis.d.ts +22 -0
- package/dist/diagnosis.js +31 -0
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +113 -0
- package/dist/exec.d.ts +19 -0
- package/dist/exec.js +56 -0
- package/dist/gather.d.ts +7 -0
- package/dist/gather.js +183 -0
- package/dist/init.d.ts +12 -0
- package/dist/init.js +111 -0
- package/dist/llm.d.ts +18 -0
- package/dist/llm.js +71 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +98 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +145 -0
- package/dist/sources/langfuse.d.ts +36 -0
- package/dist/sources/langfuse.js +147 -0
- package/dist/sources/langsmith.d.ts +9 -0
- package/dist/sources/langsmith.js +130 -0
- package/dist/sources/logs.d.ts +40 -0
- package/dist/sources/logs.js +324 -0
- package/dist/sources/postgres.d.ts +22 -0
- package/dist/sources/postgres.js +209 -0
- package/dist/sources/sentry.d.ts +24 -0
- package/dist/sources/sentry.js +123 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +27 -0
- package/dist/verify.js +358 -0
- package/package.json +57 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { launchAgent } from "./agent.js";
|
|
7
|
+
import { appendEvidence, caseDirFor, loadPack, readAgentReplay, readGathered } from "./case.js";
|
|
8
|
+
import { assertCasesOutsideRepo, config, loadConfig } from "./config.js";
|
|
9
|
+
import { caseIdFor, detect } from "./detectors.js";
|
|
10
|
+
import { validateDiagnosis } from "./diagnosis.js";
|
|
11
|
+
import { doctor } from "./doctor.js";
|
|
12
|
+
import { retryingFetch } from "./exec.js";
|
|
13
|
+
import { gather } from "./gather.js";
|
|
14
|
+
import { writeInit } from "./init.js";
|
|
15
|
+
import { narrate } from "./llm.js";
|
|
16
|
+
import { redactText, redactValue } from "./redact.js";
|
|
17
|
+
import { renderMarkdown } from "./report.js";
|
|
18
|
+
import { fetchTrace, langfuseClient, parseLangfuseTrace } from "./sources/langfuse.js";
|
|
19
|
+
import { gatherLangsmith, parseLangsmithShare } from "./sources/langsmith.js";
|
|
20
|
+
import { gatherArchive, gatherLogs } from "./sources/logs.js";
|
|
21
|
+
import { connect } from "./sources/postgres.js";
|
|
22
|
+
import { gatherSentry } from "./sources/sentry.js";
|
|
23
|
+
import { assertSafeCheckoutPath, assertSafeRelativePath, cleanWorktrees, verifyReplay } from "./verify.js";
|
|
24
|
+
const USAGE = `reprobe "<complaint>" [--trace <langfuse|langsmith url or id>] [--user <id|phone|email>] [--chat <id>]
|
|
25
|
+
[--sentry <issue>] [--since 2h] [--at <iso>] [--agent claude|codex|none] [--model <m>] [--summary]
|
|
26
|
+
Gather evidence read-only, run the detectors, write a case, then hand it to your coding agent.
|
|
27
|
+
|
|
28
|
+
reprobe agent <case> [--agent claude|codex] [--model <m>] [--headless] (re)start the agent on a case
|
|
29
|
+
reprobe init [--force] detect sources and write .reprobe/config.json
|
|
30
|
+
reprobe doctor check every source with a real read
|
|
31
|
+
|
|
32
|
+
Tools the agent uses (each appends citable evidence to the case):
|
|
33
|
+
reprobe query <case> "<SELECT ...>" one read-only query
|
|
34
|
+
reprobe logs <case> [--grep <text>]... [--since <iso>] [--until <iso>]
|
|
35
|
+
reprobe sentry <case> [--issue <id>] [--user <id>]
|
|
36
|
+
reprobe trace <case> <langfuse|langsmith url or id>
|
|
37
|
+
reprobe verify <case> <commit>... run the replay in a detached worktree per commit
|
|
38
|
+
reprobe report <case> validate diagnosis.json citations, render report.md
|
|
39
|
+
|
|
40
|
+
reprobe analyze <case> re-run detectors and the report from the saved evidence, offline
|
|
41
|
+
reprobe clean remove reprobe's worktrees for this repo
|
|
42
|
+
Global: --config <path to .reprobe/config.json>`;
|
|
43
|
+
export function parseDuration(s) {
|
|
44
|
+
const m = s.trim().match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i);
|
|
45
|
+
if (!m)
|
|
46
|
+
throw new Error(`bad duration "${s}" (use 30m, 2h, 3d)`);
|
|
47
|
+
return Number(m[1]) * { m: 60_000, min: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
|
|
48
|
+
}
|
|
49
|
+
const say = (s) => process.stderr.write(`reprobe: ${redactText(s)}\n`);
|
|
50
|
+
const out = (s) => process.stdout.write(`${redactText(s)}\n`);
|
|
51
|
+
export function formatDoctorCheck(c) {
|
|
52
|
+
return `${c.ok ? 'ok ' : c.required ? 'FAIL' : 'MISS'} ${redactText(c.name)}: ${redactText(c.detail)}${c.fix && !c.ok ? `\n fix: ${redactText(c.fix)}` : ''}\n`;
|
|
53
|
+
}
|
|
54
|
+
function caseReplay(dir, g, pack) {
|
|
55
|
+
return readAgentReplay(dir) ?? detect(g, pack).find((f) => f.replay)?.replay;
|
|
56
|
+
}
|
|
57
|
+
function readDiagnosis(dir, g) {
|
|
58
|
+
const file = join(dir, 'diagnosis.json');
|
|
59
|
+
return existsSync(file) ? redactValue(validateDiagnosis(JSON.parse(readFileSync(file, 'utf8')), new Set(g.evidence.map((e) => e.id)))) : null;
|
|
60
|
+
}
|
|
61
|
+
export function replayFingerprint(replay) {
|
|
62
|
+
return createHash('sha256').update(JSON.stringify(replay)).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
export function readVerify(dir, replay) {
|
|
65
|
+
const file = join(dir, 'verify.json');
|
|
66
|
+
if (!existsSync(file))
|
|
67
|
+
return [];
|
|
68
|
+
const results = JSON.parse(readFileSync(file, 'utf8'));
|
|
69
|
+
const fingerprint = replayFingerprint(replay);
|
|
70
|
+
return results.filter((result) => result.replayFingerprint === fingerprint);
|
|
71
|
+
}
|
|
72
|
+
export function writeReplayFiles(dir, findings) {
|
|
73
|
+
for (const finding of findings) {
|
|
74
|
+
for (const file of finding.replay?.files ?? []) {
|
|
75
|
+
assertSafeRelativePath(file.path, 'replay file');
|
|
76
|
+
const target = assertSafeCheckoutPath(dir, join('replay', file.path));
|
|
77
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
+
writeFileSync(target, file.content);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function write(dir, g, findings, narrative, verify) {
|
|
83
|
+
const caseId = caseIdFor(g);
|
|
84
|
+
const safe = redactValue({ g, findings, narrative, verify });
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
const evidencePath = assertSafeCheckoutPath(dir, 'evidence.json');
|
|
87
|
+
const findingsPath = assertSafeCheckoutPath(dir, 'findings.json');
|
|
88
|
+
const verifyPath = assertSafeCheckoutPath(dir, 'verify.json');
|
|
89
|
+
const reportPath = assertSafeCheckoutPath(dir, 'report.md');
|
|
90
|
+
writeFileSync(evidencePath, JSON.stringify(safe.g, null, 2));
|
|
91
|
+
writeFileSync(findingsPath, JSON.stringify({ caseId, subject: safe.g.subject, window: safe.g.window, sources: safe.g.sources, findings: safe.findings }, null, 2));
|
|
92
|
+
writeReplayFiles(dir, safe.findings);
|
|
93
|
+
writeFileSync(verifyPath, JSON.stringify(safe.verify, null, 2));
|
|
94
|
+
writeFileSync(reportPath, renderMarkdown(caseId, safe.g, safe.findings, safe.narrative, safe.verify, readDiagnosis(dir, safe.g)));
|
|
95
|
+
}
|
|
96
|
+
export async function main(argv) {
|
|
97
|
+
const { values, positionals } = parseArgs({
|
|
98
|
+
args: argv,
|
|
99
|
+
allowPositionals: true,
|
|
100
|
+
options: {
|
|
101
|
+
config: { type: 'string' },
|
|
102
|
+
trace: { type: 'string' },
|
|
103
|
+
user: { type: 'string' },
|
|
104
|
+
chat: { type: 'string' },
|
|
105
|
+
sentry: { type: 'string' },
|
|
106
|
+
reporter: { type: 'string' },
|
|
107
|
+
since: { type: 'string', default: '2h' },
|
|
108
|
+
until: { type: 'string' },
|
|
109
|
+
at: { type: 'string' },
|
|
110
|
+
out: { type: 'string' },
|
|
111
|
+
agent: { type: 'string' },
|
|
112
|
+
model: { type: 'string' },
|
|
113
|
+
summary: { type: 'boolean', default: false },
|
|
114
|
+
'no-llm': { type: 'boolean', default: false },
|
|
115
|
+
'no-agent': { type: 'boolean', default: false },
|
|
116
|
+
headless: { type: 'boolean', default: false },
|
|
117
|
+
verify: { type: 'string', multiple: true, default: [] },
|
|
118
|
+
grep: { type: 'string', multiple: true, default: [] },
|
|
119
|
+
issue: { type: 'string' },
|
|
120
|
+
force: { type: 'boolean', default: false },
|
|
121
|
+
help: { type: 'boolean', short: 'h' },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const [cmd, ...rest] = positionals;
|
|
125
|
+
if (values.help || !cmd)
|
|
126
|
+
return out(USAGE);
|
|
127
|
+
loadConfig(values.config);
|
|
128
|
+
if (cmd === 'init') {
|
|
129
|
+
const { path, detected } = writeInit(config.repo, values.force);
|
|
130
|
+
out(`wrote ${path}`);
|
|
131
|
+
for (const f of detected.found)
|
|
132
|
+
out(` found: ${f}`);
|
|
133
|
+
for (const t of detected.todo)
|
|
134
|
+
out(` todo: ${t}`);
|
|
135
|
+
out('next: edit the todos, then `reprobe doctor`');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const pack = await loadPack();
|
|
139
|
+
if (cmd === 'doctor') {
|
|
140
|
+
const { checks, ok } = await doctor(pack);
|
|
141
|
+
for (const c of checks)
|
|
142
|
+
process.stdout.write(formatDoctorCheck(c));
|
|
143
|
+
out(ok ? '\nrequired sources ready' : '\nrequired sources missing (see FAIL lines)');
|
|
144
|
+
process.exitCode = ok ? 0 : 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (cmd === 'clean')
|
|
148
|
+
return out(`removed ${(await cleanWorktrees()).length} worktree(s)`);
|
|
149
|
+
if (['agent', 'analyze', 'verify', 'report', 'query', 'logs', 'sentry', 'trace'].includes(cmd)) {
|
|
150
|
+
if (!rest[0])
|
|
151
|
+
throw new Error(`${cmd} needs a case dir`);
|
|
152
|
+
const dir = caseDirFor(rest[0]);
|
|
153
|
+
assertCasesOutsideRepo(dir, config.repo);
|
|
154
|
+
const g = readGathered(dir);
|
|
155
|
+
const add = (items, show = (e) => `${e.at ?? '-'} ${e.summary}`) => {
|
|
156
|
+
const saved = appendEvidence(dir, items);
|
|
157
|
+
if (!items.length)
|
|
158
|
+
out('(nothing found)');
|
|
159
|
+
for (const e of redactValue(saved))
|
|
160
|
+
out(`[${e.id}] ${show(e)}`);
|
|
161
|
+
};
|
|
162
|
+
switch (cmd) {
|
|
163
|
+
case 'agent': {
|
|
164
|
+
process.exitCode = await launchAgent(dir, g, detect(g, pack), { agent: values.agent, model: values.model, headless: values.headless });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case 'analyze': {
|
|
168
|
+
const findings = detect(g, pack);
|
|
169
|
+
const replay = caseReplay(dir, g, pack);
|
|
170
|
+
write(dir, g, findings, values.summary ? await narrate(g, findings) : null, replay ? readVerify(dir, replay) : []);
|
|
171
|
+
return out(join(dir, 'report.md'));
|
|
172
|
+
}
|
|
173
|
+
case 'verify': {
|
|
174
|
+
const replay = caseReplay(dir, g, pack);
|
|
175
|
+
if (!replay)
|
|
176
|
+
throw new Error('this case has no replay: write replay.json (see the playbook) or let a detector produce one');
|
|
177
|
+
const verifyPath = assertSafeCheckoutPath(dir, 'verify.json');
|
|
178
|
+
const results = await verifyReplay(dir, replay, rest.slice(1), pack, say);
|
|
179
|
+
const fingerprint = replayFingerprint(replay);
|
|
180
|
+
const kept = [...readVerify(dir, replay).filter((v) => !results.some((r) => r.sha === v.sha)), ...results]
|
|
181
|
+
.map((result) => ({ ...result, replayFingerprint: fingerprint }));
|
|
182
|
+
writeFileSync(verifyPath, JSON.stringify(redactValue(kept), null, 2));
|
|
183
|
+
for (const v of results)
|
|
184
|
+
out(`${v.commit} ${v.sha.slice(0, 10)} ${v.outcome.toUpperCase()} ${v.detail}\n log: ${v.logFile}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
case 'report': {
|
|
188
|
+
const findings = detect(g, pack);
|
|
189
|
+
const replay = caseReplay(dir, g, pack);
|
|
190
|
+
write(dir, g, findings, null, replay ? readVerify(dir, replay) : []);
|
|
191
|
+
const d = readDiagnosis(dir, g);
|
|
192
|
+
out(join(dir, 'report.md'));
|
|
193
|
+
if (!d)
|
|
194
|
+
out('no diagnosis.json yet: only detector findings are in the report');
|
|
195
|
+
else
|
|
196
|
+
out(`diagnosis: ${d.rootCause ? `[${d.rootCause.confidence}] ${d.rootCause.text.slice(0, 160)}` : 'no root cause survived citation checks'}; ${d.dropped.length} claim(s) dropped`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
case 'query': {
|
|
200
|
+
const sql = rest[1];
|
|
201
|
+
if (!sql)
|
|
202
|
+
throw new Error('query needs SQL');
|
|
203
|
+
const { db, status } = await connect();
|
|
204
|
+
if (!db)
|
|
205
|
+
throw new Error(`postgres: ${status.detail}`);
|
|
206
|
+
const rows = await db.query(sql);
|
|
207
|
+
const id = `query:${createHash('sha256').update(sql).digest('hex').slice(0, 10)}`;
|
|
208
|
+
add([{ id, source: 'query', at: null, kind: 'query.rows', summary: `${rows.length} row(s) for: ${sql.replace(/\s+/g, ' ').slice(0, 160)}`, data: { sql, rows: rows.slice(0, 200), truncated: rows.length > 200 } }], (e) => e.summary);
|
|
209
|
+
out(JSON.stringify(redactValue(rows.slice(0, 50)), null, 1).slice(0, 20_000));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case 'logs': {
|
|
213
|
+
const w = { from: values.since && /^\d{4}-/.test(values.since) ? values.since : g.window.from, to: values.until ?? g.window.to };
|
|
214
|
+
const ids = values.grep.length ? values.grep : [g.subject.userId, ...g.subject.sessionIds].filter((x) => Boolean(x));
|
|
215
|
+
const results = [...(await gatherLogs(w, ids)), await gatherArchive(w, ids)];
|
|
216
|
+
for (const r of results)
|
|
217
|
+
if (r.status.state !== 'ok')
|
|
218
|
+
say(`${r.status.source} ${r.status.state}: ${r.status.detail}`);
|
|
219
|
+
const lines = results.flatMap((r) => r.evidence).filter((e) => !values.grep.length || values.grep.some((t) => JSON.stringify(e.data).includes(t)));
|
|
220
|
+
return add(lines);
|
|
221
|
+
}
|
|
222
|
+
case 'sentry': {
|
|
223
|
+
const r = await gatherSentry({ window: g.window, userId: values.user ?? g.subject.userId, issue: values.issue });
|
|
224
|
+
if (r.status.state !== 'ok')
|
|
225
|
+
say(`sentry ${r.status.state}: ${r.status.detail}`);
|
|
226
|
+
return add(r.evidence);
|
|
227
|
+
}
|
|
228
|
+
case 'trace': {
|
|
229
|
+
const ref = rest[1];
|
|
230
|
+
if (!ref)
|
|
231
|
+
throw new Error('trace needs a Langfuse or LangSmith url or id');
|
|
232
|
+
const lfId = parseLangfuseTrace(ref);
|
|
233
|
+
const share = lfId ? null : parseLangsmithShare(ref);
|
|
234
|
+
if (lfId) {
|
|
235
|
+
const lf = await langfuseClient();
|
|
236
|
+
if (!lf)
|
|
237
|
+
throw new Error('langfuse is not configured or its keys did not resolve');
|
|
238
|
+
const r = await fetchTrace(lf, lfId, pack?.observation);
|
|
239
|
+
if (r.status.state !== 'ok')
|
|
240
|
+
say(`langfuse ${r.status.state}: ${r.status.detail}`);
|
|
241
|
+
return add(r.evidence);
|
|
242
|
+
}
|
|
243
|
+
if (!share)
|
|
244
|
+
throw new Error('not a Langfuse trace or a LangSmith share link');
|
|
245
|
+
const r = await gatherLangsmith(share, retryingFetch());
|
|
246
|
+
if (r.status.state !== 'ok')
|
|
247
|
+
say(`langsmith ${r.status.state}: ${r.status.detail}`);
|
|
248
|
+
return add(r.evidence);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const g = await gather({ complaint: cmd, trace: values.trace, user: values.user, chat: values.chat, sentry: values.sentry, reporter: values.reporter, at: values.at, sinceMs: parseDuration(values.since) }, pack, say);
|
|
253
|
+
const dir = resolve(values.out ?? config.casesDir, caseIdFor(g));
|
|
254
|
+
assertCasesOutsideRepo(dir, config.repo);
|
|
255
|
+
const findings = detect(g, pack);
|
|
256
|
+
const summary = values.summary && !values['no-llm'] ? (say('asking the LLM for a cited summary'), await narrate(g, findings)) : null;
|
|
257
|
+
const replay = findings.find((f) => f.replay)?.replay;
|
|
258
|
+
mkdirSync(dir, { recursive: true });
|
|
259
|
+
const verify = values.verify.length && replay
|
|
260
|
+
? (await verifyReplay(dir, replay, values.verify, pack, say)).map((result) => ({ ...result, replayFingerprint: replayFingerprint(replay) }))
|
|
261
|
+
: [];
|
|
262
|
+
write(dir, g, findings, summary, verify);
|
|
263
|
+
const top = findings[0];
|
|
264
|
+
out(join(dir, 'report.md'));
|
|
265
|
+
out(top ? `top: [${top.confidence}] ${top.title}${top.replay ? `\nreplay: ${top.replay.files.map((f) => f.path).join(', ')}` : ''}` : 'no signature matched');
|
|
266
|
+
for (const v of verify)
|
|
267
|
+
out(`verify ${v.commit}: ${v.outcome.toUpperCase()} ${v.detail}`);
|
|
268
|
+
const wantAgent = !values['no-agent'] && values.agent !== 'none' && process.stdin.isTTY && process.stdout.isTTY;
|
|
269
|
+
if (wantAgent)
|
|
270
|
+
process.exitCode = await launchAgent(dir, g, findings, { agent: values.agent, model: values.model });
|
|
271
|
+
else
|
|
272
|
+
out(`next: reprobe agent ${dir}`);
|
|
273
|
+
}
|
|
274
|
+
const fail = (e) => {
|
|
275
|
+
process.stderr.write(`reprobe: ${redactText(e?.message ?? String(e))}\n`);
|
|
276
|
+
process.exitCode = 2;
|
|
277
|
+
};
|
|
278
|
+
export const run = (argv) => main(argv).catch(fail);
|
|
279
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
280
|
+
void run(process.argv.slice(2));
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type Runner } from './exec.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Where a credential comes from. Config files hold references, never values:
|
|
4
|
+
* - "env:NAME" reads an environment variable.
|
|
5
|
+
* - { cmd: [...] } runs a command (no shell) and takes its stdout; `json` or `kv` picks one key from it.
|
|
6
|
+
* "{repo}" in an argument is replaced with the repo path.
|
|
7
|
+
*/
|
|
8
|
+
export type SecretRef = string | {
|
|
9
|
+
cmd: string[];
|
|
10
|
+
cwd?: string;
|
|
11
|
+
json?: string;
|
|
12
|
+
kv?: string;
|
|
13
|
+
};
|
|
14
|
+
export type LogSource = {
|
|
15
|
+
name: string;
|
|
16
|
+
railway: {
|
|
17
|
+
service: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Any CLI that prints log lines: fly logs, vercel logs, kubectl logs, docker logs. "{since}"/"{until}" are ISO times. */
|
|
22
|
+
| {
|
|
23
|
+
name: string;
|
|
24
|
+
command: string[];
|
|
25
|
+
cwd?: string;
|
|
26
|
+
} | {
|
|
27
|
+
name: string;
|
|
28
|
+
file: string;
|
|
29
|
+
};
|
|
30
|
+
export type SetupStep =
|
|
31
|
+
/** Install node_modules for this directory once per lockfile hash, cached outside the worktree, then symlink it in.
|
|
32
|
+
* Like every step, it runs only for replays whose cwd is inside its directory. */
|
|
33
|
+
{
|
|
34
|
+
nodeModules: string;
|
|
35
|
+
} | {
|
|
36
|
+
cwd: string;
|
|
37
|
+
run: string[];
|
|
38
|
+
};
|
|
39
|
+
export interface FileConfig {
|
|
40
|
+
repo?: string;
|
|
41
|
+
reporter?: string;
|
|
42
|
+
pack?: string;
|
|
43
|
+
sources?: {
|
|
44
|
+
postgres?: {
|
|
45
|
+
url: SecretRef;
|
|
46
|
+
};
|
|
47
|
+
langfuse?: {
|
|
48
|
+
baseUrl: SecretRef;
|
|
49
|
+
publicKey: SecretRef;
|
|
50
|
+
secretKey: SecretRef;
|
|
51
|
+
};
|
|
52
|
+
sentry?: {
|
|
53
|
+
org: string;
|
|
54
|
+
projects: string[];
|
|
55
|
+
url?: string;
|
|
56
|
+
};
|
|
57
|
+
logs?: LogSource[];
|
|
58
|
+
/** Log-shaped tool output in saved Claude Code transcripts: a fallback once live logs have expired. */
|
|
59
|
+
archive?: {
|
|
60
|
+
dir: string;
|
|
61
|
+
glob: string;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
verify?: {
|
|
65
|
+
setup?: SetupStep[];
|
|
66
|
+
allow?: string[];
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export interface Config extends Required<Pick<FileConfig, 'sources'>> {
|
|
70
|
+
/** The config file, or null when running without one. */
|
|
71
|
+
file: string | null;
|
|
72
|
+
/** The code checkout that replays run against (in detached worktrees, never in place). */
|
|
73
|
+
repo: string;
|
|
74
|
+
reporter?: string;
|
|
75
|
+
pack: string | null;
|
|
76
|
+
cacheDir: string;
|
|
77
|
+
casesDir: string;
|
|
78
|
+
llmModel: string;
|
|
79
|
+
verify: {
|
|
80
|
+
setup: SetupStep[];
|
|
81
|
+
allow: string[];
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Case evidence must never be committable: outside every git work tree, or at a path git ignores.
|
|
86
|
+
* Symlink aliases are resolved first.
|
|
87
|
+
*/
|
|
88
|
+
export declare function assertCasesOutsideRepo(casesPath: string, repoPath: string): void;
|
|
89
|
+
/** --config, then REPROBE_CONFIG, then .reprobe/config.json at the top of the current git repo. */
|
|
90
|
+
export declare function findConfigFile(flag?: string, cwd?: string, env?: NodeJS.ProcessEnv): string | null;
|
|
91
|
+
export declare function buildConfig(file: string | null, raw: FileConfig, cwd?: string, env?: NodeJS.ProcessEnv): Config;
|
|
92
|
+
export declare let config: Config;
|
|
93
|
+
export declare function loadConfig(flag?: string): Config;
|
|
94
|
+
/** Tests and packs set config directly. */
|
|
95
|
+
export declare function setConfig(c: Config): void;
|
|
96
|
+
export declare function resolveSecret(ref: SecretRef | undefined, runner?: Runner): Promise<{
|
|
97
|
+
value: string | null;
|
|
98
|
+
detail: string;
|
|
99
|
+
}>;
|
|
100
|
+
export declare function firstLine(s: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
import { redactText } from "./redact.js";
|
|
7
|
+
const expand = (p, base) => {
|
|
8
|
+
const home = p === '~' || p.startsWith('~/') ? join(homedir(), p.slice(1)) : p;
|
|
9
|
+
return isAbsolute(home) ? home : resolve(base, home);
|
|
10
|
+
};
|
|
11
|
+
function canonicalPath(p) {
|
|
12
|
+
const abs = resolve(p);
|
|
13
|
+
if (existsSync(abs))
|
|
14
|
+
return realpathSync(abs);
|
|
15
|
+
const parent = dirname(abs);
|
|
16
|
+
return parent === abs ? abs : join(canonicalPath(parent), basename(abs));
|
|
17
|
+
}
|
|
18
|
+
function containingGitTop(path) {
|
|
19
|
+
let cursor = canonicalPath(path);
|
|
20
|
+
while (!existsSync(cursor)) {
|
|
21
|
+
const parent = dirname(cursor);
|
|
22
|
+
if (parent === cursor)
|
|
23
|
+
return null;
|
|
24
|
+
cursor = parent;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: cursor, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Case evidence must never be committable: outside every git work tree, or at a path git ignores.
|
|
35
|
+
* Symlink aliases are resolved first.
|
|
36
|
+
*/
|
|
37
|
+
export function assertCasesOutsideRepo(casesPath, repoPath) {
|
|
38
|
+
const cases = canonicalPath(casesPath);
|
|
39
|
+
const repo = canonicalPath(repoPath);
|
|
40
|
+
const rel = relative(repo, cases);
|
|
41
|
+
const inConfiguredRepo = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
42
|
+
const top = inConfiguredRepo ? repo : containingGitTop(cases);
|
|
43
|
+
if (top && !gitIgnores(top, cases)) {
|
|
44
|
+
throw new Error(`case directory must be outside the repo or ignored by git (${casesPath})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function gitIgnores(top, path) {
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('git', ['-C', top, 'check-ignore', '-q', '--no-index', '--', path], { stdio: 'ignore' });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function gitTop(cwd) {
|
|
57
|
+
try {
|
|
58
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** --config, then REPROBE_CONFIG, then .reprobe/config.json at the top of the current git repo. */
|
|
65
|
+
export function findConfigFile(flag, cwd = process.cwd(), env = process.env) {
|
|
66
|
+
if (flag)
|
|
67
|
+
return resolve(cwd, flag);
|
|
68
|
+
if (env.REPROBE_CONFIG)
|
|
69
|
+
return resolve(cwd, env.REPROBE_CONFIG);
|
|
70
|
+
const top = gitTop(cwd);
|
|
71
|
+
const candidate = top ? join(top, '.reprobe/config.json') : null;
|
|
72
|
+
return candidate && existsSync(candidate) ? candidate : null;
|
|
73
|
+
}
|
|
74
|
+
export function buildConfig(file, raw, cwd = process.cwd(), env = process.env) {
|
|
75
|
+
const base = file ? (basename(dirname(file)) === '.reprobe' ? dirname(dirname(file)) : dirname(file)) : cwd;
|
|
76
|
+
const repo = raw.repo ? expand(raw.repo, base) : (gitTop(base) ?? base);
|
|
77
|
+
const cacheDir = env.REPROBE_CACHE_DIR ? expand(env.REPROBE_CACHE_DIR, cwd) : join(homedir(), '.cache/reprobe');
|
|
78
|
+
const sources = raw.sources ?? {};
|
|
79
|
+
const casesDir = join(env.REPROBE_CASES_DIR ? expand(env.REPROBE_CASES_DIR, cwd) : join(homedir(), '.reprobe/cases'), basename(repo));
|
|
80
|
+
return {
|
|
81
|
+
file,
|
|
82
|
+
repo,
|
|
83
|
+
reporter: env.REPROBE_REPORTER ?? raw.reporter,
|
|
84
|
+
pack: raw.pack ? expand(raw.pack, file ? dirname(file) : cwd) : null,
|
|
85
|
+
cacheDir,
|
|
86
|
+
casesDir,
|
|
87
|
+
llmModel: env.REPROBE_LLM_MODEL ?? 'sonnet',
|
|
88
|
+
sources: {
|
|
89
|
+
...sources,
|
|
90
|
+
archive: sources.archive ? { ...sources.archive, dir: expand(sources.archive.dir, base) } : undefined,
|
|
91
|
+
},
|
|
92
|
+
verify: { setup: raw.verify?.setup ?? [], allow: raw.verify?.allow ?? [] },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export let config = buildConfig(null, {});
|
|
96
|
+
export function loadConfig(flag) {
|
|
97
|
+
const file = findConfigFile(flag);
|
|
98
|
+
const raw = file ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
99
|
+
config = buildConfig(file, raw);
|
|
100
|
+
secretCache.clear();
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
/** Tests and packs set config directly. */
|
|
104
|
+
export function setConfig(c) {
|
|
105
|
+
config = c;
|
|
106
|
+
secretCache.clear();
|
|
107
|
+
}
|
|
108
|
+
const secretCache = new Map();
|
|
109
|
+
export async function resolveSecret(ref, runner = run) {
|
|
110
|
+
if (!ref)
|
|
111
|
+
return { value: null, detail: 'not configured' };
|
|
112
|
+
if (typeof ref === 'string') {
|
|
113
|
+
if (!ref.startsWith('env:'))
|
|
114
|
+
return { value: ref, detail: 'inline value in config' };
|
|
115
|
+
const v = process.env[ref.slice(4)];
|
|
116
|
+
return v ? { value: v, detail: ref } : { value: null, detail: `${ref.slice(4)} is not set` };
|
|
117
|
+
}
|
|
118
|
+
const key = JSON.stringify(ref);
|
|
119
|
+
const cached = secretCache.get(key);
|
|
120
|
+
if (cached)
|
|
121
|
+
return { value: cached, detail: `\`${ref.cmd[0]}\`` };
|
|
122
|
+
const args = ref.cmd.slice(1).map((a) => a.replaceAll('{repo}', config.repo));
|
|
123
|
+
const r = await runner(ref.cmd[0], args, { cwd: ref.cwd ? expand(ref.cwd, config.repo) : config.repo, timeoutMs: 30_000 });
|
|
124
|
+
if (r.code === 127)
|
|
125
|
+
return { value: null, detail: `${ref.cmd[0]} is not installed` };
|
|
126
|
+
if (r.code !== 0)
|
|
127
|
+
return { value: null, detail: `${ref.cmd[0]} failed: ${firstLine(r.stderr || r.stdout)}` };
|
|
128
|
+
let value = r.stdout.trim();
|
|
129
|
+
if (ref.json) {
|
|
130
|
+
try {
|
|
131
|
+
const v = JSON.parse(value)[ref.json];
|
|
132
|
+
value = typeof v === 'string' && v ? v : null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
value = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (ref.kv) {
|
|
139
|
+
const line = r.stdout.split('\n').find((l) => l.startsWith(`${ref.kv}=`));
|
|
140
|
+
value = line ? line.slice(ref.kv.length + 1).trim() : null;
|
|
141
|
+
}
|
|
142
|
+
if (!value)
|
|
143
|
+
return { value: null, detail: `${ref.cmd[0]} returned no ${ref.json ?? ref.kv ?? 'value'}` };
|
|
144
|
+
secretCache.set(key, value);
|
|
145
|
+
return { value, detail: `\`${ref.cmd[0]}\`` };
|
|
146
|
+
}
|
|
147
|
+
export function firstLine(s) {
|
|
148
|
+
return redactText((s.split('\n').find((l) => l.trim() && !/Config as Code|Migrate:|Existing files keep/.test(l)) ?? '').trim()).slice(0, 200);
|
|
149
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Detector, Evidence, Finding, Gathered, Pack } from './types.ts';
|
|
2
|
+
export declare const ms: (s: string | null) => number;
|
|
3
|
+
export declare const d: <T>(e: Evidence) => T;
|
|
4
|
+
export declare const byStableTime: (a: Evidence, b: Evidence) => number;
|
|
5
|
+
export declare function caseIdFor(g: Gathered): string;
|
|
6
|
+
export declare function routeTemplate(path: string): string;
|
|
7
|
+
export declare const CORE_DETECTORS: Detector[];
|
|
8
|
+
export declare function detect(g: Gathered, pack?: Pack | null): Finding[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parseIntent } from "./gather.js";
|
|
3
|
+
export const ms = (s) => {
|
|
4
|
+
if (!s)
|
|
5
|
+
return NaN;
|
|
6
|
+
const normalized = /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(s) ? `${s}Z` : s;
|
|
7
|
+
return Date.parse(normalized);
|
|
8
|
+
};
|
|
9
|
+
export const d = (e) => e.data;
|
|
10
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
11
|
+
export const byStableTime = (a, b) => {
|
|
12
|
+
const atA = ms(a.at);
|
|
13
|
+
const atB = ms(b.at);
|
|
14
|
+
return (Number.isFinite(atA) && Number.isFinite(atB) ? atA - atB : cmp(a.at ?? '', b.at ?? '')) || cmp(a.id, b.id);
|
|
15
|
+
};
|
|
16
|
+
export function caseIdFor(g) {
|
|
17
|
+
const h = createHash('sha256').update(JSON.stringify([g.input.complaint, g.input.trace ?? '', g.input.user ?? '', g.input.chat ?? '', g.window.to])).digest('hex');
|
|
18
|
+
return `${g.window.to.slice(0, 10)}-${h.slice(0, 6)}`;
|
|
19
|
+
}
|
|
20
|
+
export function routeTemplate(path) {
|
|
21
|
+
return path
|
|
22
|
+
.split('?')[0]
|
|
23
|
+
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
|
24
|
+
.replace(/\/c[a-z0-9]{24}\b/g, '/:id')
|
|
25
|
+
.replace(/\/\d+(?=\/|$)/g, '/:n');
|
|
26
|
+
}
|
|
27
|
+
// ── A deliberate 4xx turned into a 500 (Nest/Spring-style `*Exception` names) ────────────────────────────────────
|
|
28
|
+
const CLIENT_EXCEPTIONS = /\b(BadRequest|Unauthorized|Forbidden|NotFound|Conflict|Gone|PayloadTooLarge|UnprocessableEntity|PreconditionFailed|UnsupportedMediaType)Exception: ([^|]+?)\s*(?:\||$)/;
|
|
29
|
+
const maskedClientError = ({ ev, intent }) => {
|
|
30
|
+
const lines = ev.filter((e) => e.source === 'logs' || e.source === 'archive');
|
|
31
|
+
const failed = lines.filter((e) => (d(e).status ?? 0) >= 500 && d(e).path);
|
|
32
|
+
const groups = new Map();
|
|
33
|
+
for (const e of lines) {
|
|
34
|
+
const m = d(e).text.match(CLIENT_EXCEPTIONS);
|
|
35
|
+
if (!m)
|
|
36
|
+
continue;
|
|
37
|
+
const near = failed.filter((f) => {
|
|
38
|
+
const delta = Math.abs(ms(f.at) - ms(e.at));
|
|
39
|
+
if (!Number.isFinite(delta) || delta > 2_000)
|
|
40
|
+
return false;
|
|
41
|
+
const matches = lines.flatMap((candidate) => {
|
|
42
|
+
const match = d(candidate).text.match(CLIENT_EXCEPTIONS);
|
|
43
|
+
const timeDelta = Math.abs(ms(f.at) - ms(candidate.at));
|
|
44
|
+
return match && Number.isFinite(timeDelta) && timeDelta <= 2_000 ? [`${match[1]} ${match[2]}`] : [];
|
|
45
|
+
});
|
|
46
|
+
return new Set(matches).size === 1 && matches[0] === `${m[1]} ${m[2]}`;
|
|
47
|
+
});
|
|
48
|
+
if (!near.length)
|
|
49
|
+
continue;
|
|
50
|
+
if (new Set(near.map((n) => `${d(n).method} ${routeTemplate(d(n).path)}`)).size !== 1)
|
|
51
|
+
continue;
|
|
52
|
+
const http = d(near[0]);
|
|
53
|
+
const route = routeTemplate(http.path);
|
|
54
|
+
const key = `${http.method} ${route} ${m[1]} ${m[2]}`;
|
|
55
|
+
const g = groups.get(key) ?? { exc: e, name: `${m[1]}Exception`, message: m[2].replace(/\s+\(\+\d+ms\)$/, ''), route, method: http.method ?? '?', hits: [], triggers: [] };
|
|
56
|
+
for (const n of near)
|
|
57
|
+
if (!g.hits.includes(n))
|
|
58
|
+
g.hits.push(n);
|
|
59
|
+
for (const w of lines) {
|
|
60
|
+
if (d(w).level === 'warn' && ms(w.at) <= ms(e.at) && ms(e.at) - ms(w.at) <= 1_000 && !g.triggers.includes(w))
|
|
61
|
+
g.triggers.push(w);
|
|
62
|
+
}
|
|
63
|
+
groups.set(key, g);
|
|
64
|
+
}
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const g of groups.values()) {
|
|
67
|
+
const attempts = g.hits.length;
|
|
68
|
+
const subjects = new Set(g.hits.map((h) => d(h).path)).size;
|
|
69
|
+
const trigger = g.triggers.slice().sort(byStableTime)[0];
|
|
70
|
+
const cited = [g.exc.id, ...g.hits.map((h) => h.id), ...g.triggers.slice(0, 3).map((t) => t.id)];
|
|
71
|
+
const retriesNote = intent.retries ? ` The complaint says the user tried ${intent.retries} time(s); the logs show ${attempts} failed attempt(s) on ${subjects} distinct ${subjects === 1 ? 'resource' : 'resources'}.` : '';
|
|
72
|
+
const orderedHits = [...g.hits].sort(byStableTime);
|
|
73
|
+
out.push({
|
|
74
|
+
signature: 'client-error-masked-as-500',
|
|
75
|
+
title: `${g.method} ${g.route} answered 500 for a deliberate ${g.name}`,
|
|
76
|
+
confidence: 'high',
|
|
77
|
+
rootCause: `A ${g.name} ("${g.message}") and HTTP 500 evidence occur within two seconds on ${g.method} ${g.route} ` +
|
|
78
|
+
`(${attempts} matching response(s), ${orderedHits[0].at ?? 'time unknown'} to ${orderedHits.at(-1)?.at ?? 'time unknown'}). The available rows do not establish a shared request ID.` +
|
|
79
|
+
(trigger ? ` A nearby warning says: "${d(trigger).text.replace(/^.*?\b(?:LOG|ERROR|WARN|DEBUG|VERBOSE)\s+\[[^\]]+\]\s*/, '').slice(0, 200)}".` : '') +
|
|
80
|
+
` Likely cause, which the replay tests: the handler turns this deliberate ${g.name} into a 500, which a client usually shows as a generic, retryable failure, so the user retries a rejection that can never succeed.` +
|
|
81
|
+
retriesNote,
|
|
82
|
+
evidence: cited,
|
|
83
|
+
unknowns: [
|
|
84
|
+
'whether the exception and HTTP 500 share a request ID; only route and timing co-occurrence are available',
|
|
85
|
+
'the request body the client sent (the server does not log it), so why the client built that request is not observable here',
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
// ── Everything else that looks wrong, without a signature ─────────────────
|
|
92
|
+
const anomalies = ({ ev }) => {
|
|
93
|
+
const kinds = [
|
|
94
|
+
['agent run failed', (e) => e.kind === 'agent.run' && /FAILED|CANCELLED/.test(e.summary)],
|
|
95
|
+
['chat turn marked run_failed', (e) => e.kind === 'chat.user' && e.summary.includes('RUN FAILED')],
|
|
96
|
+
['Langfuse span at ERROR level', (e) => e.source === 'langfuse' && e.summary.includes('level ERROR')],
|
|
97
|
+
['server 5xx', (e) => e.kind === 'http.5xx'],
|
|
98
|
+
['server error log', (e) => e.kind === 'log.error'],
|
|
99
|
+
['Sentry issue', (e) => e.kind === 'sentry.issue'],
|
|
100
|
+
['in-app bug report', (e) => e.kind === 'bug.report'],
|
|
101
|
+
];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [title, match] of kinds) {
|
|
104
|
+
const hits = ev.filter(match);
|
|
105
|
+
if (!hits.length)
|
|
106
|
+
continue;
|
|
107
|
+
out.push({
|
|
108
|
+
signature: `anomaly:${title.replace(/\s+/g, '-').toLowerCase()}`,
|
|
109
|
+
title: `${hits.length} × ${title}`,
|
|
110
|
+
confidence: 'low',
|
|
111
|
+
rootCause: `Seen in the window but not matched to a known failure signature: ${hits.slice(0, 3).map((h) => h.summary.slice(0, 120)).join(' | ')}`,
|
|
112
|
+
evidence: hits.slice(0, 10).map((h) => h.id),
|
|
113
|
+
unknowns: [],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
export const CORE_DETECTORS = [maskedClientError, anomalies];
|
|
119
|
+
const RANK = { high: 0, medium: 1, low: 2 };
|
|
120
|
+
export function detect(g, pack = null) {
|
|
121
|
+
const ev = [...g.evidence].sort(byStableTime);
|
|
122
|
+
const ctx = { g, intent: parseIntent(g.input.complaint), caseId: caseIdFor(g), ev };
|
|
123
|
+
const all = [...(pack?.detectors ?? []), ...CORE_DETECTORS].flatMap((det) => det(ctx)).map((f) => (f.replay || !pack?.replayFor ? f : { ...f, replay: pack.replayFor(f, ctx) }));
|
|
124
|
+
const signatures = new Set(all.filter((f) => !f.signature.startsWith('anomaly:')).flatMap((f) => f.evidence));
|
|
125
|
+
return all
|
|
126
|
+
.map((f) => {
|
|
127
|
+
if (!f.signature.startsWith('anomaly:'))
|
|
128
|
+
return f;
|
|
129
|
+
const evidence = f.evidence.filter((id) => !signatures.has(id));
|
|
130
|
+
const hits = evidence.map((id) => ev.find((e) => e.id === id)).filter((e) => Boolean(e));
|
|
131
|
+
const suffix = f.signature.slice('anomaly:'.length).replace(/-/g, ' ');
|
|
132
|
+
return { ...f, title: `${evidence.length} × ${suffix}`, rootCause: `Seen in the window but not matched to a known failure signature: ${hits.slice(0, 3).map((h) => h.summary.slice(0, 120)).join(' | ')}`, evidence };
|
|
133
|
+
})
|
|
134
|
+
.filter((f) => f.evidence.length)
|
|
135
|
+
.sort((a, b) => RANK[a.confidence] - RANK[b.confidence] || b.evidence.length - a.evidence.length || (a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0));
|
|
136
|
+
}
|