@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
package/src/cli/index.ts CHANGED
@@ -10,7 +10,7 @@ import { goalFor, loadCases } from "../cases/index.ts"
10
10
  import { codeSkipReason, latestRunDir, runCodeAgent } from "../code/index.ts"
11
11
  import { detailFrom } from "../detail.ts"
12
12
  import { ensureOpencode, INSTALL_HINT } from "../code/runtime.ts"
13
- import { browserProvider, loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig, type BrowserProvider } from "../config.ts"
13
+ import { browserConfigFor, browserProvider, loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig, type BrowserProvider } from "../config.ts"
14
14
  import { runHeal } from "../heal/loop.ts"
15
15
  import { formatHeal } from "../heal/report.ts"
16
16
  import { backgroundMonitors, stopBackgroundMonitors } from "../monitor/background.ts"
@@ -30,7 +30,7 @@ import { externalMcp } from "../mcp/external.ts"
30
30
  import { createMcpServer, serveStdio } from "../mcp/server.ts"
31
31
  import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
32
32
  import { formatSuite } from "../suite/report.ts"
33
- import { runSuite, suiteExitCode } from "../suite/run.ts"
33
+ import { runSuite, suiteExitCode, suiteParallel } from "../suite/run.ts"
34
34
  import { packageVersion } from "../tui/theme.ts"
35
35
  import { update } from "../update.ts"
36
36
  import { meterSnapshot, requestLine } from "../usage.ts"
@@ -67,6 +67,12 @@ function browserProviderFlag(flags: Flags): BrowserProvider | undefined {
67
67
  return value ? browserProvider(value) : undefined
68
68
  }
69
69
 
70
+ /** --devices win11-chrome,mac-edge: run on each named device (browser_devices, or devices: in a provider file). */
71
+ function devicesFlag(flags: Flags): string[] | undefined {
72
+ const names = (strFlag(flags, "devices") || "").split(",").map((d) => d.trim()).filter(Boolean)
73
+ return names.length ? [...new Set(names)] : undefined
74
+ }
75
+
70
76
  function debugRequested(flags: Flags): boolean {
71
77
  return boolFlag(flags, "debug") || /^(1|true|yes|on)$/i.test(process.env.FORGEQE_DEBUG || "")
72
78
  }
@@ -221,6 +227,19 @@ export async function main(argv: string[]): Promise<number> {
221
227
  const out = (await TOOLS.find((t) => t.name === (trends ? "quality_trends" : "quality_report"))!.run(trends ? { page: positionals[2] } : { run_dir: positionals[1] }, ctx)) as { budget_exceeded?: unknown[]; pages?: { slower: unknown[]; newly_failing_rules: unknown[] }[] }
222
228
  return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
223
229
  }
230
+ case "defects": {
231
+ // forgeqe defects [--folder DIR]: trends; forgeqe defects predict [--repo DIR] [--base REF] [--head REF].
232
+ const cfg = loadConfig(configOpts(flags))
233
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
234
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
235
+ const str = (k: string) => (typeof flags.get(k) === "string" ? String(flags.get(k)) : undefined)
236
+ if (positionals[1] === "predict") {
237
+ await TOOLS.find((t) => t.name === "predict_defects")!.run({ repo: str("repo"), base: str("base"), head: str("head"), folder: str("folder") }, ctx)
238
+ return 0
239
+ }
240
+ await TOOLS.find((t) => t.name === "defect_trends")!.run({ folder: str("folder") }, ctx)
241
+ return 0
242
+ }
224
243
  case "logs":
225
244
  return cmdLogs(positionals.slice(1), flags)
226
245
  case "skills":
