@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.
Files changed (60) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +7 -5
  3. package/browserstack.example.yaml +15 -3
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +25 -18
  6. package/package.json +2 -2
  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 +79 -12
  14. package/py/forgeqe_engine/service.py +12 -0
  15. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  16. package/skills/playwright-typescript/SKILL.md +2 -1
  17. package/src/bi.ts +382 -302
  18. package/src/brain/index.ts +1 -1
  19. package/src/capabilities/guide.ts +3 -3
  20. package/src/capabilities/registry.ts +34 -6
  21. package/src/cases/bugs.ts +209 -22
  22. package/src/cases/defectHistory.ts +193 -0
  23. package/src/cases/defects.ts +255 -0
  24. package/src/cases/github.ts +162 -0
  25. package/src/cases/goal.ts +1 -1
  26. package/src/cases/index.ts +23 -4
  27. package/src/cases/predict.ts +372 -0
  28. package/src/cases/rca.ts +258 -0
  29. package/src/cases/readiness.ts +38 -1
  30. package/src/cases/repro.ts +202 -0
  31. package/src/cases/types.ts +8 -1
  32. package/src/cli/help.ts +16 -7
  33. package/src/cli/index.ts +56 -15
  34. package/src/code/graph/graph.ts +511 -0
  35. package/src/code/graph/locators.ts +398 -0
  36. package/src/code/graph/parse.ts +439 -0
  37. package/src/code/graph/tools.ts +113 -0
  38. package/src/code/handoff.ts +93 -18
  39. package/src/code/loop.ts +30 -5
  40. package/src/code/profile.ts +14 -3
  41. package/src/code/replay.ts +1 -1
  42. package/src/code/review.ts +22 -0
  43. package/src/config.ts +160 -56
  44. package/src/mcp/attachtools.ts +223 -0
  45. package/src/mcp/commands.ts +1 -1
  46. package/src/mcp/defecttools.ts +106 -0
  47. package/src/mcp/devicetools.ts +125 -0
  48. package/src/mcp/external.ts +42 -1
  49. package/src/mcp/tools.ts +92 -9
  50. package/src/monitor/run.ts +1 -1
  51. package/src/report/data.ts +14 -0
  52. package/src/report/html.ts +15 -3
  53. package/src/report/template.html +44 -43
  54. package/src/run.ts +9 -2
  55. package/src/suite/report.ts +44 -11
  56. package/src/suite/run.ts +163 -16
  57. package/src/tui/App.tsx +9 -3
  58. package/src/usage.ts +1 -1
  59. package/testmu.example.yaml +14 -4
  60. package/src/cache.ts +0 -92
