@capagents/forgeqe 1.1.4 → 1.1.6

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 (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. package/testmu.example.yaml +11 -0
@@ -0,0 +1,372 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { importsOf } from "../code/impact.ts"
6
+ import { DEFECT_HISTORY, readDefectHistory, type DefectHistoryFile, type PastDefect } from "./defectHistory.ts"
7
+ import type { DefectCluster } from "./defects.ts"
8
+ import type { CaseStatus } from "./types.ts"
9
+
10
+ /**
11
+ * Defect intelligence, part four: where the next defects are likely, and how the defects went over time.
12
+ *
13
+ * An area is a page or an API route, by its path without ids (/checkout, /api/cart/:id). What ForgeQE knows about each
14
+ * comes from the suites saved in one folder: the pages their cases ended on, the defects in defect-history.json, and
15
+ * how often the cases failed. A prediction scores the areas a change touches (a file named after the area, a diff that
16
+ * mentions its route) by that history, with every reason given. It is a heuristic, not a model: each prediction is
17
+ * saved and scored against the next suite, so the trend report says how often it was right.
18
+ */
19
+
20
+ export const PREDICTIONS = "defect-predictions.json"
21
+ const KEEP = 30
22
+ const WINDOW = 10
23
+ const GENERIC = new Set(["api", "v1", "v2", "v3", "rest", "graphql", "www", "app", "index", "home", "static", "assets", "src", "public"])
24
+ const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte|py|cs|java|kt|go|rb|php|rs|scala|swift)$/i
25
+ const NOT_PRODUCT = /(^|\/)(tests?|__tests__|spec|e2e|docs?|\.github|\.forgeqe)\/|\.(test|spec)\.[a-z]+$/i
26
+
27
+ export type Level = "high" | "medium" | "low"
28
+
29
+ export type RiskArea = {
30
+ area: string
31
+ level: Level
32
+ score: number
33
+ reasons: string[]
34
+ /** Changed files that touch the area. */
35
+ changed: string[]
36
+ /** Cases that ended on the area in earlier suites: the ones to run first. */
37
+ cases: string[]
38
+ defects: { id: string; title: string; seen: number; open: boolean; returns: number }[]
39
+ }
40
+
41
+ export type Prediction = {
42
+ id: string
43
+ at: string
44
+ repo: string
45
+ base: string
46
+ head: string
47
+ changed: string[]
48
+ areas: RiskArea[]
49
+ /** Changed product code that touches no area ForgeQE has seen: nothing tests it yet. */
50
+ untested: string[]
51
+ /** The cases to run first, the riskiest areas' first. */
52
+ runFirst: string[]
53
+ /** Filled in by the first suite after it: whether its defects fell in the areas predicted. */
54
+ scored?: { suite: string; at: string; defects: number; inPredicted: number; high: number; highWithDefect: number }
55
+ }
56
+
57
+ type PredictionsFile = { version: 1; predictions: Prediction[] }
58
+
59
+ /** An area's path, from a URL or a route ("localhost:4517/api/cart/:id" → /api/cart/:id). */
60
+ export function areaPath(urlOrRoute: string): string {
61
+ // "localhost:4517/api" parses as a URL whose scheme is "localhost:": only a "://" makes one.
62
+ let p: string
63
+ try {
64
+ p = urlOrRoute.includes("://") ? new URL(urlOrRoute).pathname : urlOrRoute.slice(Math.max(0, urlOrRoute.indexOf("/"))) || "/"
65
+ } catch {
66
+ p = "/"
67
+ }
68
+ if (!p.startsWith("/")) p = "/"
69
+ const segs = p.split(/[?#]/)[0].split("/").map((s) => (s && /\d/.test(s) && /^(\d+|[0-9a-f-]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$/i.test(s) ? ":id" : s))
70
+ return segs.join("/").replace(/\/+$/, "") || "/"
71
+ }
72
+
73
+ /** The area a defect is in: its route for a failed request, else the page it was seen on. */
74
+ export function areaOf(d: Pick<DefectCluster, "kind" | "title" | "page"> | (Pick<PastDefect, "kind" | "title"> & { page?: string })): string | undefined {
75
+ if (d.kind === "request") return areaPath(d.title.replace(/ (returns \d{3}|fails)$/, ""))
76
+ return d.page ? areaPath(d.page) : undefined
77
+ }
78
+
79
+ /** The words of an area a file or a change would carry: /api/cart/:id → cart. */
80
+ export function areaWords(area: string): string[] {
81
+ return area.split("/").filter((w) => w.length >= 3 && !w.startsWith(":") && !GENERIC.has(w.toLowerCase()))
82
+ }
83
+
84
+ type SavedSuite = { name: string; at: string; results: { case: { id: string }; status: CaseStatus; finalUrl?: string }[] }
85
+
86
+ function savedSuites(folder: string): SavedSuite[] {
87
+ let names: string[] = []
88
+ try {
89
+ names = readdirSync(folder).filter((n) => existsSync(path.join(folder, n, "suite.json")))
90
+ } catch {}
91
+ const out: SavedSuite[] = []
92
+ for (const name of names) {
93
+ try {
94
+ const s = JSON.parse(readFileSync(path.join(folder, name, "suite.json"), "utf8"))
95
+ if (s?.startedAt && Array.isArray(s.results)) out.push({ name, at: s.startedAt, results: s.results })
96
+ } catch {}
97
+ }
98
+ return out.sort((a, b) => a.at.localeCompare(b.at))
99
+ }
100
+
101
+ export type AreaFacts = { area: string; cases: Set<string>; runs: number; failed: number; failedLast: boolean; defects: RiskArea["defects"] }
102
+
103
+ /** What the suites in `folder` and its defect history know about each area. */
104
+ export function areaFacts(folder: string, history: DefectHistoryFile = readDefectHistory(path.join(folder, DEFECT_HISTORY))): Map<string, AreaFacts> {
105
+ const facts = new Map<string, AreaFacts>()
106
+ const get = (area: string) => {
107
+ let f = facts.get(area)
108
+ if (!f) facts.set(area, (f = { area, cases: new Set(), runs: 0, failed: 0, failedLast: false, defects: [] }))
109
+ return f
110
+ }
111
+ const suites = savedSuites(folder).slice(-WINDOW)
112
+ suites.forEach((s, i) => {
113
+ for (const r of s.results) {
114
+ if (!r.finalUrl || r.status === "stopped") continue
115
+ const f = get(areaPath(r.finalUrl))
116
+ f.cases.add(r.case.id)
117
+ f.runs++
118
+ if (r.status !== "passed") {
119
+ f.failed++
120
+ if (i === suites.length - 1) f.failedLast = true
121
+ }
122
+ }
123
+ })
124
+ for (const [id, d] of Object.entries(history.defects)) {
125
+ const area = areaOf(d)
126
+ if (!area) continue
127
+ const f = get(area)
128
+ for (const c of d.cases) f.cases.add(c)
129
+ f.defects.push({ id, title: d.title, seen: d.seen, open: !d.goneSince, returns: d.returns?.length || 0 })
130
+ }
131
+ return facts
132
+ }
133
+
134
+ const git = (repo: string, args: string[]) => spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 50 * 1024 * 1024 })
135
+
136
+ export type Change = {
137
+ files: string[]
138
+ diff: Map<string, string>
139
+ /** For each changed file, the repository's files that import it: a change to a helper reaches what uses it. */
140
+ usedBy?: Map<string, string[]>
141
+ }
142
+
143
+ const MAX_SCAN = 4000
144
+ const MAX_READ = 300_000
145
+
146
+ /** The files a change touches, its diff, and who imports each changed file, between the merge base of `base` and `head`. */
147
+ export function changeOf(repo: string, base: string, head = "HEAD"): Change {
148
+ const range = `${base}...${head}`
149
+ const names = git(repo, ["diff", "--name-only", range])
150
+ if (names.status !== 0) throw new Error(`git diff ${range} failed in ${repo}: ${names.stderr.trim().split("\n")[0]}`)
151
+ const files = names.stdout.split("\n").map((l) => l.trim()).filter(Boolean)
152
+ const diff = new Map<string, string>()
153
+ const patch = git(repo, ["diff", "-U0", range])
154
+ let current = ""
155
+ for (const line of patch.stdout.split("\n")) {
156
+ const m = /^\+\+\+ b\/(.+)$/.exec(line)
157
+ if (m) current = m[1]
158
+ else if (current && (line.startsWith("+") || line.startsWith("-")) && !line.startsWith("---")) diff.set(current, `${diff.get(current) || ""}${line.slice(1)}\n`)
159
+ }
160
+ const usedBy = new Map<string, string[]>()
161
+ const changed = new Set(files)
162
+ const all = git(repo, ["ls-files"]).stdout.split("\n").map((l) => l.trim()).filter((f) => f && CODE.test(f) && !NOT_PRODUCT.test(f))
163
+ const known = new Set(all)
164
+ for (const rel of all.slice(0, MAX_SCAN)) {
165
+ let text = ""
166
+ try {
167
+ const file = path.join(repo, rel)
168
+ if (statSync(file).size <= MAX_READ) text = readFileSync(file, "utf8")
169
+ } catch {}
170
+ for (const dep of importsOf(rel, text, known)) if (changed.has(dep) && dep !== rel) usedBy.set(dep, [...(usedBy.get(dep) || []), rel])
171
+ }
172
+ return { files, diff, usedBy }
173
+ }
174
+
175
+ const LEVEL = (score: number): Level => (score >= 6 ? "high" : score >= 3 ? "medium" : "low")
176
+
177
+ /** Score each area the change touches, with the reasons, riskiest first. */
178
+ export function predict(facts: Map<string, AreaFacts>, change: Change): Pick<Prediction, "areas" | "untested" | "runFirst"> {
179
+ const product = change.files.filter((f) => CODE.test(f) && !NOT_PRODUCT.test(f))
180
+ const touched = new Set<string>()
181
+ const areas: RiskArea[] = []
182
+ for (const f of facts.values()) {
183
+ const words = areaWords(f.area)
184
+ const route = f.area.split("/:")[0]
185
+ const namedAfter = (file: string) => words.some((w) => file.toLowerCase().includes(w.toLowerCase()))
186
+ const named = product.filter(namedAfter)
187
+ // The route on its own: "/cart" in "/api/cart" is another route.
188
+ const own = new RegExp(`(^|[^\\w/-])${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`)
189
+ const mentioned = route.length > 1 ? product.filter((file) => own.test(change.diff.get(file) || "")) : []
190
+ const used = product.filter((file) => !named.includes(file) && (change.usedBy?.get(file) || []).some(namedAfter))
191
+ const changed = [...new Set([...mentioned, ...named, ...used])]
192
+ if (!changed.length) continue
193
+ for (const c of changed) touched.add(c)
194
+ const reasons: string[] = []
195
+ let score = 0
196
+ if (mentioned.length) {
197
+ score += 3
198
+ reasons.push(`the change mentions ${route} (${mentioned.slice(0, 3).join(", ")})`)
199
+ }
200
+ if (named.length) {
201
+ score += 2
202
+ reasons.push(`it changes ${named.slice(0, 3).join(", ")}${named.length > 3 ? ` and ${named.length - 3} more` : ""}`)
203
+ } else if (used.length) {
204
+ score += 1
205
+ const by = (change.usedBy?.get(used[0]) || []).filter(namedAfter)
206
+ reasons.push(`it changes ${used[0]}, which ${by.slice(0, 2).join(" and ")} import${by.length === 1 ? "s" : ""}${used.length > 1 ? ` (and ${used.length - 1} more such file${used.length === 2 ? "" : "s"})` : ""}`)
207
+ }
208
+ const defects = [...f.defects].sort((a, b) => b.seen - a.seen)
209
+ if (defects.length) {
210
+ score += 2 * Math.min(defects.length, 3)
211
+ reasons.push(`${defects.length} defect${defects.length === 1 ? "" : "s"} here before, the latest: ${defects[0].title}`)
212
+ }
213
+ const returned = defects.reduce((n, d) => n + d.returns, 0)
214
+ if (returned) {
215
+ score += 2
216
+ reasons.push(`defects came back here ${returned} time${returned === 1 ? "" : "s"} after they were fixed`)
217
+ }
218
+ if (defects.some((d) => d.open)) {
219
+ score += 1
220
+ reasons.push("a defect here is still open")
221
+ }
222
+ if (f.runs) {
223
+ const rate = f.failed / f.runs
224
+ if (rate >= 0.2) {
225
+ score += rate >= 0.5 ? 2 : 1
226
+ reasons.push(`its cases failed in ${f.failed} of ${f.runs} recent runs`)
227
+ }
228
+ }
229
+ if (!f.cases.size) reasons.push("no case ends on it: nothing tests it directly")
230
+ areas.push({ area: f.area, level: LEVEL(score), score, reasons, changed, cases: [...f.cases], defects })
231
+ }
232
+ areas.sort((a, b) => b.score - a.score || a.area.localeCompare(b.area))
233
+ const runFirst = [...new Set(areas.filter((a) => a.level !== "low").flatMap((a) => a.cases))]
234
+ return { areas, untested: product.filter((f) => !touched.has(f)), runFirst }
235
+ }
236
+
237
+ const predictionsFile = (folder: string) => path.join(folder, PREDICTIONS)
238
+
239
+ export function readPredictions(folder: string): PredictionsFile {
240
+ try {
241
+ const d = JSON.parse(readFileSync(predictionsFile(folder), "utf8")) as PredictionsFile
242
+ return { version: 1, predictions: Array.isArray(d?.predictions) ? d.predictions : [] }
243
+ } catch {
244
+ return { version: 1, predictions: [] }
245
+ }
246
+ }
247
+
248
+ export function savePrediction(folder: string, p: Prediction): void {
249
+ const file = readPredictions(folder)
250
+ file.predictions = [...file.predictions, p].slice(-KEEP)
251
+ writeFileSync(predictionsFile(folder), `${JSON.stringify(file, null, 2)}\n`)
252
+ }
253
+
254
+ /**
255
+ * Score the newest prediction made before this suite, once: how many of the suite's defects fell in the areas it
256
+ * named (medium or high), and how many of its high areas had one. Returns it, scored, or undefined.
257
+ */
258
+ export function scorePrediction(folder: string, suite: string, startedAt: string, clusters: DefectCluster[]): Prediction | undefined {
259
+ const file = readPredictions(folder)
260
+ const p = [...file.predictions].reverse().find((x) => x.at <= startedAt)
261
+ if (!p || p.scored) return undefined
262
+ const named = new Set(p.areas.filter((a) => a.level !== "low").map((a) => a.area))
263
+ const high = p.areas.filter((a) => a.level === "high").map((a) => a.area)
264
+ const hit = clusters.map((c) => areaOf(c)).filter((a): a is string => Boolean(a))
265
+ p.scored = { suite, at: startedAt, defects: hit.length, inPredicted: hit.filter((a) => named.has(a)).length, high: high.length, highWithDefect: high.filter((a) => hit.includes(a)).length }
266
+ writeFileSync(predictionsFile(folder), `${JSON.stringify(file, null, 2)}\n`)
267
+ return p
268
+ }
269
+
270
+ export type DefectTrends = {
271
+ suites: number
272
+ defects: number
273
+ open: number
274
+ gone: number
275
+ /** Defects that came back after going away, and how many times in all. */
276
+ regressions: number
277
+ returns: number
278
+ /** Days from first seen to gone, for the defects that went away. */
279
+ daysToFix?: { median: number; mean: number; count: number }
280
+ recurring: { id: string; title: string; seen: number; firstSeen: string; lastSeen: string; open: boolean; returns: number; bug?: { label: string; url?: string } }[]
281
+ areas: { area: string; defects: number; open: number; failed: number; runs: number; cases: number }[]
282
+ /** Defects found in each suite, oldest first. */
283
+ perSuite: { suite: string; at: string; defects: number; failed: number }[]
284
+ predictions?: { scored: number; defects: number; inPredicted: number; high: number; highWithDefect: number }
285
+ }
286
+
287
+ const DAY = 86_400_000
288
+
289
+ /** How the defects of the suites in `folder` went over time. */
290
+ export function defectTrends(folder: string): DefectTrends {
291
+ const h = readDefectHistory(path.join(folder, DEFECT_HISTORY))
292
+ const all = Object.entries(h.defects)
293
+ const suites = savedSuites(folder)
294
+ const days = all.filter(([, d]) => d.goneSince).map(([, d]) => (Date.parse(d.goneSince!) - Date.parse(d.firstSeen)) / DAY).filter((n) => n >= 0).sort((a, b) => a - b)
295
+ const round = (n: number) => Math.round(n * 10) / 10
296
+ const facts = areaFacts(folder, h)
297
+ const scored = readPredictions(folder).predictions.filter((p) => p.scored)
298
+ const sum = (k: keyof NonNullable<Prediction["scored"]>) => scored.reduce((n, p) => n + Number(p.scored![k] || 0), 0)
299
+ const perSuite = suites.map((s) => {
300
+ const json = (() => {
301
+ try {
302
+ return JSON.parse(readFileSync(path.join(folder, s.name, "suite.json"), "utf8")) as { defects?: unknown[] }
303
+ } catch {
304
+ return {}
305
+ }
306
+ })()
307
+ return { suite: s.name, at: s.at, defects: Array.isArray(json.defects) ? json.defects.length : 0, failed: s.results.filter((r) => r.status === "failed" || r.status === "error").length }
308
+ })
309
+ return {
310
+ suites: suites.length,
311
+ defects: all.length,
312
+ open: all.filter(([, d]) => !d.goneSince).length,
313
+ gone: all.filter(([, d]) => d.goneSince).length,
314
+ regressions: all.filter(([, d]) => d.returns?.length).length,
315
+ returns: all.reduce((n, [, d]) => n + (d.returns?.length || 0), 0),
316
+ ...(days.length ? { daysToFix: { median: round(days[Math.floor((days.length - 1) / 2)]), mean: round(days.reduce((a, b) => a + b, 0) / days.length), count: days.length } } : {}),
317
+ recurring: all
318
+ .map(([id, d]) => ({ id, title: d.title, seen: d.seen, firstSeen: d.firstSeen, lastSeen: d.lastSeen, open: !d.goneSince, returns: d.returns?.length || 0, ...(d.bug ? { bug: d.bug } : {}) }))
319
+ .sort((a, b) => b.seen - a.seen || b.lastSeen.localeCompare(a.lastSeen))
320
+ .slice(0, 10),
321
+ areas: [...facts.values()]
322
+ .filter((f) => f.defects.length || f.failed)
323
+ .map((f) => ({ area: f.area, defects: f.defects.length, open: f.defects.filter((d) => d.open).length, failed: f.failed, runs: f.runs, cases: f.cases.size }))
324
+ .sort((a, b) => b.defects - a.defects || b.failed - a.failed)
325
+ .slice(0, 10),
326
+ perSuite: perSuite.slice(-20),
327
+ ...(scored.length ? { predictions: { scored: scored.length, defects: sum("defects"), inPredicted: sum("inPredicted"), high: sum("high"), highWithDefect: sum("highWithDefect") } } : {}),
328
+ }
329
+ }
330
+
331
+ const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : "—")
332
+
333
+ /** The trends as Markdown, for defect-trends.md and the terminal. */
334
+ export function trendsMarkdown(t: DefectTrends, generated = new Date().toISOString().slice(0, 16).replace("T", " ")): string {
335
+ const cell = (s: string) => s.replace(/\|/g, "\\|")
336
+ const lines = [
337
+ `# Defect trends`,
338
+ "",
339
+ `${t.suites} suite run${t.suites === 1 ? "" : "s"} · ${t.defects} defect${t.defects === 1 ? "" : "s"} found by browser evidence: ${t.open} still there, ${t.gone} gone, ${t.regressions} came back (${t.returns} time${t.returns === 1 ? "" : "s"}) · generated ${generated} UTC`,
340
+ "",
341
+ t.daysToFix ? `Time to go away: median ${t.daysToFix.median} days, mean ${t.daysToFix.mean} days, over ${t.daysToFix.count} defect${t.daysToFix.count === 1 ? "" : "s"}.` : "Time to go away: no defect has gone away yet.",
342
+ ]
343
+ if (t.predictions) {
344
+ const p = t.predictions
345
+ lines.push("", `Predictions: ${p.scored} scored. ${p.inPredicted} of ${p.defects} defects (${pct(p.inPredicted, p.defects)}) fell in an area rated medium or high; ${p.highWithDefect} of ${p.high} high-risk areas (${pct(p.highWithDefect, p.high)}) had a defect.`)
346
+ }
347
+ if (t.recurring.length) {
348
+ lines.push("", "## Most seen", "", "| Defect | Runs | First seen | Last seen | Now | Came back | Bug |", "|---|---|---|---|---|---|---|")
349
+ for (const d of t.recurring) lines.push(`| ${cell(d.title)} | ${d.seen} | ${d.firstSeen.slice(0, 10)} | ${d.lastSeen.slice(0, 10)} | ${d.open ? "there" : "gone"} | ${d.returns || ""} | ${d.bug ? (d.bug.url ? `[${cell(d.bug.label)}](${d.bug.url})` : cell(d.bug.label)) : ""} |`)
350
+ }
351
+ if (t.areas.length) {
352
+ lines.push("", "## By area", "", "| Area | Defects | Still there | Case failures | Cases |", "|---|---|---|---|---|")
353
+ for (const a of t.areas) lines.push(`| ${cell(a.area)} | ${a.defects} | ${a.open} | ${a.runs ? `${a.failed} of ${a.runs}` : "—"} | ${a.cases} |`)
354
+ }
355
+ if (t.perSuite.length) {
356
+ lines.push("", "## Per suite", "", "| Suite | Started | Defects | Failed cases |", "|---|---|---|---|")
357
+ for (const s of t.perSuite) lines.push(`| ${s.suite} | ${s.at.slice(0, 16).replace("T", " ")} | ${s.defects} | ${s.failed} |`)
358
+ }
359
+ return `${lines.join("\n")}\n`
360
+ }
361
+
362
+ /** The prediction as Markdown lines, for the terminal and the agent. */
363
+ export function predictionLines(p: Pick<Prediction, "areas" | "untested" | "runFirst" | "changed">): string[] {
364
+ const lines = [`${p.changed.length} changed file${p.changed.length === 1 ? "" : "s"}; ${p.areas.length} area${p.areas.length === 1 ? "" : "s"} touched.`]
365
+ for (const a of p.areas) {
366
+ lines.push("", `${a.level.toUpperCase()} ${a.area} (score ${a.score})`, ...a.reasons.map((r) => ` - ${r}`))
367
+ if (a.cases.length) lines.push(` cases: ${a.cases.slice(0, 8).join(", ")}${a.cases.length > 8 ? ` and ${a.cases.length - 8} more` : ""}`)
368
+ }
369
+ if (p.runFirst.length) lines.push("", `Run first: ${p.runFirst.join(", ")}`)
370
+ if (p.untested.length) lines.push("", `Changed with no area ForgeQE has seen (nothing tests them yet): ${p.untested.slice(0, 10).join(", ")}${p.untested.length > 10 ? ` and ${p.untested.length - 10} more` : ""}`)
371
+ return lines
372
+ }
@@ -0,0 +1,258 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { AppConfig } from "../config.ts"
6
+ import { openSession, type AgentSpec, type OpencodeSession } from "../code/runtime.ts"
7
+ import { privateEnvNames } from "../childEnv.ts"
8
+ import { domainNotes } from "../domain.ts"
9
+ import { prompt } from "../prompts.ts"
10
+ import type { CodeEvent, Usage } from "../types.ts"
11
+ import { costOf, type Cost } from "../usage.ts"
12
+ import { lastPassed } from "./defectHistory.ts"
13
+ import { routeOf, type DefectCluster } from "./defects.ts"
14
+ import { reproText } from "./repro.ts"
15
+ import type { CaseResult } from "./types.ts"
16
+
17
+ /**
18
+ * Defect intelligence, part three (b): why a defect may happen. Always a hypothesis, never a verdict.
19
+ *
20
+ * - From the evidence alone, at no cost: the layer it most likely sits in (the server, the front end, access, the
21
+ * environment) and the component (the route, the script).
22
+ * - From the product's repository (defects.product_repo): suspect commits, made since the defect's cases last
23
+ * passed, whose changes mention its route or error (git log -G).
24
+ * - From a model, for new defects and regressions only (defects.analyze): a read-only agent reads the evidence, the
25
+ * suspect commits, and the product's code, and writes a hypothesis with its confidence and how to confirm it.
26
+ */
27
+
28
+ export type Suspect = { sha: string; subject: string; author: string; at: string; files: string[]; why: string }
29
+
30
+ export type RootCause = {
31
+ layer: string
32
+ component?: string
33
+ /** What the layer was read from. */
34
+ basis: string
35
+ suspects?: Suspect[]
36
+ /** The commits searched, e.g. "since 2026-10-12, when SHOP-1 last passed". */
37
+ range?: string
38
+ hypothesis?: string
39
+ confidence?: "low" | "medium" | "high"
40
+ evidence?: string[]
41
+ confirm?: string[]
42
+ files?: string[]
43
+ /** Why there is no hypothesis or no suspect commits, when there is a reason to give. */
44
+ note?: string
45
+ model?: string
46
+ tokens?: number
47
+ usage?: Usage
48
+ cost?: Cost
49
+ }
50
+
51
+ const STATUS = /returns (\d{3})/
52
+
53
+ /** The layer and component a defect points to, from its evidence. */
54
+ export function layerOf(c: DefectCluster): Pick<RootCause, "layer" | "component" | "basis"> {
55
+ if (c.kind === "request") {
56
+ const status = Number(STATUS.exec(c.title)?.[1] || 0)
57
+ const component = c.title.replace(/ (returns \d{3}|fails)$/, "")
58
+ if (status >= 500) return { layer: "Server (API)", component, basis: `the server answered ${status}` }
59
+ if (status === 401 || status === 403) return { layer: "Access: sign-in or permissions", component, basis: `the server refused with ${status}` }
60
+ if (status === 404) return { layer: "API route or deployment", component, basis: "the route was not found" }
61
+ if (status >= 400) return { layer: "API request: the page sent what the server refused", component, basis: `the server answered ${status}` }
62
+ return { layer: "Network", component, basis: "the request did not complete" }
63
+ }
64
+ if (c.kind === "exception") {
65
+ const at = / at (\S+?)(?::\d+)?$/.exec(c.evidence[0] || "")?.[1]
66
+ return { layer: "Front end (JavaScript)", ...(at ? { component: routeOf(at) } : {}), basis: "an uncaught error in the page" }
67
+ }
68
+ if (c.kind === "console") return { layer: "Front end", ...(c.page ? { component: routeOf(c.page) } : {}), basis: "an error in the browser console" }
69
+ const page = c.page ? { component: routeOf(c.page) } : {}
70
+ if (c.failure === "environment") return { layer: "Environment", ...page, basis: "the run met an environment problem" }
71
+ if (c.failure === "test_case_issue") return { layer: "Test case", ...page, basis: "the test case does not fit the application" }
72
+ if (c.failure === "automation") return { layer: "Test automation", ...page, basis: "the browser agent could not finish" }
73
+ return { layer: "Application behaviour (a page or business rule)", ...page, basis: "the page showed a wrong result with no browser error" }
74
+ }
75
+
76
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
77
+ const GENERIC = new Set(["api", "v1", "v2", "v3", "rest", "graphql", "www", "app", "index", "home"])
78
+
79
+ /** What a change that caused it would mention: the route and its words, or the error message. */
80
+ export function searchTerms(c: DefectCluster): string[] {
81
+ if (c.kind === "request" && c.term) return [c.term, ...c.term.split("/").filter((w) => w.length >= 3 && !GENERIC.has(w.toLowerCase()))]
82
+ if (c.kind === "exception" && c.term) return [c.term, ...c.term.split(/\W+/).filter((w) => w.length >= 5).slice(0, 3)]
83
+ if (c.page) {
84
+ const p = routeOf(c.page)
85
+ return p.slice(p.indexOf("/")).split("/").filter((w) => w.length >= 3 && !GENERIC.has(w.toLowerCase()))
86
+ }
87
+ return []
88
+ }
89
+
90
+ const git = (repo: string, args: string[]) => spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 })
91
+
92
+ type Commit = { sha: string; subject: string; author: string; at: string; files: string[] }
93
+
94
+ function commits(repo: string, args: string[]): Commit[] {
95
+ const out = git(repo, ["log", "--no-merges", "-n", "300", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s", "--name-only", ...args])
96
+ if (out.status !== 0) return []
97
+ return out.stdout
98
+ .split("\x1e")
99
+ .filter((b) => b.trim())
100
+ .map((b) => {
101
+ const [head, ...files] = b.trim().split("\n")
102
+ const [sha, author, at, subject] = head.split("\x1f")
103
+ return { sha, author, at, subject, files: files.map((f) => f.trim()).filter(Boolean) }
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Commits since `since` whose changes mention the defect's terms (strongest first), or whose files are named after
109
+ * them. Five at most; none when the folder is not a git repository.
110
+ */
111
+ export function suspectCommits(repo: string, c: DefectCluster, since: string): Suspect[] {
112
+ const terms = searchTerms(c)
113
+ if (!terms.length || !existsSync(path.join(repo, ".git"))) return []
114
+ const changed = commits(repo, [`--since=${since}`, "-i", `-G${terms.map(escape).join("|")}`])
115
+ const all = commits(repo, [`--since=${since}`])
116
+ const scored = new Map<string, { c: Commit; score: number; why: string[] }>()
117
+ for (const x of changed) scored.set(x.sha, { c: x, score: 2, why: [`its changes mention ${terms.slice(0, 2).map((t) => `\`${t}\``).join(" or ")}`] })
118
+ for (const x of all) {
119
+ const named = x.files.filter((f) => terms.slice(1).some((t) => f.toLowerCase().includes(t.toLowerCase())))
120
+ if (!named.length) continue
121
+ const s = scored.get(x.sha) || { c: x, score: 0, why: [] }
122
+ s.score += 1
123
+ s.why.push(`it changes ${named.slice(0, 2).map((f) => `\`${f}\``).join(", ")}`)
124
+ scored.set(x.sha, s)
125
+ }
126
+ return [...scored.values()]
127
+ .sort((a, b) => b.score - a.score || b.c.at.localeCompare(a.c.at))
128
+ .slice(0, 5)
129
+ .map(({ c: x, why }) => ({ sha: x.sha, subject: x.subject, author: x.author, at: x.at, files: x.files.slice(0, 8), why: why.join("; ") }))
130
+ }
131
+
132
+ /** The layer, and the suspect commits when the product's repository is known: no model, no cost. */
133
+ export function basicCause(c: DefectCluster, opts: { suiteDir: string; productRepo?: string; now?: string }): RootCause {
134
+ const rc: RootCause = { ...layerOf(c) }
135
+ if (!opts.productRepo) return rc
136
+ const passed = lastPassed(opts.suiteDir, c.cases.map((x) => x.id))
137
+ if (!passed) {
138
+ rc.note = "No earlier run where its cases passed: no commit range to search."
139
+ return rc
140
+ }
141
+ rc.range = `since ${passed.slice(0, 16).replace("T", " ")}, the last run where its cases passed`
142
+ rc.suspects = suspectCommits(opts.productRepo, c, passed)
143
+ return rc
144
+ }
145
+
146
+ export const rcaAgentPrompt = () => prompt("defect-rca")
147
+
148
+ export const RCA_AGENT: AgentSpec = {
149
+ name: "forgeqe-defect-rca",
150
+ description: "Writes a root-cause hypothesis for a defect from its evidence and the product's code",
151
+ get prompt() {
152
+ return rcaAgentPrompt()
153
+ },
154
+ permission: {
155
+ edit: "deny",
156
+ webfetch: "deny",
157
+ task: "deny",
158
+ "live_*": "deny",
159
+ bash: { "*": "deny", "git log*": "allow", "git show*": "allow", "git diff*": "allow", "git blame*": "allow", "git ls-files*": "allow", "git grep*": "allow", "ls*": "allow" },
160
+ },
161
+ steps: 25,
162
+ }
163
+
164
+ const lines = { type: "array", items: { type: "string" } } as const
165
+
166
+ export const RCA_SCHEMA = {
167
+ type: "object",
168
+ properties: {
169
+ hypothesis: { type: "string", description: "The most likely cause, in two sentences or fewer" },
170
+ confidence: { type: "string", enum: ["low", "medium", "high"] },
171
+ layer: { type: "string", description: "Where the cause most likely sits, e.g. Server (API), Front end, Data, Configuration" },
172
+ component: { type: "string", description: "The file, module, route, or service, as named in the code or the evidence" },
173
+ evidence: { ...lines, description: "The facts the hypothesis rests on, each from the evidence or a file you read, with where" },
174
+ confirm: { ...lines, description: "Checks a developer can make to confirm or reject it" },
175
+ files: { ...lines, description: "Repository paths of the code to look at first" },
176
+ },
177
+ required: ["hypothesis", "confidence", "layer", "evidence", "confirm", "files"],
178
+ } as const
179
+
180
+ type RcaReport = { hypothesis?: string; confidence?: string; layer?: string; component?: string; evidence?: string[]; confirm?: string[]; files?: string[] }
181
+
182
+ /** What the agent is told: the defect, how it happened, its history, and the suspect commits. */
183
+ export function rcaPrompt(c: DefectCluster, first: CaseResult, rc: RootCause, productRepo?: string): string {
184
+ return [
185
+ `Defect: ${c.title}`,
186
+ `Kind of failure: ${c.failureLabel}. It breaks ${c.cases.length} case${c.cases.length === 1 ? "" : "s"}: ${c.cases.map((x) => `${x.id} ${x.title}`).join("; ")}.`,
187
+ "",
188
+ "Evidence from the runs:",
189
+ ...c.evidence.map((e) => `- ${e}`),
190
+ ...(c.pages.length ? [`Pages: ${c.pages.join(", ")}`] : []),
191
+ "",
192
+ `What the browser agent reported for ${first.case.id}: ${first.summary.split("\n")[0]}`,
193
+ ...(c.repro ? ["", reproText(c.repro)] : []),
194
+ "",
195
+ `From the evidence alone: layer ${rc.layer}${rc.component ? `, component ${rc.component}` : ""} (${rc.basis}).`,
196
+ ...(c.history ? [`History: ${c.history.status}${c.history.seen ? `, seen in ${c.history.seen} earlier runs` : ""}${c.history.previousBug ? `, earlier bug ${c.history.previousBug.label}` : ""}.`] : []),
197
+ ...(rc.suspects?.length ? ["", `Suspect commits (${rc.range}):`, ...rc.suspects.map((s) => `- ${s.sha.slice(0, 10)} ${s.subject} (${s.author}, ${s.at.slice(0, 10)}): ${s.why}. Files: ${s.files.join(", ")}`)] : rc.range ? ["", `No commit ${rc.range} mentions it.`] : []),
198
+ "",
199
+ productRepo ? `The product's code is in ${productRepo}. Read it.` : "You have no access to the product's code. Reason from the evidence only, and say so in the confidence.",
200
+ ].join("\n")
201
+ }
202
+
203
+ /**
204
+ * The model's hypothesis for one defect, added to `rc`. A failure leaves `rc` with what it had and a note: the
205
+ * analysis is a hint, never a reason for a suite to fail.
206
+ */
207
+ export async function analyzeCause(
208
+ config: AppConfig,
209
+ c: DefectCluster,
210
+ first: CaseResult,
211
+ rc: RootCause,
212
+ opts: { cwd: string; productRepo?: string; signal?: AbortSignal; onEvent?: (e: CodeEvent) => void; open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<RcaReport>> },
213
+ ): Promise<RootCause> {
214
+ const repo = opts.productRepo || opts.cwd
215
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<RcaReport>>
216
+ let session: OpencodeSession<RcaReport> | undefined
217
+ try {
218
+ session = await open({
219
+ repo,
220
+ profile: config.llm,
221
+ domain: domainNotes(config),
222
+ model: config.code.model,
223
+ bin: config.code.opencode,
224
+ readDirs: [repo],
225
+ hide: privateEnvNames(config),
226
+ signal: opts.signal,
227
+ onEvent: opts.onEvent,
228
+ agent: RCA_AGENT,
229
+ schema: RCA_SCHEMA as unknown as Record<string, unknown>,
230
+ title: `ForgeQE root cause: ${c.title}`.slice(0, 120),
231
+ role: "root-cause analyst",
232
+ })
233
+ let reply = await session.prompt(rcaPrompt(c, first, rc, opts.productRepo))
234
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: hypothesis, confidence, layer, component, evidence, confirm, and files.")
235
+ const usage: Usage = { ...session.usage }
236
+ const spent = { model: session.model, tokens: usage.input + usage.output, usage, ...(costOf(usage, config.llm.price) ? { cost: costOf(usage, config.llm.price) } : {}) }
237
+ const r = reply.report
238
+ if (!r?.hypothesis) return { ...rc, ...spent, note: `No hypothesis${reply.error ? `: ${reply.error}` : "."}` }
239
+ const confidence = r.confidence === "high" || r.confidence === "medium" ? r.confidence : "low"
240
+ // A guess does not replace what the evidence says: the model's layer and component only with code behind them.
241
+ const sure = confidence !== "low"
242
+ return {
243
+ ...rc,
244
+ ...(sure && r.layer ? { layer: r.layer } : {}),
245
+ ...(sure && r.component ? { component: r.component } : {}),
246
+ hypothesis: r.hypothesis.trim(),
247
+ confidence,
248
+ evidence: (r.evidence || []).map(String).filter(Boolean).slice(0, 8),
249
+ confirm: (r.confirm || []).map(String).filter(Boolean).slice(0, 5),
250
+ files: (r.files || []).map(String).filter(Boolean).slice(0, 8),
251
+ ...spent,
252
+ }
253
+ } catch (err) {
254
+ return { ...rc, note: `No hypothesis: ${(err as Error).message.split("\n")[0].slice(0, 200)}` }
255
+ } finally {
256
+ await session?.close()
257
+ }
258
+ }