@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,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
|
+
}
|