@capagents/forgeqe 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/HOW_TO_USE.md +54 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -0,0 +1,327 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig } from "../config.ts"
5
+ import { areaFacts, predict } from "../cases/predict.ts"
6
+ import { buildGraph, qualified, testsReaching, type CodeGraph } from "../code/graph/graph.ts"
7
+ import { flakyReport } from "../code/history.ts"
8
+ import { analyzeImpact } from "../code/impact.ts"
9
+ import { walk } from "../code/profile.ts"
10
+ import { indexTests, shortlistTests, testsOnAreas, type TestEntry } from "../code/shortlist.ts"
11
+ import { readMappings } from "../code/traceability.ts"
12
+ import { readPages } from "../memory.ts"
13
+ import { projectDir } from "../tools.ts"
14
+ import type { ChangeItem } from "./collect.ts"
15
+
16
+ /**
17
+ * A regression pack: the tests a release's changes can break, each with why it is in, saved under a name so it can be
18
+ * run again (locally, on TestMu AI, or in a pipeline) and its runs compared. A test is in for one or more reasons:
19
+ *
20
+ * - linked: it automates a test case that tests a changed work item (Tested By, through .forgeqe/test-cases.json, or a
21
+ * case id in the test's attributes);
22
+ * - changed: the change is in the test repository, and the test reaches the changed code (imports, class names);
23
+ * - covers: the work item's or pull request's words name what the test does (the shortlist: page memory elements,
24
+ * pages, and test names);
25
+ * - risk: the change touches an area with defects or failures before (defect prediction), and the test works there;
26
+ * - smoke: it is tagged smoke, so it goes in every pack.
27
+ *
28
+ * Changes no test is in for are the pack's gaps: what nobody tests yet.
29
+ */
30
+
31
+ export type ReasonKind = "linked" | "changed" | "covers" | "risk" | "smoke"
32
+ export type PackReason = { kind: ReasonKind; text: string; item?: string }
33
+ export type PackTest = { test: string; file: string; line?: number; reasons: PackReason[]; score: number; flaky?: string }
34
+ export type PackRun = {
35
+ at: string
36
+ on: "local" | "testmu" | "pipeline"
37
+ target?: string
38
+ status: string
39
+ counts: { passed: number; failed: number; skipped: number }
40
+ seconds: number
41
+ failed: string[]
42
+ dir?: string
43
+ url?: string
44
+ }
45
+ export type Pack = {
46
+ version: 1
47
+ name: string
48
+ createdAt: string
49
+ repo: string
50
+ scope: string[]
51
+ items: ChangeItem[]
52
+ tests: PackTest[]
53
+ /** Changes no test is in the pack for; with the closest test, a start for extending one rather than writing one. */
54
+ uncovered: { item: string; title: string; why: string; nearest?: { test: string; file: string; lacks: string[] } }[]
55
+ risk: { area: string; level: string; reasons: string[] }[]
56
+ notes: string[]
57
+ runs: PackRun[]
58
+ }
59
+
60
+ /** A work item's or PR's words must match a test at least this closely for it to count as covering it. */
61
+ const COVER_FROM = 0.45
62
+ /** A test reaching one element a change names is its closest only this close: one generic element is not enough. */
63
+ const NEAREST_FROM = 0.35
64
+ const COVER_TOP = 3
65
+ const WEIGHT: Record<ReasonKind, number> = { linked: 1, changed: 0.9, covers: 0.8, risk: 0.6, smoke: 0.3 }
66
+ const SMOKE = /(^|[^a-z])smoke([^a-z]|$)/i
67
+
68
+ const itemName = (i: ChangeItem) => `${i.kind === "pr" ? "PR " : i.kind === "commit" ? "commit " : ""}${i.source === "azure_devops" && i.kind === "work_item" ? `${i.type || "work item"} ` : ""}${i.id}`
69
+
70
+ export const packsDir = (repo: string) => path.join(projectDir(repo), "packs")
71
+ const packFile = (repo: string, name: string) => path.join(packsDir(repo), `${name}.json`)
72
+
73
+ export function packName(given?: string, now = new Date()): string {
74
+ const slug = (given || "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")
75
+ return slug || `release-${now.toISOString().slice(0, 16).replace(/[-:T]/g, "").replace(/^(\d{8})(\d{4})$/, "$1-$2")}`
76
+ }
77
+
78
+ export function savePack(p: Pack): string {
79
+ mkdirSync(packsDir(p.repo), { recursive: true })
80
+ const file = packFile(p.repo, p.name)
81
+ writeFileSync(file, `${JSON.stringify(p, null, 2)}\n`)
82
+ return file
83
+ }
84
+
85
+ export function loadPack(repo: string, name: string): Pack {
86
+ const file = packFile(repo, name)
87
+ if (!existsSync(file)) {
88
+ const known = listPacks(repo).map((p) => p.name)
89
+ throw new Error(`No pack named "${name}" in ${packsDir(repo)}.${known.length ? ` Packs: ${known.join(", ")}.` : " Build one first."}`)
90
+ }
91
+ return JSON.parse(readFileSync(file, "utf8")) as Pack
92
+ }
93
+
94
+ export function listPacks(repo: string): Pack[] {
95
+ const dir = packsDir(repo)
96
+ if (!existsSync(dir)) return []
97
+ return readdirSync(dir)
98
+ .filter((f) => f.endsWith(".json"))
99
+ .flatMap((f) => {
100
+ try {
101
+ return [JSON.parse(readFileSync(path.join(dir, f), "utf8")) as Pack]
102
+ } catch {
103
+ return []
104
+ }
105
+ })
106
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
107
+ }
108
+
109
+ /** The pack for these changes: every test a change can break, with why; and the changes no test covers. */
110
+ export async function buildPack(opts: { config: AppConfig; repo: string; name?: string; scope: string[]; items: ChangeItem[]; notes?: string[]; risk?: boolean; smoke?: boolean; graph?: CodeGraph }): Promise<Pack> {
111
+ const cfg = opts.config
112
+ let repo = opts.repo
113
+ try {
114
+ repo = realpathSync(repo)
115
+ } catch {}
116
+ const graph = opts.graph || (await buildGraph(repo))
117
+ const index = indexTests(graph).map((t) => t.entry)
118
+ const pages = cfg.memory.enabled ? readPages(cfg.memory.dir) : []
119
+ const notes = [...(opts.notes || [])]
120
+ const tests = new Map<string, PackTest>()
121
+ const reached = new Set<string>()
122
+ const add = (t: Pick<TestEntry, "test" | "file"> & { line?: number }, reason: PackReason) => {
123
+ const key = `${t.file}#${t.test}`
124
+ const e = tests.get(key) || { test: t.test, file: t.file, ...(t.line ? { line: t.line } : {}), reasons: [], score: 0 }
125
+ if (!e.reasons.some((r) => r.kind === reason.kind && r.text === reason.text && r.item === reason.item)) e.reasons.push(reason)
126
+ tests.set(key, e)
127
+ if (reason.item) reached.add(reason.item)
128
+ }
129
+ const inFile = (file: string) => index.filter((t) => t.file === file)
130
+
131
+ // linked: test cases that test a changed work item, through the mappings or the tests' own case ids.
132
+ const mappings = readMappings(repo).cases
133
+ for (const item of opts.items) {
134
+ for (const caseId of item.testCases || []) {
135
+ const m = mappings[caseId]
136
+ const why = { kind: "linked" as const, text: `automates ${caseId}, which tests ${itemName(item)} "${item.title}"`, item: item.id }
137
+ if (m?.testFile && m.status !== "needs_attention") {
138
+ const named = m.testName ? index.filter((t) => t.file === m.testFile && (m.testName === t.test || m.testName!.endsWith(`.${t.test}`) || m.testName!.endsWith(t.test.split(".").pop()!))) : []
139
+ const hits = named.length ? named : inFile(m.testFile)
140
+ if (hits.length) for (const t of hits) add(t, why)
141
+ else add({ test: m.testName || m.testFile, file: m.testFile }, why)
142
+ }
143
+ for (const t of index.filter((x) => x.tags.some((tag) => tag.toUpperCase() === caseId.toUpperCase()))) add(t, why)
144
+ }
145
+ }
146
+
147
+ // changed: changes in the test repository reach tests through its code. Per changed file, every change that touched
148
+ // it gets the credit: tests in the file, tests that call its members, tests whose class inherits one of its classes
149
+ // (a base test class), and, for JS and Python, tests that import it.
150
+ const testFiles = [...new Set(opts.items.filter((i) => i.inTestRepo).flatMap((i) => i.files || []))]
151
+ if (testFiles.length) {
152
+ const owners = (file: string) => opts.items.filter((i) => i.inTestRepo && (i.files || []).includes(file))
153
+ const credit = (file: string, t: Pick<TestEntry, "test" | "file"> & { line?: number }, text: string) => {
154
+ const who = owners(file)
155
+ if (!who.length) add(t, { kind: "changed", text })
156
+ for (const i of who) add(t, { kind: "changed", text, item: i.id })
157
+ }
158
+ const classesIn = new Map(graph.files.map((f) => [f.file, f.classes.map((c) => c.name)]))
159
+ const basesOf = new Map(graph.files.flatMap((f) => f.classes.map((c) => [c.name, c.bases] as const)))
160
+ const inherits = (cls: string | undefined, target: Set<string>) => {
161
+ const seen = new Set<string>()
162
+ let todo = cls ? [cls] : []
163
+ while (todo.length) {
164
+ const c = todo.pop()!
165
+ if (seen.has(c)) continue
166
+ seen.add(c)
167
+ for (const b of basesOf.get(c) || []) {
168
+ if (target.has(b)) return b
169
+ todo.push(b)
170
+ }
171
+ }
172
+ return undefined
173
+ }
174
+ const byKey = new Map(index.map((t) => [`${t.file}#${t.test}`, t]))
175
+ for (const file of testFiles.slice(0, 200)) {
176
+ for (const t of inFile(file)) credit(file, t, `is in ${file}, which changed`)
177
+ for (const r of testsReaching(graph, [file])) {
178
+ const e = byKey.get(`${r.test.file}#${qualified(r.test)}`)
179
+ if (e && e.file !== file) credit(file, e, `${r.why.replace(/^calls /, "calls ")} (${file} changed)`)
180
+ }
181
+ const changedClasses = new Set(classesIn.get(file) || [])
182
+ if (changedClasses.size) {
183
+ for (const t of index) {
184
+ const cls = t.test.includes(".") ? t.test.slice(0, t.test.lastIndexOf(".")) : undefined
185
+ const base = inherits(cls, changedClasses)
186
+ if (base) credit(file, t, `inherits ${base} (${file} changed)`)
187
+ }
188
+ }
189
+ }
190
+ // Imports, for JS and Python; a file named like a test but holding none is not one.
191
+ const impact = analyzeImpact(repo, testFiles.filter((f) => /\.([cm]?[jt]sx?|py)$/.test(f)))
192
+ for (const t of impact.tests) {
193
+ const hits = inFile(t.file)
194
+ const text = t.reasons.join("; ")
195
+ const file = testFiles.find((f) => text.includes(f)) || t.file
196
+ if (hits.length) for (const e of hits) credit(file, e, text)
197
+ }
198
+ }
199
+
200
+ // covers: the item's words match what a test does. The closest one is kept for a change no test covers.
201
+ const nearest = new Map<string, { test: string; file: string; lacks: string[] }>()
202
+ for (const item of opts.items) {
203
+ if (item.inTestRepo && item.kind === "commit") continue
204
+ const request = `${item.title}\n${item.text || ""}`.slice(0, 1500)
205
+ const s = shortlistTests({ graph, pages, request, max: COVER_TOP })
206
+ // Closest only when it reaches an element the change names: a shared word alone ("checkout") is not a start.
207
+ const close = s.candidates.find((c) => c.covers.length >= 2 || (c.covers.length === 1 && c.score >= NEAREST_FROM))
208
+ if (close) nearest.set(item.id, { test: close.test, file: close.file, lacks: close.lacks })
209
+ for (const c of s.candidates.filter((c) => c.score >= COVER_FROM)) {
210
+ add(c, { kind: "covers", text: `matches ${itemName(item)} "${item.title}"${c.why.length ? ` (${c.why.join("; ")})` : ""}`, item: item.id })
211
+ }
212
+ }
213
+
214
+ // risk: changed product code in areas with defects or failures before, and the tests that work there.
215
+ const risk: Pack["risk"] = []
216
+ if (opts.risk !== false) {
217
+ const product = [...new Set(opts.items.filter((i) => !i.inTestRepo).flatMap((i) => i.files || []))]
218
+ if (product.length) {
219
+ const facts = areaFacts(cfg.suite.outDir)
220
+ const p = predict(facts, { files: product, diff: new Map() })
221
+ const areas = p.areas.filter((a) => a.level !== "low")
222
+ for (const a of areas) risk.push({ area: a.area, level: a.level, reasons: a.reasons })
223
+ const on = testsOnAreas(graph, pages, areas.map((a) => a.area))
224
+ for (const t of index) {
225
+ const hit = on.get(`${t.file}#${t.test}`)
226
+ if (hit) add(t, { kind: "risk", text: `works on ${hit.join(", ")}, a ${areas.find((a) => hit.includes(a.area))?.level} risk area this release changes` })
227
+ }
228
+ for (const caseId of p.runFirst) {
229
+ const m = mappings[caseId]
230
+ if (m?.testFile) for (const t of inFile(m.testFile)) add(t, { kind: "risk", text: `${caseId} is one to run first for the risk areas` })
231
+ }
232
+ }
233
+ }
234
+
235
+ // smoke: in every pack.
236
+ if (opts.smoke !== false) for (const t of index.filter((x) => x.tags.some((tag) => SMOKE.test(tag)) || SMOKE.test(x.test))) add(t, { kind: "smoke", text: "tagged smoke: in every pack" })
237
+
238
+ // Flaky tests are marked, so their failures are read as flaky first.
239
+ const flaky = flakyReport(repo).filter((f) => f.flaky)
240
+ for (const t of tests.values()) {
241
+ const f = flaky.find((x) => x.name === t.test || x.name.endsWith(`.${t.test}`) || x.name.endsWith(t.test))
242
+ if (f) t.flaky = `flaky: ${f.flips ?? "several"} flips in its recent runs`
243
+ const kinds = [...new Set(t.reasons.map((r) => r.kind))]
244
+ t.score = Number((Math.max(...kinds.map((k) => WEIGHT[k])) + 0.1 * (kinds.length - 1)).toFixed(2))
245
+ }
246
+ const uncovered = opts.items
247
+ .filter((i) => !reached.has(i.id))
248
+ .map((i) => ({
249
+ item: i.id,
250
+ title: i.title,
251
+ why: [i.testCases?.length ? `its test cases ${i.testCases.join(", ")} have no automated test here` : "no test case is linked", i.files?.length && i.inTestRepo ? "no test reaches its files" : "", "no test matches what it describes"].filter(Boolean).join("; "),
252
+ ...(nearest.has(i.id) ? { nearest: nearest.get(i.id)! } : {}),
253
+ }))
254
+ if (!index.length) notes.push(`ForgeQE found no tests in ${repo}: the pack can only list the changes.`)
255
+ const pack: Pack = {
256
+ version: 1,
257
+ name: packName(opts.name),
258
+ createdAt: new Date().toISOString(),
259
+ repo,
260
+ scope: opts.scope,
261
+ items: opts.items,
262
+ tests: [...tests.values()].sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.test.localeCompare(b.test)),
263
+ uncovered,
264
+ risk,
265
+ notes,
266
+ runs: [],
267
+ }
268
+ savePack(pack)
269
+ return pack
270
+ }
271
+
272
+ const quote = (s: string) => (/^[\w./@:+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
273
+
274
+ /** Past this the dotnet filter names the classes instead of each test: a command line has a limit. */
275
+ const MAX_FILTER = 6000
276
+
277
+ /**
278
+ * The command that runs exactly the pack's tests, writing results into `resultsDir`, built the way the repository
279
+ * already runs a test (an earlier job's command: its configuration and settings) or the framework's usual form.
280
+ */
281
+ export function packCommand(repo: string, tests: Pick<PackTest, "test" | "file">[], resultsDir: string, knownCommands: string[] = [], files = walk(repo)): { command: string; framework: string; notes: string[] } | null {
282
+ const notes: string[] = []
283
+ const parts: { command: string; framework: string }[] = []
284
+ const cs = tests.filter((t) => t.file.endsWith(".cs"))
285
+ if (cs.length) {
286
+ const projects = files.filter((f) => f.endsWith(".csproj"))
287
+ const projectOf = (file: string) => projects.filter((p) => file.startsWith(`${path.posix.dirname(p)}/`)).sort((a, b) => b.length - a.length)[0]
288
+ const known = knownCommands.find((c) => /\bdotnet\s+test\b/.test(c) && /--filter/.test(c))
289
+ const byProject = new Map<string, typeof cs>()
290
+ for (const t of cs) {
291
+ const p = projectOf(t.file)
292
+ if (!p) {
293
+ notes.push(`${t.file} is in no test project: left out of the command.`)
294
+ continue
295
+ }
296
+ byProject.set(p, [...(byProject.get(p) || []), t])
297
+ }
298
+ for (const [project, list] of byProject) {
299
+ let filter = list.map((t) => `FullyQualifiedName~${t.test}`).join("|")
300
+ if (filter.length > MAX_FILTER) {
301
+ filter = [...new Set(list.map((t) => t.test.split(".").slice(0, -1).join(".") || t.test))].map((c) => `FullyQualifiedName~${c}`).join("|")
302
+ notes.push(`The pack has too many tests in ${project} for one filter: its classes run whole.`)
303
+ }
304
+ const logger = `--logger "trx;LogFileName=${path.posix.basename(project, ".csproj")}.trx" --results-directory ${quote(resultsDir)}`
305
+ // An earlier job's command for this project: its configuration and settings, its filter swapped; never headed.
306
+ const base = known && known.includes(project) ? known.replace(/^(?:\s*HEADED=\S+\s+)+/, "").replace(/--filter\s+("[^"]*"|\S+)/, `--filter "${filter}"`) : `dotnet test ${project} --filter "${filter}"`
307
+ parts.push({ framework: "dotnet", command: `${base} ${logger}` })
308
+ }
309
+ }
310
+ const js = tests.filter((t) => /\.[cm]?[jt]sx?$/.test(t.file))
311
+ if (js.length) {
312
+ if (files.some((f) => /(^|\/)playwright\.config\.(ts|js|mjs|cjs)$/.test(f))) {
313
+ const titles = [...new Set(js.map((t) => t.test.split(".").pop()!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))]
314
+ parts.push({ framework: "Playwright", command: `PLAYWRIGHT_JUNIT_OUTPUT_NAME=${quote(path.join(resultsDir, "playwright.xml"))} npx playwright test ${[...new Set(js.map((t) => quote(t.file)))].join(" ")} -g ${quote(titles.join("|"))} --reporter=line,junit` })
315
+ } else notes.push(`${js.length} JavaScript tests are not Playwright Test tests: left out of the command.`)
316
+ }
317
+ const py = tests.filter((t) => t.file.endsWith(".py"))
318
+ if (py.length) {
319
+ const ids = py.map((t) => (t.test === t.file ? t.file : `${t.file}::${t.test.replace(/\./g, "::")}`))
320
+ parts.push({ framework: "pytest", command: `python -m pytest ${ids.map(quote).join(" ")} --junitxml=${quote(path.join(resultsDir, "pytest.xml"))}` })
321
+ }
322
+ const other = tests.length - cs.length - js.length - py.length
323
+ if (other) notes.push(`${other} tests in other languages are left out of the command.`)
324
+ if (!parts.length) return null
325
+ // Every part runs, whatever the one before it did; the results files say what passed.
326
+ return { command: parts.map((p) => p.command).join(" ; "), framework: [...new Set(parts.map((p) => p.framework))].join(" + "), notes }
327
+ }