@capagents/forgeqe 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. package/testmu.example.yaml +11 -0
@@ -0,0 +1,518 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { findPage, type MemoryElement, type MemoryPage } from "../../memory.ts"
6
+ import { forgeqeHome } from "../../tools.ts"
7
+ import { walk } from "../profile.ts"
8
+ import { describeLocator, fromMemory, matchLocator, type LocatorRef } from "./locators.ts"
9
+ import { langOf, parseFile, type ClassFacts, type CodeLocator, type FileFacts, type Member } from "./parse.ts"
10
+
11
+ /**
12
+ * The code graph of a test repository: every class, member, test, call, and locator, read without a model and kept
13
+ * between jobs (a file is read again only when it changes). Scribe asks it where things are instead of reading files,
14
+ * and ForgeQE ties it to page memory, so an element of a run is found in the code however the code locates it.
15
+ */
16
+
17
+ export type CodeGraph = { repo: string; files: FileFacts[]; parsed: number; reused: number; ms: number }
18
+
19
+ type CacheEntry = { size: number; mtime: number; facts: FileFacts | null }
20
+ type Cache = { version: number; files: Record<string, CacheEntry> }
21
+
22
+ /** Raise when FileFacts changes shape, so older caches are read again. */
23
+ const CACHE_VERSION = 4
24
+
25
+ export function graphCacheFile(repo: string, dir = process.env.FORGEQE_GRAPH_CACHE_DIR || path.join(forgeqeHome(), "cache", "code-graph")): string {
26
+ let real = repo
27
+ try {
28
+ real = realpathSync(repo)
29
+ } catch {}
30
+ return path.join(dir, `${createHash("sha256").update(real).digest("hex").slice(0, 16)}.json`)
31
+ }
32
+
33
+ function readCache(file: string): Cache {
34
+ try {
35
+ const c = JSON.parse(readFileSync(file, "utf8")) as Cache
36
+ if (c.version === CACHE_VERSION && c.files) return c
37
+ } catch {}
38
+ return { version: CACHE_VERSION, files: {} }
39
+ }
40
+
41
+ /** The repository's graph, from the cache where its files have not changed. */
42
+ export async function buildGraph(repo: string, opts: { cacheDir?: string } = {}): Promise<CodeGraph> {
43
+ const started = performance.now()
44
+ const cacheFile = graphCacheFile(repo, opts.cacheDir)
45
+ const cache = readCache(cacheFile)
46
+ const next: Cache = { version: CACHE_VERSION, files: {} }
47
+ const files: FileFacts[] = []
48
+ let parsed = 0
49
+ let reused = 0
50
+ for (const rel of walk(repo).filter((f) => langOf(f))) {
51
+ let st
52
+ try {
53
+ st = statSync(path.join(repo, rel))
54
+ } catch {
55
+ continue
56
+ }
57
+ const old = cache.files[rel]
58
+ let entry: CacheEntry
59
+ if (old && old.size === st.size && old.mtime === st.mtimeMs) {
60
+ entry = old
61
+ reused++
62
+ } else {
63
+ let facts: FileFacts | null = null
64
+ try {
65
+ facts = await parseFile(rel, readFileSync(path.join(repo, rel), "utf8"))
66
+ } catch {
67
+ // A file the grammar cannot read stays out of the graph.
68
+ }
69
+ entry = { size: st.size, mtime: st.mtimeMs, facts }
70
+ parsed++
71
+ }
72
+ next.files[rel] = entry
73
+ if (entry.facts) files.push(entry.facts)
74
+ }
75
+ if (parsed || Object.keys(cache.files).length !== Object.keys(next.files).length) {
76
+ try {
77
+ mkdirSync(path.dirname(cacheFile), { recursive: true })
78
+ writeFileSync(cacheFile, JSON.stringify(next))
79
+ } catch {
80
+ // Without a cache the next job reads every file again.
81
+ }
82
+ }
83
+ return { repo, files, parsed, reused, ms: Math.round(performance.now() - started) }
84
+ }
85
+
86
+ /** A member (or top-level function or test) with where it is. */
87
+ export type Located = { file: string; cls?: ClassFacts; member: Member }
88
+
89
+ export function allMembers(g: CodeGraph): Located[] {
90
+ return g.files.flatMap((f) => [...f.classes.flatMap((cls) => cls.members.map((member) => ({ file: f.file, cls, member }))), ...f.functions.map((member) => ({ file: f.file, member }))])
91
+ }
92
+
93
+ const qualified = (l: Located) => (l.cls ? `${l.cls.name}.${l.member.name}` : l.member.name)
94
+ const where = (file: string, line: number) => `${file}:${line}`
95
+
96
+ /** Words of an identifier or phrase: SignInAsync → sign, in, async; "Sign in" → sign, in. */
97
+ function words(s: string): string[] {
98
+ return s
99
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
100
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/)
103
+ .filter(Boolean)
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------------------------------------------
107
+ // Find
108
+
109
+ export type Found = { score: number; text: string }
110
+
111
+ /**
112
+ * Classes and members whose name, file, or locators match the query, best first. Only strong matches: every word of
113
+ * the query in the name, its class, or its locator text. A query for text in files (a config key, a case id) finds
114
+ * nothing here, and grep is the tool for it.
115
+ */
116
+ export function findSymbols(g: CodeGraph, query: string, limit = 15): string[] {
117
+ const q = query.trim().toLowerCase()
118
+ const qw = words(query)
119
+ if (!qw.length) return []
120
+ const found: Found[] = []
121
+ const score = (name: string, extra: string): number => {
122
+ const n = name.toLowerCase()
123
+ if (n === q || n === q.replace(/\s+/g, "")) return 100
124
+ const nw = words(name)
125
+ if (qw.every((w) => nw.includes(w))) return 70
126
+ if (n.includes(q.replace(/\s+/g, ""))) return 60
127
+ const all = new Set([...nw, ...words(extra)])
128
+ const hit = qw.filter((w) => all.has(w) || [...all].some((a) => a.startsWith(w) && w.length >= 3)).length
129
+ return hit === qw.length ? 40 : hit && qw.length > 1 ? (20 * hit) / qw.length : 0
130
+ }
131
+ for (const f of g.files) {
132
+ for (const cls of f.classes) {
133
+ const s = score(cls.name, f.file)
134
+ if (s) found.push({ score: s + 5, text: `${cls.kind} ${cls.name}${cls.bases.length ? ` : ${cls.bases.join(", ")}` : ""} ${where(f.file, cls.line)} (${cls.members.length} members)` })
135
+ }
136
+ }
137
+ for (const l of allMembers(g)) {
138
+ const locText = l.member.locators.map((x) => `${x.role || ""} ${x.value}`).join(" ")
139
+ let s = score(l.member.name, `${l.cls?.name || ""} ${l.file}`)
140
+ // A locator's text ("Sign in") finds the member that locates it.
141
+ if (!s && locText && qw.every((w) => words(locText).includes(w))) s = 50
142
+ if (s) found.push({ score: s, text: `${l.member.kind} ${qualified(l)} ${where(l.file, l.member.line)} ${l.member.signature}` })
143
+ }
144
+ return found
145
+ .filter((x) => x.score >= 40)
146
+ .sort((a, b) => b.score - a.score)
147
+ .slice(0, limit)
148
+ .map((x) => x.text)
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------------------------------------------
152
+ // Outline
153
+
154
+ function locatorNote(l: CodeLocator): string {
155
+ return `${describeLocator(l)}${l.quality === "brittle" ? " [brittle]" : ""}`
156
+ }
157
+
158
+ function classOutline(file: string, cls: ClassFacts): string[] {
159
+ const lines = [`${cls.kind} ${cls.name}${cls.bases.length ? ` : ${cls.bases.join(", ")}` : ""} ${where(file, cls.line)}-${cls.endLine}`]
160
+ for (const m of cls.members) {
161
+ lines.push(` L${m.line} ${m.kind === "test" ? "test " : ""}${m.signature}`)
162
+ for (const l of m.locators) lines.push(` locates: ${locatorNote(l)}`)
163
+ }
164
+ return lines
165
+ }
166
+
167
+ /**
168
+ * The outline of a file, a class, or the source of one member ("LoginPage.SignInAsync"): signatures and locators,
169
+ * not bodies, so Scribe reads only the lines it needs.
170
+ */
171
+ export function outline(g: CodeGraph, target: string): string {
172
+ const t = target.trim().replace(/^\.\//, "")
173
+ const file = g.files.find((f) => f.file === t || f.file.endsWith(`/${t}`))
174
+ if (file) {
175
+ const out = [`${file.file} (${file.lang})`]
176
+ for (const cls of file.classes) out.push(...classOutline(file.file, cls))
177
+ for (const fn of file.functions) {
178
+ out.push(`L${fn.line} ${fn.kind === "test" ? "test " : ""}${fn.signature}`)
179
+ for (const l of fn.locators) out.push(` locates: ${locatorNote(l)}`)
180
+ }
181
+ return out.join("\n")
182
+ }
183
+ const [clsName, memberName] = t.includes(".") ? [t.slice(0, t.lastIndexOf(".")), t.slice(t.lastIndexOf(".") + 1)] : [t, ""]
184
+ const owners = g.files.flatMap((f) => f.classes.filter((c) => c.name === clsName).map((cls) => ({ file: f.file, cls })))
185
+ if (owners.length && !memberName) return owners.flatMap((o) => classOutline(o.file, o.cls)).join("\n")
186
+ const members = memberName
187
+ ? owners.flatMap((o) => o.cls.members.filter((m) => m.name === memberName).map((member) => ({ file: o.file, cls: o.cls, member })))
188
+ : allMembers(g).filter((l) => l.member.name === t)
189
+ if (!members.length) return `Nothing named ${target} in the code graph. Use code_find to search.`
190
+ return members
191
+ .slice(0, 3)
192
+ .map((l) => {
193
+ let src = ""
194
+ try {
195
+ src = readFileSync(path.join(g.repo, l.file), "utf8").split("\n").slice(l.member.line - 1, l.member.endLine).join("\n")
196
+ } catch {}
197
+ return `${qualified(l)} ${where(l.file, l.member.line)}-${l.member.endLine}\n${src}`
198
+ })
199
+ .join("\n\n")
200
+ }
201
+
202
+ /** The outlines of several classes, in order, up to a size, so a prompt can carry them instead of the files. */
203
+ export function outlines(g: CodeGraph, classes: string[], maxChars = 8000): string {
204
+ const out: string[] = []
205
+ let size = 0
206
+ for (const cls of classes) {
207
+ const text = outline(g, cls)
208
+ if (text.startsWith("Nothing named") || size + text.length > maxChars) continue
209
+ size += text.length
210
+ out.push(text)
211
+ }
212
+ return out.join("\n\n")
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------------------------------------------
216
+ // Elements: code ↔ page memory
217
+
218
+ export type ElementMatch = { at: Located; locator: CodeLocator; how: "exact" | "pattern" | "param"; via: LocatorRef }
219
+
220
+ const HOW_RANK = { exact: 0, pattern: 1, param: 2 }
221
+
222
+ /** Members whose locators find an element known by any of `refs`, best first. */
223
+ export function membersLocating(g: CodeGraph, refs: LocatorRef[]): ElementMatch[] {
224
+ const out: ElementMatch[] = []
225
+ for (const at of allMembers(g)) {
226
+ let best: ElementMatch | null = null
227
+ for (const locator of at.member.locators) {
228
+ for (const ref of refs) {
229
+ const how = matchLocator(locator, ref)
230
+ if (how && (!best || HOW_RANK[how] < HOW_RANK[best.how])) best = { at, locator, how, via: ref }
231
+ }
232
+ }
233
+ if (best) out.push(best)
234
+ }
235
+ // A parameterised role (any link) matches too much to be useful once something matches better.
236
+ const sorted = out.sort((a, b) => HOW_RANK[a.how] - HOW_RANK[b.how] || Number(b.at.member.kind === "property" || b.at.member.kind === "field") - Number(a.at.member.kind === "property" || a.at.member.kind === "field"))
237
+ return sorted[0] && sorted[0].how !== "param" ? sorted.filter((m) => m.how !== "param") : sorted
238
+ }
239
+
240
+ /** An element's locators from page memory, as refs, with its role and name. */
241
+ export function elementRefs(e: MemoryElement): LocatorRef[] {
242
+ const refs = e.locators.map((l) => fromMemory(l.type, l.value)).filter((r): r is LocatorRef => Boolean(r))
243
+ if (e.role && e.name && !refs.some((r) => r.kind === "role")) refs.push({ kind: "role", role: e.role, value: e.name, quality: "semantic" })
244
+ if (e.text && !refs.some((r) => r.kind === "text")) refs.push({ kind: "text", value: e.text, quality: "semantic" })
245
+ return refs
246
+ }
247
+
248
+ /**
249
+ * Every locator of the element `refs` describe, from page memory: the same element is known there by its id, its role
250
+ * and name, its text, and its CSS, so code that uses any one of them is found. `url` narrows it to one page. When the
251
+ * refs fit several different elements (six "Add to cart" buttons), it says how many and adds nothing: guessing one
252
+ * would point at the wrong page-object member.
253
+ */
254
+ export function sameElement(
255
+ pages: MemoryPage[],
256
+ refs: LocatorRef[],
257
+ url?: string,
258
+ ): { refs: LocatorRef[]; page?: MemoryPage; element?: MemoryElement; ambiguous?: number } {
259
+ const scope = url ? [findPage(pages, url)].filter((p): p is MemoryPage => Boolean(p)) : pages
260
+ const found = new Map<string, { page: MemoryPage; element: MemoryElement; known: LocatorRef[] }>()
261
+ for (const page of scope) {
262
+ for (const element of Object.values(page.elements || {})) {
263
+ const known = elementRefs(element)
264
+ // Only an exact, unique-looking locator ties a ref to a memory element: role+name, id, test id, label.
265
+ if (!refs.some((r) => !r.param && !r.pattern && r.value && r.kind !== "text" && r.kind !== "css" && known.some((k) => matchLocator(r, k) === "exact"))) continue
266
+ // The same element on several pages (a header on every page) is one element: keyed by its strongest locator.
267
+ const id = known.find((k) => k.kind === "testid") || known.find((k) => k.kind === "id") || known.find((k) => k.kind === "xpath") || known[0]
268
+ const key = `${id.kind}|${id.role || ""}|${id.value}`
269
+ if (!found.has(key)) found.set(key, { page, element, known })
270
+ }
271
+ }
272
+ if (found.size > 1) return { refs, ambiguous: found.size }
273
+ const [only] = found.values()
274
+ if (!only) return { refs }
275
+ const all = [...refs]
276
+ for (const k of only.known) if (!all.some((a) => a.kind === k.kind && a.value === k.value && a.role === k.role)) all.push(k)
277
+ return { refs: all, page: only.page, element: only.element }
278
+ }
279
+
280
+ /**
281
+ * The members that use a member: in its class and subclasses (LoginButton → SignInAsync), or, for a module-level
282
+ * selector constant, in its file and the files that import it (loginButton → LoginPage.login).
283
+ */
284
+ const CONSTRUCTOR = /^(constructor|__init__)$/
285
+
286
+ export function usersOf(g: CodeGraph, at: Located): string[] {
287
+ const name = at.member.name
288
+ if (!at.cls) {
289
+ const base = path.basename(at.file).replace(/\.\w+$/, "")
290
+ const files = g.files.filter((f) => f.file === at.file || (f.imports || []).some((spec) => spec.split(/[/.]/).filter(Boolean).pop() === base))
291
+ return allMembers(g)
292
+ .filter((l) => files.some((f) => f.file === l.file) && l.member !== at.member && !CONSTRUCTOR.test(l.member.name) && (l.member.names || []).includes(name))
293
+ // Page objects' methods first: they are what a test calls.
294
+ .sort((a, b) => Number(a.member.kind === "test") - Number(b.member.kind === "test"))
295
+ .map(qualified)
296
+ }
297
+ const subclasses = new Set(g.files.flatMap((f) => f.classes.filter((c) => c.bases.includes(at.cls!.name)).map((c) => c.name)))
298
+ return allMembers(g)
299
+ // A constructor only sets the field up; the members that act through it are its users.
300
+ .filter((l) => l.cls && (l.cls === at.cls || subclasses.has(l.cls.name)) && l.member !== at.member && !CONSTRUCTOR.test(l.member.name) && l.member.idents.includes(name))
301
+ .map(qualified)
302
+ }
303
+
304
+ /** One line per match, for the handoff and the tools. */
305
+ export function describeMatches(g: CodeGraph, matches: ElementMatch[], max = 3): string[] {
306
+ return matches.slice(0, max).map((m) => {
307
+ const users = usersOf(g, m.at).slice(0, 4)
308
+ const how = m.how === "exact" ? "" : m.how === "pattern" ? " (its pattern matches)" : " (a parameterised locator for this role)"
309
+ return `${qualified(m.at)} at ${where(m.at.file, m.at.member.line)}: ${describeLocator(m.locator)}${m.locator.quality === "brittle" ? " [brittle]" : ""}${how}${users.length ? `; used by ${users.join(", ")}` : ""}`
310
+ })
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------------------------------------------
314
+ // References
315
+
316
+ /** The class a receiver names: from the file's declarations, then any file's, then by name (articlePage → WikipediaArticlePage). */
317
+ function receiverClass(g: CodeGraph, file: FileFacts, receiver: string, classes: Set<string>): string | null {
318
+ if (!receiver) return null
319
+ const last = receiver.split(".").pop() || receiver
320
+ if (classes.has(last)) return last
321
+ const bound = file.bindings[receiver] || file.bindings[last]
322
+ if (bound && classes.has(bound)) return bound
323
+ for (const f of g.files) {
324
+ const t = f.bindings[last]
325
+ if (t && classes.has(t)) return t
326
+ }
327
+ const key = last.replace(/^_+/, "").toLowerCase()
328
+ if (key.length < 3) return null
329
+ const byName = [...classes].filter((c) => c.toLowerCase() === key || c.toLowerCase().endsWith(key))
330
+ return byName.length === 1 ? byName[0] : null
331
+ }
332
+
333
+ /** The class and its base classes, nearest first. */
334
+ function lineage(g: CodeGraph, name: string): string[] {
335
+ const out: string[] = []
336
+ let cur: string | undefined = name
337
+ while (cur && !out.includes(cur)) {
338
+ out.push(cur)
339
+ cur = g.files.flatMap((f) => f.classes).find((c) => c.name === cur)?.bases[0]
340
+ }
341
+ return out
342
+ }
343
+
344
+ export type Reference = { at: Located; line: number; certain: boolean }
345
+
346
+ /** Every place that calls or reads `Class.member` (or `member`), tests first. */
347
+ export function references(g: CodeGraph, target: string): { defined: Located[]; refs: Reference[] } {
348
+ const t = target.trim()
349
+ const dot = t.lastIndexOf(".")
350
+ const clsName = dot > 0 ? t.slice(0, dot) : ""
351
+ const name = dot > 0 ? t.slice(dot + 1) : t
352
+ const defined = allMembers(g).filter((l) => l.member.name === name && (!clsName || l.cls?.name === clsName))
353
+ if (!defined.length) return { defined, refs: [] }
354
+ const owners = new Set(defined.map((d) => d.cls?.name).filter((c): c is string => Boolean(c)))
355
+ const classes = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
356
+ const refs: Reference[] = []
357
+ for (const file of g.files) {
358
+ const members: Located[] = [...file.classes.flatMap((cls) => cls.members.map((member) => ({ file: file.file, cls, member }))), ...file.functions.map((member) => ({ file: file.file, member }))]
359
+ for (const at of members) {
360
+ if (defined.some((d) => d.member === at.member)) continue
361
+ const call = at.member.calls.find((c) => c.name === name)
362
+ if (call) {
363
+ const own = !call.receiver || /^(this|self|base|super)$/.test(call.receiver)
364
+ const cls = own ? (at.cls?.name ?? null) : receiverClass(g, file, call.receiver, classes)
365
+ if (cls && owners.size && lineage(g, cls).some((c) => owners.has(c))) {
366
+ refs.push({ at, line: call.line, certain: true })
367
+ continue
368
+ }
369
+ if (!cls && (owners.size <= 1 || !clsName)) {
370
+ refs.push({ at, line: call.line, certain: false })
371
+ continue
372
+ }
373
+ }
374
+ // A property read inside its own class or a subclass: LoginButton in SignInAsync.
375
+ if (!call && at.cls && at.member.idents.includes(name) && lineage(g, at.cls.name).some((c) => owners.has(c))) refs.push({ at, line: at.member.line, certain: true })
376
+ }
377
+ }
378
+ refs.sort((a, b) => Number(b.at.member.kind === "test") - Number(a.at.member.kind === "test") || Number(b.certain) - Number(a.certain))
379
+ return { defined, refs }
380
+ }
381
+
382
+ export function describeReferences(target: string, r: ReturnType<typeof references>, limit = 40): string {
383
+ if (!r.defined.length) return `Nothing named ${target} in the code graph. Use code_find to search.`
384
+ const lines = [`${target} is defined at ${r.defined.map((d) => where(d.file, d.member.line)).join(", ")}`]
385
+ if (!r.refs.length) return [...lines, "Nothing in the repository calls or reads it."].join("\n")
386
+ const tests = r.refs.filter((x) => x.at.member.kind === "test").length
387
+ lines.push(`${r.refs.length} ${r.refs.length === 1 ? "place uses" : "places use"} it${tests ? ` (${tests} ${tests === 1 ? "test" : "tests"})` : ""}:`)
388
+ for (const x of r.refs.slice(0, limit)) lines.push(`- ${x.at.member.kind === "test" ? "test " : ""}${qualified(x.at)} ${where(x.at.file, x.line)}${x.certain ? "" : " (by name only: its receiver's type is unknown)"}`)
389
+ if (r.refs.length > limit) lines.push(`… and ${r.refs.length - limit} more`)
390
+ return lines.join("\n")
391
+ }
392
+
393
+ /**
394
+ * The tests a change to these files reaches, through the calls between members: a changed page object reaches the
395
+ * flows that call it, and the tests that call those. Tests are known by their markers ([Test], @Test, test('…'),
396
+ * def test_), so a suite file of any name counts.
397
+ */
398
+ export function testsReaching(g: CodeGraph, changed: string[]): { test: Located; why: string }[] {
399
+ const files = new Set(changed.map((f) => f.replace(/^\.\//, "")))
400
+ const classes = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
401
+ const byFile = new Map(g.files.map((f) => [f.file, f]))
402
+ const all = allMembers(g)
403
+ // Reached members, as Class.member, with why.
404
+ const reached = new Map<string, string>()
405
+ for (const l of all) if (files.has(l.file)) reached.set(qualified(l), `in ${l.file}`)
406
+ const out = new Map<Located, string>()
407
+ for (const l of all) if (files.has(l.file) && l.member.kind === "test") out.set(l, `is in ${l.file}`)
408
+ for (let round = 0; round < 5; round++) {
409
+ let grew = false
410
+ for (const l of all) {
411
+ const key = qualified(l)
412
+ if (reached.has(key) && !(l.member.kind === "test" && !out.has(l))) continue
413
+ for (const call of l.member.calls) {
414
+ const own = !call.receiver || /^(this|self|base|super)$/.test(call.receiver)
415
+ const cls = own ? (l.cls?.name ?? null) : receiverClass(g, byFile.get(l.file)!, call.receiver, classes)
416
+ if (!cls) continue
417
+ const hit = lineage(g, cls).map((c) => `${c}.${call.name}`).find((k) => reached.has(k))
418
+ if (!hit) continue
419
+ if (l.member.kind === "test") out.set(l, `calls ${hit}${reached.get(hit)?.startsWith("in ") ? "" : ` (${reached.get(hit)})`}`)
420
+ if (!reached.has(key)) {
421
+ reached.set(key, `through ${hit}`)
422
+ grew = true
423
+ }
424
+ break
425
+ }
426
+ }
427
+ if (!grew) break
428
+ }
429
+ return [...out].map(([test, why]) => ({ test, why }))
430
+ }
431
+
432
+ /** The classes a member calls into: its receivers' classes and their bases, and its own class's bases. */
433
+ function classesCalled(g: CodeGraph, file: FileFacts, at: Located, classes: Set<string>): Set<string> {
434
+ const out = new Set<string>()
435
+ for (const call of at.member.calls) {
436
+ const own = !call.receiver || /^(this|self|base|super)$/.test(call.receiver)
437
+ const cls = own ? (at.cls?.bases[0] ?? null) : receiverClass(g, file, call.receiver, classes)
438
+ if (!cls) continue
439
+ // Only the classes in the lineage that define what was called, so BasePage is named for OpenAsync, not always.
440
+ for (const c of lineage(g, cls)) {
441
+ if (!classes.has(c)) continue
442
+ out.add(c)
443
+ if (g.files.some((f) => f.classes.some((k) => k.name === c && k.members.some((m) => m.name === call.name)))) break
444
+ }
445
+ }
446
+ return out
447
+ }
448
+
449
+ /** The classes the code in these files uses, most used first; not the classes the files define themselves. */
450
+ export function classesUsedBy(g: CodeGraph, files: string[]): string[] {
451
+ const wanted = new Set(files.map((f) => f.replace(/^\.\//, "")))
452
+ const classes = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
453
+ const own = new Set(g.files.filter((f) => wanted.has(f.file)).flatMap((f) => f.classes.map((c) => c.name)))
454
+ const count = new Map<string, number>()
455
+ for (const file of g.files.filter((f) => wanted.has(f.file))) {
456
+ const members: Located[] = [...file.classes.flatMap((cls) => cls.members.map((member) => ({ file: file.file, cls, member }))), ...file.functions.map((member) => ({ file: file.file, member }))]
457
+ for (const at of members) for (const c of classesCalled(g, file, at, classes)) if (!own.has(c)) count.set(c, (count.get(c) || 0) + 1)
458
+ }
459
+ return [...count].sort((a, b) => b[1] - a[1]).map(([c]) => c)
460
+ }
461
+
462
+ /**
463
+ * The test that uses the most of these classes, as an example of how the repository's tests use them: its fixture,
464
+ * attributes, setup, and assertion style. Null when no test uses any of them.
465
+ */
466
+ export function exampleTest(g: CodeGraph, classes: string[]): Located | null {
467
+ const wanted = new Set(classes)
468
+ const all = new Set(g.files.flatMap((f) => f.classes.map((c) => c.name)))
469
+ let best: { at: Located; hits: number; size: number } | null = null
470
+ for (const file of g.files) {
471
+ const members: Located[] = [...file.classes.flatMap((cls) => cls.members.map((member) => ({ file: file.file, cls, member }))), ...file.functions.map((member) => ({ file: file.file, member }))]
472
+ for (const at of members) {
473
+ if (at.member.kind !== "test") continue
474
+ const hits = [...classesCalled(g, file, at, all)].filter((c) => wanted.has(c)).length
475
+ const size = at.member.endLine - at.member.line
476
+ if (hits && (!best || hits > best.hits || (hits === best.hits && size < best.size))) best = { at, hits, size }
477
+ }
478
+ }
479
+ return best?.at ?? null
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------------------------------------------
483
+ // What a job added: duplicates and brittle locators, for the reviewer
484
+
485
+ /** Members in `after` that are new, keyed Class.member@file. */
486
+ function newMembers(before: CodeGraph, after: CodeGraph, changed: Set<string>): Located[] {
487
+ const key = (l: Located) => `${l.file}|${qualified(l)}`
488
+ const old = new Set(allMembers(before).map(key))
489
+ return allMembers(after).filter((l) => changed.has(l.file) && !old.has(key(l)))
490
+ }
491
+
492
+ /**
493
+ * Facts about what a job added, for the reviewer: new members that locate an element an existing member already
494
+ * locates (a duplicate to reuse), and new XPath or positional locators.
495
+ */
496
+ export function additionFacts(before: CodeGraph, after: CodeGraph, changed: string[]): string[] {
497
+ const added = newMembers(before, after, new Set(changed))
498
+ const existing = allMembers(before)
499
+ const out: string[] = []
500
+ for (const l of added) {
501
+ for (const loc of l.member.locators) {
502
+ if (loc.param || loc.pattern || !loc.value) continue
503
+ const same = existing.find((e) => e.member.locators.some((x) => !x.param && matchLocator(x, loc) === "exact"))
504
+ if (same) out.push(`${qualified(l)} (${where(l.file, l.member.line)}) locates ${describeLocator(loc)}, which ${qualified(same)} (${where(same.file, same.member.line)}) already locates: reuse it instead of a second locator for the same element.`)
505
+ }
506
+ for (const loc of l.member.locators.filter((x) => x.quality === "brittle")) {
507
+ out.push(`${qualified(l)} (${where(l.file, loc.line)}) uses a brittle locator: ${describeLocator(loc)}.`)
508
+ }
509
+ }
510
+ // Tests the repository had in the changed files that are gone now: the job removed or renamed them.
511
+ const now = new Set(allMembers(after).map((l) => `${l.file}|${qualified(l)}`))
512
+ for (const l of allMembers(before)) {
513
+ if (l.member.kind === "test" && changed.includes(l.file) && !now.has(`${l.file}|${qualified(l)}`)) {
514
+ out.push(`The existing test ${qualified(l)} (${where(l.file, l.member.line)}) is gone after the job: it was removed or rewritten.`)
515
+ }
516
+ }
517
+ return [...new Set(out)].slice(0, 20)
518
+ }