@@ -796,6 +815,8 @@ async function cmdStart(flags: Flags): Promise<number> {
796
815
  url: strFlag(flags, "url"),
797
816
  goal: strFlag(flags, "goal"),
798
817
  headed: boolFlag(flags, "headed"),
818
+ browserProvider: browserProviderFlag(flags),
819
+ devices: devicesFlag(flags),
799
820
  verbose: boolFlag(flags, "verbose"),
800
821
  ...memoryFlags(flags),
801
822
  repo: boolFlag(flags, "no-code") ? null : strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : undefined,
@@ -941,6 +962,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
941
962
  context: () => ({
942
963
  headed: boolFlag(flags, "headed"),
943
964
  browserProvider: browserProviderFlag(flags),
965
+ devices: devicesFlag(flags),
944
966
  maxSteps,
945
967
  url: strFlag(flags, "url"),
946
968
  ...memoryFlags(flags),
@@ -974,7 +996,7 @@ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: nu
974
996
  }
975
997
 
976
998
  function cmdMcpList(cfg: AppConfig): number {
977
- const { servers, skipped } = externalMcp(cfg)
999
+ const { servers, skipped } = externalMcp(cfg, Bun.which, process.env, { cwd: process.cwd() })
978
1000
  if (!servers.length && !skipped.length) {
979
1001
  console.log("No MCP servers for other systems. Set sources.azure_devops or sources.jira, or add servers under mcp:, in forgeqe.yaml.")
980
1002
  }
@@ -1006,6 +1028,7 @@ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
1006
1028
  cwd,
1007
1029
  headed: boolFlag(flags, "headed"),
1008
1030
  browserProvider: browserProviderFlag(flags),
1031
+ devices: devicesFlag(flags),
1009
1032
  maxSteps: maxStepsFlag(flags),
1010
1033
  url: strFlag(flags, "url"),
1011
1034
  ...memoryFlags(flags),
@@ -1023,10 +1046,13 @@ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: num
1023
1046
  const out = runDirectory(cfg.export.outDir, strFlag(flags, "out"))
1024
1047
  const t0 = Date.now()
1025
1048
  let steps = 0
1049
+ const devices = devicesFlag(flags) || []
1050
+ if (devices.length > 1) throw new Error("run --direct runs one browser: pass one device, or use forgeqe suite or ForgeQE's agent for several.")
1026
1051
  const { result, files } = await executeRun({
1027
1052
  config: cfg,
1028
1053
  goal,
1029
- browserProvider: browserProviderFlag(flags),
1054
+ browserProvider: devices.length ? undefined : browserProviderFlag(flags),
1055
+ browserDevice: devices[0],
1030
1056
  url: strFlag(flags, "url"),
1031
1057
  headed: boolFlag(flags, "headed"),
1032
1058
  maxSteps,
@@ -1265,11 +1291,13 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1265
1291
  cfg = withCodeFlags(cfg, flags)
1266
1292
  const parallelRaw = strFlag(flags, "parallel")
1267
1293
  if (parallelRaw && !(Number(parallelRaw) >= 1)) throw new Error("--parallel must be a positive number")
1268
- const parallel = parallelRaw ? Number(parallelRaw) : cfg.suite.parallel
1269
1294
  const cases = await loadCases(refs, cfg.sources)
1295
+ const devices = devicesFlag(flags) || []
1296
+ for (const d of devices) browserConfigFor(cfg, undefined, d)
1297
+ const parallel = suiteParallel(parallelRaw ? Number(parallelRaw) : undefined, cfg.suite.parallel, devices.length, cases.length * Math.max(1, devices.length))
1270
1298
  const repo = codeRepo(cfg, flags)
1271
1299
  const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
1272
- console.log(`${cases.length} test case${cases.length === 1 ? "" : "s"}${parallel > 1 ? `, ${parallel} at a time` : ""}${repo ? `; passed cases get a test in ${repo}` : ""}`)
1300
+ console.log(`${cases.length} test case${cases.length === 1 ? "" : "s"}${devices.length ? ` on ${devices.length} device${devices.length === 1 ? "" : "s"} (${devices.join(", ")})` : ""}${parallel > 1 ? `, ${parallel} at a time` : ""}${repo ? `; passed cases get a test in ${repo}` : ""}`)
1273
1301
 
1274
1302
  const controller = new AbortController()
1275
1303
  const onSigint = () => controller.abort()
@@ -1279,7 +1307,8 @@ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1279
1307
  config: cfg,
1280
1308
  cases,
1281
1309
  repo,
1282
- browserProvider: browserProviderFlag(flags),
1310
+ browserProvider: devices.length ? undefined : browserProviderFlag(flags),
1311
+ devices,
1283
1312
  parallel,
1284
1313
  outDir: strFlag(flags, "out"),
1285
1314
  writeBack: boolFlag(flags, "no-write-back") ? false : undefined,
@@ -0,0 +1,511 @@
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
+ return [...new Set(out)].slice(0, 20)
511
+ }