@capagents/forgeqe 1.1.3 → 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 +7 -5
- package/browserstack.example.yaml +15 -3
- package/docs/user-guide.html +62 -10
- package/forgeqe.example.yaml +25 -18
- package/package.json +2 -2
- 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 +79 -12
- package/py/forgeqe_engine/service.py +12 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/bi.ts +382 -302
- package/src/brain/index.ts +1 -1
- package/src/capabilities/guide.ts +3 -3
- 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 +16 -7
- package/src/cli/index.ts +56 -15
- 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 +160 -56
- 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 +9 -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 +14 -4
- package/src/cache.ts +0 -92
|
@@ -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
|
+
}
|