@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.
- package/HOW_TO_USE.md +46 -4
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +62 -10
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +7 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +12 -2
- package/prompts/test-reviewer.md +5 -2
- package/prompts/test-rules.md +8 -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 +511 -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/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/tools.ts +92 -9
- 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 +9 -3
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/cases/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|