@capagents/forgeqe 1.1.4 → 1.1.5

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 (56) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +62 -10
  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 +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -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 +511 -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/config.ts +142 -18
  41. package/src/mcp/attachtools.ts +223 -0
  42. package/src/mcp/commands.ts +1 -1
  43. package/src/mcp/defecttools.ts +106 -0
  44. package/src/mcp/devicetools.ts +125 -0
  45. package/src/mcp/external.ts +42 -1
  46. package/src/mcp/tools.ts +92 -9
  47. package/src/monitor/run.ts +1 -1
  48. package/src/report/data.ts +14 -0
  49. package/src/report/html.ts +15 -3
  50. package/src/report/template.html +44 -43
  51. package/src/run.ts +4 -2
  52. package/src/suite/report.ts +44 -11
  53. package/src/suite/run.ts +163 -16
  54. package/src/tui/App.tsx +9 -3
  55. package/src/usage.ts +1 -1
  56. package/testmu.example.yaml +11 -0
@@ -4,6 +4,7 @@ import path from "node:path"
4
4
  import type { SourcesConfig } from "../config.ts"
5
5
  import { adoCases, adoWriteBack } from "./ado.ts"
6
6
  import { fileCases, textCase } from "./files.ts"
7
+ import { githubCases, githubWriteBack } from "./github.ts"
7
8
  import { jiraCases, jiraWriteBack } from "./jira.ts"
8
9
  import { qtestCases, qtestWriteBack } from "./qtest.ts"
9
10
  import { testrailCases, testrailWriteBack } from "./testrail.ts"
@@ -18,10 +19,11 @@ export type { CaseResult, CaseStatus, CaseStep, TestCase, WriteBack } from "./ty
18
19
  * Where a reference points: `ado:…`, `jira:…`, `xray:…`, `testrail:…`, `zephyr:…`, `qtest:…`, an existing file, or
19
20
  * otherwise plain text that is itself the test case.
20
21
  */
21
- export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text" {
22
- const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest):/i)?.[1]?.toLowerCase()
22
+ export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github" | "file" | "text" {
23
+ const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest|github|gh):/i)?.[1]?.toLowerCase()
23
24
  if (scheme === "ado" || scheme === "azure") return "ado"
24
- if (scheme) return scheme as "jira" | "xray" | "testrail" | "zephyr" | "qtest"
25
+ if (scheme === "gh") return "github"
26
+ if (scheme) return scheme as "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github"
25
27
  const file = path.resolve(ref)
26
28
  if (ref.length < 1024 && existsSync(file) && statSync(file).isFile()) return "file"
27
29
  return "text"
@@ -39,6 +41,7 @@ export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<Tes
39
41
  else if (kind === "testrail") cases.push(...(await testrailCases(rest, cfg)))
40
42
  else if (kind === "zephyr") cases.push(...(await zephyrCases(rest, cfg)))
41
43
  else if (kind === "qtest") cases.push(...(await qtestCases(rest, cfg)))
44
+ else if (kind === "github") cases.push(...(await githubCases(rest, cfg)))
42
45
  else if (kind === "file") cases.push(...fileCases(path.resolve(ref)))
43
46
  else cases.push(textCase(ref, ++texts))
44
47
  }
@@ -47,8 +50,14 @@ export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<Tes
47
50
 
48
51
  /** Where the agent wants test cases from, as it decided from the user's request. */
49
52
  export type CaseSelector = {
50
- source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text"
53
+ source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github" | "file" | "text"
51
54
  ids?: string[]
55
+ /** github: owner/repo (default sources.github.repo). */
56
+ repo?: string
57
+ /** github: issue labels, all of which a case has. */
58
+ labels?: string[]
59
+ /** github: GitHub issue search, e.g. "login in:title is:open". */
60
+ query?: string
52
61
  plan?: string
53
62
  suite?: string
54
63
  run?: string
@@ -82,6 +91,15 @@ export async function casesFrom(selectors: CaseSelector[], cfg: SourcesConfig, c
82
91
  cases.push(...(await zephyrCases(s.cycle ? `cycle=${s.cycle}` : s.folder ? `folder=${s.folder}` : ids(s, "Zephyr Scale"), cfg)))
83
92
  } else if (s.source === "qtest") {
84
93
  cases.push(...(await qtestCases(s.cycle ? `cycle=${s.cycle}` : s.suite ? `suite=${s.suite}` : ids(s, "qTest"), cfg)))
94
+ } else if (s.source === "github") {
95
+ const repo = s.repo ? `${s.repo} ` : ""
96
+ const nums = (s.ids || []).map((i) => String(i).trim()).filter(Boolean)
97
+ const sel = s.query ? `q=${s.query}` : s.labels?.length ? `label=${s.labels.join(",")}` : nums.length ? nums.map((n) => (n.includes("#") ? n : `#${n}`)).join(",") : ""
98
+ if (!sel) throw new Error("GitHub: give issue numbers, labels, or a query.")
99
+ // owner/repo#12 ids name their own repository; the others take repo.
100
+ if (nums.some((n) => /^[^#\s]+\/[^#\s]+#\d+$/.test(n)) && !s.query && !s.labels?.length) {
101
+ for (const n of nums) cases.push(...(await githubCases(n, cfg)))
102
+ } else cases.push(...(await githubCases(`${repo}${sel}`.trim(), cfg)))
85
103
  } else if (s.source === "file") {
86
104
  if (!s.paths?.length) throw new Error("File source: give the file paths.")
87
105
  for (const p of s.paths) {
@@ -117,6 +135,7 @@ export async function writeBack(results: CaseResult[], cfg: SourcesConfig, runNa
117
135
  ["testrail", (rs) => testrailWriteBack(rs, cfg, runName)],
118
136
  ["zephyr", (rs) => zephyrWriteBack(rs, cfg, runName)],
119
137
  ["qtest", (rs) => qtestWriteBack(rs, cfg, runName)],
138
+ ["github", (rs) => githubWriteBack(rs, cfg)],
120
139
  ]
121
140
  for (const [source, post] of tasks) {
122
141
  const rs = of(source)
@@ -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
+ }