@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.
- package/HOW_TO_USE.md +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- 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
|
+
}
|
package/src/cases/rca.ts
ADDED
|
@@ -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
|
+
}
|