@@ -0,0 +1,439 @@
1
+ import { createRequire } from "node:module"
2
+ import path from "node:path"
3
+
4
+ import { Language, Parser, type Node } from "@vscode/tree-sitter-wasm"
5
+
6
+ import { fromSelectorString, locatorFromBy, locatorFromCall, selectorLike, type LocatorRef } from "./locators.ts"
7
+
8
+ /**
9
+ * What one source file of a test repository holds, read with tree-sitter: its classes and their members, top-level
10
+ * functions and tests, the calls each makes, and the locators each builds. TypeScript, JavaScript, C#, Java, Python.
11
+ */
12
+
13
+ export type Lang = "typescript" | "tsx" | "javascript" | "c_sharp" | "java" | "python"
14
+
15
+ export type Call = { name: string; receiver: string; line: number }
16
+
17
+ export type CodeLocator = LocatorRef & { line: number; code: string }
18
+
19
+ export type Member = {
20
+ name: string
21
+ kind: "method" | "property" | "field" | "function" | "test"
22
+ /** The declaration up to its body, on one line. */
23
+ signature: string
24
+ line: number
25
+ endLine: number
26
+ calls: Call[]
27
+ /** Names the member's body mentions (properties it reads, as well as calls). */
28
+ idents: string[]
29
+ /** The names it uses on their own, not as a property of something (loginButton, not testData.username). */
30
+ names?: string[]
31
+ locators: CodeLocator[]
32
+ }
33
+
34
+ export type ClassFacts = { name: string; kind: "class" | "interface"; bases: string[]; line: number; endLine: number; members: Member[] }
35
+
36
+ export type FileFacts = {
37
+ file: string
38
+ lang: Lang
39
+ classes: ClassFacts[]
40
+ /** Top-level functions and tests (test('…'), def test_…), outside any class. */
41
+ functions: Member[]
42
+ /** Variables and parameters the file declares with a type or constructs: loginPage → LoginPage. */
43
+ bindings: Record<string, string>
44
+ /** The modules it imports, as written: "../pageobjects/loginPage", "pages.login_page", "pages.LoginPage". */
45
+ imports: string[]
46
+ }
47
+
48
+ const EXT: Record<string, Lang> = { ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "tsx", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".jsx": "javascript", ".cs": "c_sharp", ".java": "java", ".py": "python" }
49
+
50
+ export function langOf(file: string): Lang | null {
51
+ if (file.endsWith(".d.ts")) return null
52
+ return EXT[path.extname(file).toLowerCase()] || null
53
+ }
54
+
55
+ const WASM: Record<Lang, string> = { typescript: "typescript", tsx: "tsx", javascript: "javascript", c_sharp: "c-sharp", java: "java", python: "python" }
56
+
57
+ let ready: Promise<void> | null = null
58
+ const languages = new Map<Lang, Promise<Language>>()
59
+ const wasmDir = () => path.dirname(createRequire(import.meta.url).resolve("@vscode/tree-sitter-wasm"))
60
+
61
+ async function parserFor(lang: Lang): Promise<Parser> {
62
+ ready ??= Parser.init({ locateFile: (f: string) => path.join(wasmDir(), f) })
63
+ await ready
64
+ if (!languages.has(lang)) languages.set(lang, Language.load(path.join(wasmDir(), `tree-sitter-${WASM[lang]}.wasm`)))
65
+ const parser = new Parser()
66
+ parser.setLanguage(await languages.get(lang)!)
67
+ return parser
68
+ }
69
+
70
+ const CLASS_TYPES = new Set(["class_declaration", "abstract_class_declaration", "interface_declaration", "record_declaration", "struct_declaration", "class_definition", "class"])
71
+ const CALL_TYPES = new Set(["call_expression", "invocation_expression", "method_invocation", "call"])
72
+ const IDENT_TYPES = new Set(["identifier", "property_identifier", "field_identifier", "type_identifier", "shorthand_property_identifier", "shorthand_property_identifier_pattern"])
73
+ const TEST_ATTRS = /^(Test|TestCase|TestCaseSource|Fact|Theory|TestMethod|DataTestMethod|ParameterizedTest|RepeatedTest|TestTemplate)$/
74
+ const TEST_CALLEE = /^(test|it|specify)(\.(only|skip|fixme|fail|slow))?$/
75
+
76
+ const line = (n: Node) => n.startPosition.row + 1
77
+ const endLine = (n: Node) => n.endPosition.row + 1
78
+ const one = (s: string, max = 200) => {
79
+ const t = s.replace(/\s+/g, " ").trim()
80
+ return t.length <= max ? t : `${t.slice(0, max - 1)}…`
81
+ }
82
+ const kids = (n: Node | null) => (n ? (n.namedChildren.filter(Boolean) as Node[]) : [])
83
+
84
+ /**
85
+ * What a call is made on: this.loginPage → loginPage, new CartPage(page) → CartPage, (await x) → x. "?" for an
86
+ * expression it cannot name (a chained call), so it is never taken for the caller's own class.
87
+ */
88
+ function receiverName(n: Node | null): string {
89
+ if (!n) return ""
90
+ let e = n
91
+ while (["parenthesized_expression", "await_expression"].includes(e.type) && kids(e).length === 1) e = kids(e)[0]
92
+ if (["object_creation_expression", "new_expression"].includes(e.type)) {
93
+ const type = e.childForFieldName("type") || e.childForFieldName("constructor")
94
+ const name = type?.text.replace(/<.*$/, "").split(".").pop() || ""
95
+ return /^[A-Za-z_]\w*$/.test(name) ? name : "?"
96
+ }
97
+ const t = e.text.replace(/^(this|self)\./, "")
98
+ return /^[\w$.]+$/.test(t) ? t : "?"
99
+ }
100
+
101
+ /** A call's callee name and the expression it is called on. */
102
+ function calleeOf(call: Node): { name: string; receiver: string } | null {
103
+ if (call.type === "method_invocation") return { name: call.childForFieldName("name")?.text || "", receiver: receiverName(call.childForFieldName("object")) }
104
+ const fn = call.childForFieldName("function")
105
+ if (!fn) return null
106
+ switch (fn.type) {
107
+ case "identifier":
108
+ return { name: fn.text, receiver: "" }
109
+ case "member_expression":
110
+ return { name: fn.childForFieldName("property")?.text || "", receiver: receiverName(fn.childForFieldName("object")) }
111
+ case "member_access_expression": {
112
+ const name = fn.childForFieldName("name")
113
+ return { name: (name?.type === "generic_name" ? kids(name)[0]?.text : name?.text) || "", receiver: receiverName(fn.childForFieldName("expression")) }
114
+ }
115
+ case "attribute":
116
+ return { name: fn.childForFieldName("attribute")?.text || "", receiver: receiverName(fn.childForFieldName("object")) }
117
+ case "generic_name":
118
+ return { name: kids(fn)[0]?.text || "", receiver: "" }
119
+ default:
120
+ return null
121
+ }
122
+ }
123
+
124
+ function argsText(call: Node): string {
125
+ const args = call.childForFieldName("arguments")
126
+ return args ? args.text.replace(/^\(|\)$/g, "") : ""
127
+ }
128
+
129
+ /** Calls, names, and locators in a subtree. */
130
+ const ACCESS_TYPES = new Set(["member_expression", "member_access_expression", "attribute", "field_access"])
131
+
132
+ /** An identifier that names a property of something: the "username" of testData.username. */
133
+ function isProperty(n: Node): boolean {
134
+ const p = n.parent
135
+ if (!p || !ACCESS_TYPES.has(p.type)) return false
136
+ const object = p.childForFieldName("object") || p.childForFieldName("expression")
137
+ return !object || object.startIndex !== n.startIndex
138
+ }
139
+
140
+ function scan(root: Node, skip?: (n: Node) => boolean): { calls: Call[]; idents: string[]; names: string[]; locators: CodeLocator[] } {
141
+ const calls: Call[] = []
142
+ const idents = new Set<string>()
143
+ const names = new Set<string>()
144
+ const locators: CodeLocator[] = []
145
+ const seen = new Set<string>()
146
+ const stack: Node[] = [root]
147
+ while (stack.length) {
148
+ const n = stack.pop()!
149
+ if (n !== root && skip?.(n)) continue
150
+ if (CALL_TYPES.has(n.type)) {
151
+ const c = calleeOf(n)
152
+ if (c?.name) {
153
+ calls.push({ name: c.name, receiver: c.receiver, line: line(n) })
154
+ const loc = locatorFromCall(c.name, c.receiver, argsText(n))
155
+ const key = loc ? `${loc.kind}|${loc.role || ""}|${loc.value}` : ""
156
+ if (loc && !seen.has(key)) {
157
+ seen.add(key)
158
+ locators.push({ ...loc, line: line(n), code: one(n.text, 160) })
159
+ }
160
+ }
161
+ } else if (IDENT_TYPES.has(n.type) && n.text.length > 1) {
162
+ idents.add(n.text)
163
+ if (n.type === "identifier" && !isProperty(n)) names.add(n.text)
164
+ }
165
+ for (const k of kids(n)) stack.push(k)
166
+ }
167
+ locators.sort((a, b) => a.line - b.line)
168
+ return { calls, idents: [...idents].slice(0, 300), names: [...names].slice(0, 300), locators }
169
+ }
170
+
171
+ /** The declaration up to its body, without the body. */
172
+ function signatureOf(n: Node): string {
173
+ const body = n.childForFieldName("body")
174
+ if (!body) return one(n.text)
175
+ return one(n.text.slice(0, body.startIndex - n.startIndex).replace(/\s*(=>|:)\s*$/, ""))
176
+ }
177
+
178
+ function memberName(n: Node): string {
179
+ const name = n.childForFieldName("name") || n.childForFieldName("property")
180
+ if (name) return name.text
181
+ // C# and Java fields: the first declarator's name.
182
+ const decl = n.descendantsOfType("variable_declarator")[0]
183
+ const declName = decl?.childForFieldName("name") || (decl ? kids(decl)[0] : null)
184
+ if (declName) return declName.text
185
+ // Python class attribute: x = …
186
+ const assign = n.type === "expression_statement" ? kids(n).find((k) => k.type === "assignment") : null
187
+ return assign?.childForFieldName("left")?.text || ""
188
+ }
189
+
190
+ /** C# [Test] attributes, Java @Test annotations. */
191
+ function hasTestMarker(n: Node): boolean {
192
+ for (const k of kids(n)) {
193
+ if (k.type === "attribute_list" && k.descendantsOfType("attribute").some((a) => TEST_ATTRS.test(a?.childForFieldName("name")?.text.split(".").pop() || ""))) return true
194
+ if (k.type === "modifiers" && k.descendantsOfType(["marker_annotation", "annotation"]).some((a) => TEST_ATTRS.test(a?.childForFieldName("name")?.text || ""))) return true
195
+ }
196
+ return false
197
+ }
198
+
199
+ const STRING_TYPES = new Set(["string", "string_literal", "verbatim_string_literal", "raw_string_literal", "template_string"])
200
+ const VALUE_PARENTS = new Set(["variable_declarator", "public_field_definition", "field_definition", "assignment", "assignment_expression", "arrow_expression_clause", "equals_value_clause", "property_declaration"])
201
+
202
+ /** A plain string literal's value ('…', "…", @"…", r"…", `…` without ${}); null for anything else. */
203
+ function stringValue(n: Node | null): string | null {
204
+ if (!n || !STRING_TYPES.has(n.type)) return null
205
+ const m = /^(?:@|[rRbBuU]{1,2})?(["'`])([\s\S]*)\1$/.exec(n.text)
206
+ if (!m || (m[1] === "`" && m[2].includes("${"))) return null
207
+ return m[2].replace(/\\(["'`\\])/g, "$1")
208
+ }
209
+
210
+ /** A selector kept as a declaration's value: const login = "#login", private String LOGIN = "[data-test=x]". */
211
+ function constantLocator(n: Node): CodeLocator | null {
212
+ const lit = n.descendantsOfType([...STRING_TYPES]).find((x) => x && VALUE_PARENTS.has(x.parent?.type || "") && x.parent !== null)
213
+ const value = stringValue(lit || null)
214
+ if (!lit || value === null || !selectorLike(value)) return null
215
+ return { ...fromSelectorString(value), line: line(lit), code: one(n.text, 160) }
216
+ }
217
+
218
+ /** Selenium page-factory annotations: Java @FindBy(id = "x"), @FindBy(how = How.ID, using = "x"); C# [FindsBy(How = How.Id, Using = "x")]. */
219
+ function annotationLocators(n: Node): CodeLocator[] {
220
+ const out: CodeLocator[] = []
221
+ const notes = kids(n).flatMap((k) =>
222
+ k.type === "modifiers" ? (k.descendantsOfType("annotation").filter(Boolean) as Node[]) : k.type === "attribute_list" ? (k.descendantsOfType("attribute").filter(Boolean) as Node[]) : [],
223
+ )
224
+ for (const a of notes) {
225
+ if (!/^(FindBy|FindsBy|AndroidFindBy|iOSXCUITFindBy)$/.test(a.childForFieldName("name")?.text.split(".").pop() || "")) continue
226
+ const pairs = new Map<string, string>()
227
+ for (const m of a.text.matchAll(/(\w+)\s*=\s*("(?:[^"\\]|\\.)*"|How\.\w+)/g)) pairs.set(m[1].toLowerCase(), m[2].replace(/^"|"$/g, "").replace(/^How\./, ""))
228
+ const how = pairs.get("how")
229
+ const using = pairs.get("using")
230
+ const [kind, value] = how && using ? [how, using] : [...pairs].find(([k]) => /^(id|name|classname|css|xpath|linktext|partiallinktext|tagname|accessibility|androiduiautomator)$/.test(k)) || ["", ""]
231
+ const ref = kind && value ? locatorFromBy(kind === "accessibility" ? "name" : kind, value) : null
232
+ if (ref) out.push({ ...ref, line: line(a), code: one(a.text, 160) })
233
+ }
234
+ return out
235
+ }
236
+
237
+ function member(n: Node, kind: Member["kind"], name = memberName(n)): Member | null {
238
+ if (!name) return null
239
+ const body = scan(n)
240
+ const constant = kind === "field" || kind === "property" ? constantLocator(n) : null
241
+ const locators = [...annotationLocators(n), ...(constant ? [constant] : []), ...body.locators]
242
+ return { name, kind, signature: signatureOf(n), line: line(n), endLine: endLine(n), ...body, locators }
243
+ }
244
+
245
+ /** this.x = …, self.x = …, X = … (C#, Java): the field an assignment sets, or "" for a local variable. */
246
+ function assignedField(left: Node | null, fields: Set<string>): string {
247
+ if (!left) return ""
248
+ const t = left.text
249
+ const own = /^(this|self)\.([A-Za-z_$][\w$]*)$/.exec(t)
250
+ if (own) return own[2]
251
+ return /^[A-Za-z_]\w*$/.test(t) && fields.has(t) ? t : ""
252
+ }
253
+
254
+ /**
255
+ * Locators that methods and constructors assign to fields (this.cart = page.locator('.cart'), self.user =
256
+ * page.get_by_label("User"), LoginButton = page.Locator("#login") in a C# constructor) belong to those fields:
257
+ * the field is what the page object's other members use, not the constructor.
258
+ */
259
+ function assignFieldLocators(body: Node | null, members: Member[]): void {
260
+ const fields = new Set(members.filter((m) => m.kind === "field" || m.kind === "property").map((m) => m.name))
261
+ for (const holder of kids(body)) {
262
+ const def = holder.type === "decorated_definition" ? holder.childForFieldName("definition") : holder
263
+ if (!def || !["method_definition", "method_declaration", "constructor_declaration", "function_definition"].includes(def.type)) continue
264
+ const owner = members.find((m) => m.line === line(def) && m.name === memberName(def))
265
+ for (const a of def.descendantsOfType(["assignment_expression", "assignment"]).filter(Boolean) as Node[]) {
266
+ const field = assignedField(a.childForFieldName("left"), fields)
267
+ const right = a.childForFieldName("right")
268
+ if (!field || !right) continue
269
+ const found = scan(right).locators.map((l) => ({ ...l, line: line(a) }))
270
+ const value = stringValue(right)
271
+ if (value !== null && selectorLike(value)) found.push({ ...fromSelectorString(value), line: line(a), code: one(a.text, 160) })
272
+ if (!found.length) continue
273
+ let target = members.find((m) => m.name === field && (m.kind === "field" || m.kind === "property"))
274
+ if (!target) {
275
+ target = { name: field, kind: "field", signature: one(a.text), line: line(a), endLine: endLine(a), calls: [], idents: [], locators: [] }
276
+ members.push(target)
277
+ fields.add(field)
278
+ }
279
+ for (const l of found) if (!target.locators.some((x) => x.kind === l.kind && x.value === l.value && x.role === l.role)) target.locators.push(l)
280
+ // The constructor or method only assigned them.
281
+ if (owner) owner.locators = owner.locators.filter((l) => !found.some((f) => f.kind === l.kind && f.value === l.value && f.role === l.role))
282
+ }
283
+ }
284
+ }
285
+
286
+ /** One member of a class body, or null for what is not one (comments, constructors, nested classes). */
287
+ function classMember(n: Node, lang: Lang): Member | null {
288
+ const def = n.type === "decorated_definition" ? n.childForFieldName("definition") : n
289
+ if (!def) return null
290
+ switch (def.type) {
291
+ case "method_definition":
292
+ case "method_declaration":
293
+ return member(def, hasTestMarker(def) ? "test" : def.text.startsWith("get ") ? "property" : "method")
294
+ case "function_definition":
295
+ return member(def, lang === "python" && /^test_/.test(memberName(def)) ? "test" : n.type === "decorated_definition" && /@property/.test(n.text) ? "property" : "method")
296
+ case "property_declaration":
297
+ return member(def, "property")
298
+ case "public_field_definition":
299
+ case "field_definition":
300
+ case "field_declaration":
301
+ return member(def, "field")
302
+ case "expression_statement":
303
+ return lang === "python" ? member(def, "field") : null
304
+ default:
305
+ return null
306
+ }
307
+ }
308
+
309
+ function basesOf(n: Node): string[] {
310
+ const holder = kids(n).find((k) => ["class_heritage", "base_list", "superclass", "super_interfaces", "argument_list"].includes(k.type)) || n.childForFieldName("superclasses")
311
+ if (!holder) return []
312
+ const names = holder.descendantsOfType(["identifier", "type_identifier"]).map((x) => x?.text || "")
313
+ return [...new Set(names.filter((x) => /^[A-Z]/.test(x)))]
314
+ }
315
+
316
+ function classFacts(n: Node, lang: Lang): ClassFacts | null {
317
+ const name = n.childForFieldName("name")?.text
318
+ if (!name) return null
319
+ const body = n.childForFieldName("body")
320
+ const members = kids(body)
321
+ .map((k) => classMember(k, lang))
322
+ .filter((m): m is Member => Boolean(m))
323
+ assignFieldLocators(body, members)
324
+ members.sort((a, b) => a.line - b.line)
325
+ return { name, kind: n.type === "interface_declaration" ? "interface" : "class", bases: basesOf(n), line: line(n), endLine: endLine(n), members }
326
+ }
327
+
328
+ /** test('title', …, async () => {…}) in TypeScript and JavaScript, with the title as its name. */
329
+ function jsTest(call: Node): Member | null {
330
+ const c = calleeOf(call)
331
+ const callee = c ? (c.receiver ? `${c.receiver}.${c.name}` : c.name) : ""
332
+ if (!TEST_CALLEE.test(callee)) return null
333
+ const args = kids(call.childForFieldName("arguments"))
334
+ const title = args[0]
335
+ const fn = args[args.length - 1]
336
+ if (!title || !/string|template_string/.test(title.type) || !fn || !/function|arrow_function/.test(fn.type)) return null
337
+ return { name: title.text.slice(1, -1), kind: "test", signature: one(`${callee}(${title.text})`), line: line(call), endLine: endLine(call), ...scan(fn) }
338
+ }
339
+
340
+ /** Top-level functions: function f(), const f = () =>, def f(). */
341
+ function topFunction(n: Node, lang: Lang): Member | null {
342
+ const def = n.type === "decorated_definition" ? n.childForFieldName("definition") : n
343
+ if (!def) return null
344
+ if (def.type === "function_declaration" || def.type === "generator_function_declaration") return member(def, "function")
345
+ if (def.type === "function_definition") return member(def, lang === "python" && /^test_/.test(memberName(def)) ? "test" : "function")
346
+ // A selector constant at module level: export const loginButton = "[data-test='login-button']", LOGIN = "#login".
347
+ if (def.type === "lexical_declaration" || def.type === "variable_declaration" || (lang === "python" && def.type === "expression_statement")) {
348
+ const decl = kids(def).find((k) => k.type === "variable_declarator" || k.type === "assignment")
349
+ const value = decl?.childForFieldName("value") || decl?.childForFieldName("right")
350
+ const text = stringValue(value || null)
351
+ const name = (decl?.childForFieldName("name") || decl?.childForFieldName("left"))?.text || ""
352
+ if (text !== null && selectorLike(text) && /^[A-Za-z_$][\w$]*$/.test(name)) {
353
+ return { name, kind: "field", signature: one(def.text), line: line(def), endLine: endLine(def), calls: [], idents: [], locators: [{ ...fromSelectorString(text), line: line(def), code: one(def.text, 160) }] }
354
+ }
355
+ }
356
+ if (def.type === "lexical_declaration" || def.type === "variable_declaration") {
357
+ const decl = kids(def).find((k) => k.type === "variable_declarator")
358
+ const value = decl?.childForFieldName("value")
359
+ if (decl && value && /function|arrow_function/.test(value.type)) {
360
+ const name = decl.childForFieldName("name")?.text || ""
361
+ const body = value.childForFieldName("body")
362
+ return name ? { name, kind: "function", signature: one(def.text.slice(0, (body?.startIndex ?? value.endIndex) - def.startIndex).replace(/\s*=>\s*$/, "")), line: line(def), endLine: endLine(def), ...scan(value) } : null
363
+ }
364
+ }
365
+ return null
366
+ }
367
+
368
+ /** loginPage → LoginPage from `x = new T(`, `T x`, `x: T`, `x = T(` (Python). Only names that look like types. */
369
+ export function bindingsOf(text: string, lang: Lang): Record<string, string> {
370
+ const out: Record<string, string> = {}
371
+ const add = (v: string, t: string) => {
372
+ if (v && t && /^[A-Z]/.test(t) && !(v in out)) out[v.replace(/^(this|self)\./, "")] = t
373
+ }
374
+ for (const m of text.matchAll(/([\w$.]+)\s*=\s*new\s+([A-Z]\w*)\s*[(<]/g)) add(m[1], m[2])
375
+ if (lang === "python") for (const m of text.matchAll(/([\w.]+)\s*=\s*([A-Z]\w*)\(/g)) add(m[1], m[2])
376
+ for (const m of text.matchAll(/([\w$]+)\s*\??:\s*([A-Z]\w*)\b/g)) add(m[1], m[2])
377
+ if (lang === "c_sharp" || lang === "java") {
378
+ for (const m of text.matchAll(/\b([A-Z]\w*)(?:<[^<>]*>)?\s+(_?[a-z]\w*)\s*(?=[;=,)])/g)) add(m[2], m[1])
379
+ }
380
+ return out
381
+ }
382
+
383
+ /** The modules a file imports, as written. */
384
+ function importsOf(text: string, lang: Lang): string[] {
385
+ const out = new Set<string>()
386
+ const patterns =
387
+ lang === "python"
388
+ ? [/^\s*from\s+([\w.]+)\s+import/gm, /^\s*import\s+([\w.]+)/gm]
389
+ : lang === "java"
390
+ ? [/^\s*import\s+(?:static\s+)?([\w.]+)\s*;/gm]
391
+ : lang === "c_sharp"
392
+ ? [/^\s*using\s+(?:static\s+)?([\w.]+)\s*;/gm]
393
+ : [/(?:from\s+|require\(\s*|import\s+)['"]([^'"]+)['"]/g]
394
+ for (const re of patterns) for (const m of text.matchAll(re)) out.add(m[1])
395
+ return [...out]
396
+ }
397
+
398
+ /** The file's facts; null when the language is not supported or it does not parse. */
399
+ export async function parseFile(file: string, text: string): Promise<FileFacts | null> {
400
+ const lang = langOf(file)
401
+ if (!lang) return null
402
+ const parser = await parserFor(lang)
403
+ const tree = parser.parse(text)
404
+ if (!tree) {
405
+ parser.delete()
406
+ return null
407
+ }
408
+ try {
409
+ const classes: ClassFacts[] = []
410
+ const functions: Member[] = []
411
+ const visit = (n: Node, inClass: boolean) => {
412
+ if (CLASS_TYPES.has(n.type)) {
413
+ const c = classFacts(n, lang)
414
+ if (c) classes.push(c)
415
+ // Nested classes are classes of their own.
416
+ for (const k of kids(n.childForFieldName("body"))) visit(k, true)
417
+ return
418
+ }
419
+ if (!inClass) {
420
+ const f = topFunction(n, lang)
421
+ if (f) functions.push(f)
422
+ if (CALL_TYPES.has(n.type)) {
423
+ const t = jsTest(n)
424
+ if (t) {
425
+ functions.push(t)
426
+ return
427
+ }
428
+ }
429
+ }
430
+ if (n.type === "function_declaration" || n.type === "function_definition" || n.type === "method_definition") return
431
+ for (const k of kids(n)) visit(k, inClass)
432
+ }
433
+ visit(tree.rootNode, false)
434
+ return { file, lang, classes, functions, bindings: bindingsOf(text, lang), imports: importsOf(text, lang) }
435
+ } finally {
436
+ tree.delete()
437
+ parser.delete()
438
+ }
439
+ }
@@ -0,0 +1,113 @@
1
+ import { readPages, type MemoryPage } from "../../memory.ts"
2
+ import type { McpTool } from "../../mcp/tools.ts"
3
+ import { analyzeImpact } from "../impact.ts"
4
+ import { buildGraph, describeMatches, describeReferences, findSymbols, membersLocating, outline, references, sameElement, testsReaching, type CodeGraph } from "./graph.ts"
5
+ import { describeLocator, fromCss, locatorFromExpression, type LocatorRef } from "./locators.ts"
6
+
7
+ /**
8
+ * The code graph as tools for Scribe (the agent runtime names them code_find, code_outline, …). Each call brings the
9
+ * graph up to date first, re-reading only the files changed since, so Scribe's own edits are in it.
10
+ */
11
+
12
+ /** The agent runtime names this server's tools `code_<tool>`. */
13
+ export const CODE_GRAPH_SERVER = "code"
14
+
15
+ /** The refs an element argument describes: a Playwright expression, or role and name, text, test id, id, label, CSS. */
16
+ export function refsFromArgs(a: Record<string, unknown>): LocatorRef[] {
17
+ const s = (k: string) => (typeof a[k] === "string" ? (a[k] as string).trim() : "")
18
+ const refs: LocatorRef[] = []
19
+ if (s("locator")) {
20
+ const r = locatorFromExpression(s("locator")) || (/^[#.[\w]/.test(s("locator")) ? fromCss(s("locator")) : null)
21
+ if (r) refs.push(r)
22
+ }
23
+ if (s("role")) refs.push({ kind: "role", role: s("role").toLowerCase(), value: s("name"), quality: "semantic" })
24
+ else if (s("name")) refs.push({ kind: "role", role: "*", value: s("name"), quality: "semantic" })
25
+ if (s("text")) refs.push({ kind: "text", value: s("text"), quality: "semantic" })
26
+ if (s("label")) refs.push({ kind: "label", value: s("label"), quality: "semantic" })
27
+ if (s("test_id")) refs.push({ kind: "testid", value: s("test_id"), quality: "semantic" })
28
+ if (s("id")) refs.push({ kind: "id", value: s("id").replace(/^#/, ""), quality: "stable" })
29
+ return refs
30
+ }
31
+
32
+ export function codeGraphTools(opts: { repo: string; memoryDir?: string; cacheDir?: string }): McpTool[] {
33
+ let pages: MemoryPage[] | null = null
34
+ const memory = () => (pages ??= opts.memoryDir ? readPages(opts.memoryDir) : [])
35
+ const graph = (): Promise<CodeGraph> => buildGraph(opts.repo, { cacheDir: opts.cacheDir })
36
+ return [
37
+ {
38
+ name: "find",
39
+ description:
40
+ "Find classes, page objects, methods, properties, and tests by name or words (\"login\", \"SignIn\", \"search results\"), or by the text a locator uses (\"Sign in\"). Returns each with its file:line and signature. It searches names, not file contents: for config keys, ids, or strings, use grep.",
41
+ inputSchema: { type: "object", properties: { query: { type: "string", description: "Words or a name" } }, required: ["query"] },
42
+ async run(args) {
43
+ const found = findSymbols(await graph(), String(args.query || ""))
44
+ return found.length ? found.join("\n") : `No class, member, or test is named "${args.query}". For text in files (config keys, ids, strings), use grep.`
45
+ },
46
+ },
47
+ {
48
+ name: "outline",
49
+ description:
50
+ "The outline of a file or a class (\"LoginPage\", \"tests/login.spec.ts\"): each member's signature and line and the locators it builds, without bodies. With \"Class.member\" (\"LoginPage.SignInAsync\"), the source of that member only. Use it instead of reading a whole file.",
51
+ inputSchema: { type: "object", properties: { target: { type: "string", description: "A file path, a class name, or Class.member" } }, required: ["target"] },
52
+ async run(args) {
53
+ return outline(await graph(), String(args.target || ""))
54
+ },
55
+ },
56
+ {
57
+ name: "element",
58
+ description:
59
+ "Which page objects already locate an element, however they locate it: give the handoff's locator (\"page.getByRole('button', { name: 'Sign in' })\"), or role and name, text, label, test_id, id. ForgeQE's page memory knows each element by all its locators (id, role and name, text, CSS), so a page object that uses #login-button is found from getByRole('button', { name: 'Login' }). Reuse what it returns instead of writing a new locator.",
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: {
63
+ locator: { type: "string", description: "A Playwright locator expression or a CSS selector" },
64
+ role: { type: "string" },
65
+ name: { type: "string", description: "Accessible name" },
66
+ text: { type: "string" },
67
+ label: { type: "string" },
68
+ test_id: { type: "string" },
69
+ id: { type: "string" },
70
+ url: { type: "string", description: "The page it is on, to look it up in page memory" },
71
+ },
72
+ },
73
+ async run(args) {
74
+ const given = refsFromArgs(args)
75
+ if (!given.length) return "Give a locator, or role and name, text, label, test_id, or id."
76
+ const same = sameElement(memory(), given, typeof args.url === "string" ? args.url : undefined)
77
+ const g = await graph()
78
+ const matches = membersLocating(g, same.refs)
79
+ const known = same.element
80
+ ? `Page memory knows this element on ${same.page?.host}${same.page?.path} as: ${same.refs.map(describeLocator).join("; ")}`
81
+ : same.ambiguous
82
+ ? `Page memory has ${same.ambiguous} different elements like this; matched on the locator as given. Give a test_id, an id, or the url of its page to tell them apart.`
83
+ : "Page memory has no record of this element; matched on the locator as given."
84
+ if (!matches.length) return `${known}\nNo page object locates it by these locators. One may still locate it another way (a class or a path page memory has not recorded): check the handoff's "in this repository" lines and the outline of that page's page object before you add one.`
85
+ return [known, "Already in the repository:", ...describeMatches(g, matches, 6).map((l) => `- ${l}`)].join("\n")
86
+ },
87
+ },
88
+ {
89
+ name: "references",
90
+ description:
91
+ "Every test, page object, and helper that calls or reads a member (\"LoginPage.SignInAsync\", or just \"SignInAsync\"), tests first. Use it before you change a shared member, to see what else depends on it.",
92
+ inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Class.member or member" } }, required: ["symbol"] },
93
+ async run(args) {
94
+ const symbol = String(args.symbol || "")
95
+ return describeReferences(symbol, references(await graph(), symbol))
96
+ },
97
+ },
98
+ {
99
+ name: "impact",
100
+ description: "The tests a change to these files reaches, through imports and the classes they name, each with the reason. Use it to choose which tests to run after changing a shared page object or helper.",
101
+ inputSchema: { type: "object", properties: { files: { type: "array", items: { type: "string" }, description: "Changed files, relative to the repository" } }, required: ["files"] },
102
+ async run(args) {
103
+ const files = Array.isArray(args.files) ? args.files.map(String) : []
104
+ const g = await graph()
105
+ const lines = testsReaching(g, files).map((t) => `- test ${t.test.cls ? `${t.test.cls.name}.` : ""}${t.test.member.name} ${t.test.file}:${t.test.member.line}: ${t.why}`)
106
+ // Imports reach test files the call graph cannot resolve (dynamic fixtures, re-exports).
107
+ const named = new Set(lines.map((l) => l.split(" ")[1]?.split(":")[0]))
108
+ for (const t of analyzeImpact(opts.repo, files).tests) if (!named.has(t.file)) lines.push(`- ${t.file}: ${t.reasons.join("; ")}`)
109
+ return lines.length ? lines.join("\n") : `No test reaches ${files.join(", ")}.`
110
+ },
111
+ },
112
+ ]
113
+ }