@capagents/forgeqe 0.0.0-stage → 1.0.0

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,187 @@
1
+ import { appendFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { createInterface } from "node:readline"
4
+
5
+ import { cardText } from "../card.ts"
6
+ import type { TestCase } from "../cases/index.ts"
7
+ import type { Detail } from "../detail.ts"
8
+ import { formatEvent } from "../print.ts"
9
+ import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
10
+ import { buildSummary, formatSummary } from "../summary.ts"
11
+ import type { ToolContext, ToolEvent } from "../mcp/tools.ts"
12
+ import { formatSuite, markdownSummary } from "../suite/report.ts"
13
+ import type { SuiteEvent, SuiteResult } from "../suite/run.ts"
14
+
15
+ /** Plain-terminal output and prompts: tool and suite events as lines, Enter to continue, numbered choices. */
16
+
17
+ export function suiteEventLine(e: SuiteEvent, parallel: number, color: boolean, detail: Detail = "brief"): string | null {
18
+ const prefix = (tc: TestCase) => (parallel > 1 ? `[${tc.id}] ` : "")
19
+ if (e.type === "case-start") return `\n━━ ${e.index + 1}/${e.total} ${e.case.id} ${e.case.title}`
20
+ if (e.type === "code-start") return `\n${prefix(e.case)}▸ Writing a test for ${e.case.id}`
21
+ if (e.type === "code-done") {
22
+ const c = e.result.code
23
+ if (!c) return null
24
+ const extra = c.existing === "reused" ? " (the existing test passes: no coding job)" : c.knownDefect ? " (marked as an expected failure until the defect is fixed)" : c.attention ? ` (moved to ${c.attention}/)` : c.existing === "updated" ? " (the existing test, updated)" : ""
25
+ return `${prefix(e.case)}${c.status === "passed" ? "✓" : "✗"} Test for ${e.case.id} ${c.status}${c.testFile ? `: ${c.testFile}` : c.error ? `: ${c.error}` : ""}${extra}`
26
+ }
27
+ if (e.type === "case-event" && e.event.type !== "done") {
28
+ const line = formatEvent(e.event, color, detail)
29
+ return line ? prefix(e.case) + line : null
30
+ }
31
+ if (e.type === "case-done") {
32
+ const mark = { passed: "✓", failed: "✗", error: "!", stopped: "■" }[e.result.status]
33
+ const f = e.result.failure
34
+ const why = f ? ` · ${FAILURE_LABEL[f.type]}: ${FAILURE_ACTION[f.type]}` : ""
35
+ return `${prefix(e.result.case)}${mark} ${e.result.case.id} ${e.result.status} in ${Math.round(e.result.ms / 1000)}s${why}`
36
+ }
37
+ return null
38
+ }
39
+
40
+ export function writeJobSummary(suite: SuiteResult) {
41
+ if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdownSummary(suite))
42
+ }
43
+
44
+ /** Prints what ForgeQE's tools do (browser runs, suites, test commands) as plain lines. */
45
+ export function toolPrinter(color: boolean, write: (line: string) => void, detail: Detail = "brief"): (e: ToolEvent) => void {
46
+ let steps = 0
47
+ let parallel = 1
48
+ const out = (line: string | null) => {
49
+ if (line) write(line)
50
+ }
51
+ return (e) => {
52
+ switch (e.type) {
53
+ case "run-start":
54
+ steps = 0
55
+ return out(`\n▸ Browser: ${e.goal}${e.url ? ` (${e.url})` : ""}`)
56
+ case "run-event":
57
+ if (e.event.type === "step") steps = Math.max(steps, e.event.step)
58
+ return e.event.type === "done" ? undefined : out(formatEvent(e.event, color, detail))
59
+ case "run-done":
60
+ if (!e.result) return out(`✗ Browser run failed: ${e.error}`)
61
+ return out(
62
+ `\n${formatSummary(
63
+ buildSummary({
64
+ status: e.result.status,
65
+ text: e.result.summary,
66
+ finalUrl: e.result.finalUrl,
67
+ ms: e.ms,
68
+ tokens: e.result.usage.input + e.result.usage.output,
69
+ steps,
70
+ files: e.files,
71
+ memory: e.result.memory,
72
+ usage: e.result.usage,
73
+ cost: e.result.cost,
74
+ outcome: e.result.outcome,
75
+ failedChecks: e.result.failedChecks,
76
+ quality: e.result.quality,
77
+ budgetExceeded: e.result.budgetExceeded,
78
+ }),
79
+ )}\n`,
80
+ )
81
+ case "suite-start":
82
+ parallel = e.parallel
83
+ return out(`\n▸ ${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
84
+ case "suite-event":
85
+ // A suite started by a tool reports its coding through code-start, agent, and code-done events instead.
86
+ if (e.event.type === "code-start" || e.event.type === "code-done") return
87
+ return out(suiteEventLine(e.event, parallel, color, detail))
88
+ case "suite-done":
89
+ writeJobSummary(e.suite)
90
+ return out(`\n${formatSuite(e.suite)}\n`)
91
+ case "info":
92
+ return out(`${e.ok === undefined ? "▸" : e.ok ? "✓" : "✗"} ${e.text}`)
93
+ case "agent":
94
+ return e.event.kind === "usage" ? undefined : out(formatEvent(e.event, color, detail))
95
+ case "code-start":
96
+ return out(`\n⇢ Handing over to the coding agent: ${path.relative(process.cwd(), e.runDir) || e.runDir} → a test in ${e.repo}`)
97
+ case "code-done":
98
+ return e.error ? out(`✗ Coding agent: ${e.error}`) : undefined
99
+ case "card":
100
+ return out(`\n${cardText(e.card)}\n`)
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Enter on the terminal continues, Ctrl+C (the signal) cancels; none when stdin is not a terminal. */
106
+ export function terminalWait(signal: AbortSignal): ToolContext["waitForUser"] {
107
+ if (!process.stdin.isTTY) return undefined
108
+ return (prompt) =>
109
+ new Promise((resolve) => {
110
+ const keys = createInterface({ input: process.stdin })
111
+ const done = (ok: boolean) => {
112
+ keys.close()
113
+ resolve(ok)
114
+ }
115
+ console.log(`\n${prompt} Press Enter to continue (Ctrl+C cancels).`)
116
+ keys.once("line", () => done(true))
117
+ signal.addEventListener("abort", () => done(false), { once: true })
118
+ })
119
+ }
120
+
121
+ /** Numbered options on the terminal; Enter picks the first, Ctrl+C (the signal) cancels. None when stdin is not a terminal. */
122
+ export function terminalAsk(signal: AbortSignal): ToolContext["ask"] {
123
+ if (!process.stdin.isTTY) return undefined
124
+ return (q) =>
125
+ new Promise((resolve) => {
126
+ const keys = createInterface({ input: process.stdin, output: process.stdout })
127
+ const done = (id: string | null) => {
128
+ keys.close()
129
+ resolve(id)
130
+ }
131
+ console.log(["", q.title, ...(q.detail ? [q.detail] : []), ...(q.lines || []), ...q.options.map((o, i) => ` ${i + 1}. ${o.label}`)].join("\n"))
132
+ const askOnce = () =>
133
+ keys.question(`Choose 1-${q.options.length} (Enter: 1): `, (line) => {
134
+ const t = line.trim()
135
+ const n = t ? Number(t) : 1
136
+ if (Number.isInteger(n) && n >= 1 && n <= q.options.length) return done(q.options[n - 1].id)
137
+ askOnce()
138
+ })
139
+ askOnce()
140
+ signal.addEventListener("abort", () => done(null), { once: true })
141
+ })
142
+ }
143
+
144
+ /**
145
+ * One line from the terminal; a secret is read with echo off, so a pasted token never shows. Enter on an empty line
146
+ * takes `initial`; Ctrl+C or the signal cancels (null). Null straight away when stdin is not a terminal.
147
+ */
148
+ export function terminalInput(signal?: AbortSignal): (q: { label: string; secret?: boolean; initial?: string }) => Promise<string | null> {
149
+ return (q) =>
150
+ new Promise((resolve) => {
151
+ if (!process.stdin.isTTY) return resolve(null)
152
+ const label = `${q.label}${q.initial ? ` [${q.initial}]` : ""}: `
153
+ if (!q.secret) {
154
+ const keys = createInterface({ input: process.stdin, output: process.stdout })
155
+ const done = (v: string | null) => {
156
+ keys.close()
157
+ resolve(v)
158
+ }
159
+ keys.question(label, (line) => done(line.trim() || q.initial || ""))
160
+ keys.on("SIGINT", () => done(null))
161
+ signal?.addEventListener("abort", () => done(null), { once: true })
162
+ return
163
+ }
164
+ const stdin = process.stdin
165
+ let value = ""
166
+ const done = (v: string | null) => {
167
+ stdin.off("data", onData)
168
+ stdin.setRawMode(false)
169
+ stdin.pause()
170
+ process.stdout.write("\n")
171
+ resolve(v)
172
+ }
173
+ const onData = (chunk: Buffer | string) => {
174
+ for (const ch of chunk.toString()) {
175
+ if (ch === "\r" || ch === "\n") return done(value.trim() || q.initial || "")
176
+ if (ch === "\u0003") return done(null)
177
+ if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1)
178
+ else if (ch >= " ") value += ch
179
+ }
180
+ }
181
+ process.stdout.write(`${label}(hidden) `)
182
+ stdin.setRawMode(true)
183
+ stdin.resume()
184
+ stdin.on("data", onData)
185
+ signal?.addEventListener("abort", () => done(null), { once: true })
186
+ })
187
+ }
@@ -0,0 +1,337 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readFileSync, statSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { AppConfig } from "../config.ts"
6
+ import { adoRequirements, type Requirement } from "../cases/context.ts"
7
+ import { adoSettings } from "../cases/ado.ts"
8
+ import { http } from "../cases/http.ts"
9
+ import { readPages, type MemoryPage } from "../memory.ts"
10
+ import { importsOf, isTestFile } from "./impact.ts"
11
+ import { adoBase, fetchPr, fetchPrBranches, gh, gitRemotes, parsePrRef, type PrInfo, type PrRef } from "./prreview.ts"
12
+ import { readMappings } from "./traceability.ts"
13
+
14
+ /**
15
+ * Which parts of the product a pull request touches, when the product's code and the automated tests may live in
16
+ * different repositories. The pull request is read from a local clone of the product repository when there is one
17
+ * (its diff, and its imports to follow a shared component to the pages that use it), otherwise through the GitHub or
18
+ * Azure DevOps API (the changed files and their contents). Its changes are then tied to the product by:
19
+ *
20
+ * - the work items it is linked to, up through feature and epic, with their Area Paths;
21
+ * - the test ids, ids, labels, and texts its changed UI code carries, matched against page memory (every element
22
+ * ForgeQE met on each page of the application), and the routes it changes, matched against the pages' paths;
23
+ * - in the automation repository (the working directory): the tests that reach those pages, the test cases they
24
+ * automate, and the requirements those test cases test.
25
+ *
26
+ * ForgeQE's agent turns the evidence into the impact report: the areas, the risk, what to test, and the gaps.
27
+ */
28
+
29
+ export type ChangedSource = { path: string; added: string }
30
+
31
+ export type ImpactedPage = { page: string; title?: string; evidence: string[]; files: string[] }
32
+ export type ImpactedTest = { file: string; reasons: string[]; caseId?: string }
33
+ export type ManualCase = { id: string; title?: string; url?: string; requirement: string }
34
+
35
+ export type AreaImpact = {
36
+ pr: PrInfo
37
+ /** Where the changes were read: a clone's diff, or the API. */
38
+ via: "clone" | "api"
39
+ files: string[]
40
+ requirements: Requirement[]
41
+ pages: ImpactedPage[]
42
+ routes: string[]
43
+ tests: ImpactedTest[]
44
+ manual: ManualCase[]
45
+ /** Changed UI files nothing above ties to a page. */
46
+ unmapped: string[]
47
+ notes: string[]
48
+ }
49
+
50
+ const UI = /\.(tsx|jsx|vue|svelte|html?|cshtml|razor|component\.ts|aspx|erb|hbs|ejs|jinja2?|twig|xaml)$/i
51
+ const CODE = /\.([cm]?[jt]sx?|vue|svelte|py|cs|cshtml|razor|java|kt|go|rb|php|html?)$/i
52
+ const MAX_FILES = 60
53
+ const MAX_BYTES = 200_000
54
+
55
+ const git = (repo: string, args: string[]) => spawnSync("git", ["-c", "core.quotepath=false", ...args], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
56
+
57
+ /** Added lines per file from a unified diff. */
58
+ export function addedByFile(diff: string): ChangedSource[] {
59
+ const out = new Map<string, string[]>()
60
+ let file = ""
61
+ for (const line of diff.split("\n")) {
62
+ if (line.startsWith("+++ ")) {
63
+ file = line === "+++ /dev/null" ? "" : line.slice(4).replace(/^b\//, "")
64
+ if (file && !out.has(file)) out.set(file, [])
65
+ } else if (file && line.startsWith("+") && !line.startsWith("+++")) out.get(file)!.push(line.slice(1))
66
+ }
67
+ return [...out].map(([p, lines]) => ({ path: p, added: lines.join("\n") }))
68
+ }
69
+
70
+ // ---- What the changed code names ----
71
+
72
+ const ATTR = /\b(data-testid|data-test-id|data-test|data-qa|data-cy|id|name|aria-label|placeholder|title|alt|formControlName|testID)\s*=\s*\{?\s*["'`]([^"'`{}$\n]{2,60})["'`]/g
73
+ const TEST_ID_CALL = /\bgetBy(?:TestId|Label|Placeholder|Text|Title|AltText)\(\s*["'`]([^"'`\n]{2,60})["'`]/g
74
+ const JSX_TEXT = />\s*([A-Z][^<>{}\n]{2,40}?)\s*</g
75
+ const ROUTE_PATTERNS = [
76
+ /\b(?:path|route|to|href|url)\s*[:=]\s*["'`](\/[\w\-/:.{}[\]*]*)["'`]/g,
77
+ /<Route[^>]*\bpath=\{?["'`](\/?[\w\-/:.{}[\]*]*)["'`]/g,
78
+ /@(?:app|router|bp|blueprint)\.(?:route|get|post|put|delete)\(\s*["'`](\/[^"'`]*)["'`]/g,
79
+ /\b(?:router|app)\.(?:get|post|put|patch|delete|use)\(\s*["'`](\/[^"'`]*)["'`]/g,
80
+ /\[(?:Http(?:Get|Post|Put|Delete))?Route\(\s*"([^"]+)"\s*\)\]/g,
81
+ /@(?:Get|Post|Put|Delete|Request)Mapping\(\s*(?:value\s*=\s*)?"([^"]+)"/g,
82
+ ]
83
+
84
+ export function namesInCode(text: string): { selectors: string[]; texts: string[]; routes: string[] } {
85
+ const selectors = new Set<string>()
86
+ const texts = new Set<string>()
87
+ const routes = new Set<string>()
88
+ for (const m of text.matchAll(ATTR)) selectors.add(m[2].trim())
89
+ for (const m of text.matchAll(TEST_ID_CALL)) selectors.add(m[1].trim())
90
+ for (const m of text.matchAll(JSX_TEXT)) {
91
+ const t = m[1].trim()
92
+ if (t.split(/\s+/).length <= 6 && !/[=;()]/.test(t)) texts.add(t)
93
+ }
94
+ for (const r of ROUTE_PATTERNS) for (const m of text.matchAll(r)) if (m[1] && m[1] !== "/") routes.add(m[1].startsWith("/") ? m[1] : `/${m[1]}`)
95
+ return { selectors: [...selectors], texts: [...texts], routes: [...routes] }
96
+ }
97
+
98
+ /** The route a file stands for under file-based routing (Next.js pages/ and app/, SvelteKit, Nuxt). */
99
+ export function fileRoute(file: string): string | null {
100
+ const m = /(?:^|\/)(?:src\/)?(pages|app|routes)\/(.+?)\.(tsx|jsx|ts|js|vue|svelte)$/.exec(file)
101
+ if (!m) return null
102
+ let route = m[2].replace(/\/(page|index|\+page|_index)$/, "").replace(/^(page|index|\+page)$/, "")
103
+ if (m[1] === "app" && !/(^|\/)page$/.test(m[2])) return null
104
+ route = route.replace(/\([^)]*\)\/?/g, "").replace(/\[\.\.\.(\w+)\]/g, "*").replace(/\[(\w+)\]/g, ":$1")
105
+ if (/^_|\/_/.test(route) || /^api(\/|$)/.test(route)) return null
106
+ return `/${route}`.replace(/\/+$/, "") || "/"
107
+ }
108
+
109
+ // ---- Matching against page memory ----
110
+
111
+ const lower = (s: string | null | undefined) => (s || "").trim().toLowerCase()
112
+
113
+ /** The pages whose elements carry a changed selector or text, or whose path is a changed route. */
114
+ export function pagesFor(pages: MemoryPage[], found: { file: string; selectors: string[]; texts: string[]; routes: string[] }[]): ImpactedPage[] {
115
+ const out = new Map<string, ImpactedPage>()
116
+ const hit = (p: MemoryPage, why: string, file: string) => {
117
+ const key = `${p.host}${p.path}`
118
+ const e = out.get(key) || { page: key, ...(p.title ? { title: p.title } : {}), evidence: [], files: [] }
119
+ if (!e.evidence.includes(why)) e.evidence.push(why)
120
+ if (!e.files.includes(file)) e.files.push(file)
121
+ out.set(key, e)
122
+ }
123
+ const routeRe = (r: string) => new RegExp(`^${r.replace(/[.+?^${}()|\\]/g, "\\$&").replace(/:\w+|\\\{\w+\\\}|\[\w+\]/g, "[^/]+").replace(/\*/g, ".*")}/?$`, "i")
124
+ for (const f of found) {
125
+ for (const p of pages) {
126
+ const elements = Object.values(p.elements || {})
127
+ for (const sel of f.selectors) {
128
+ const s = lower(sel)
129
+ const el = elements.find((e) => Object.values(e.attributes || {}).some((v) => lower(v) === s))
130
+ if (el) hit(p, `element ${JSON.stringify(sel)} (${el.tag}${el.name ? ` "${el.name}"` : ""})`, f.file)
131
+ }
132
+ for (const t of f.texts) {
133
+ const s = lower(t)
134
+ if (s.length >= 3 && elements.some((e) => lower(e.name) === s || lower(e.text) === s)) hit(p, `text ${JSON.stringify(t)}`, f.file)
135
+ }
136
+ for (const r of f.routes) if (routeRe(r).test(p.path) || p.path.toLowerCase().endsWith(r.toLowerCase())) hit(p, `route ${r}`, f.file)
137
+ }
138
+ }
139
+ return [...out.values()].sort((a, b) => b.evidence.length - a.evidence.length)
140
+ }
141
+
142
+ // ---- Reading the pull request ----
143
+
144
+ async function adoChanges(cfg: AppConfig, ref: PrRef, pr: PrInfo): Promise<ChangedSource[]> {
145
+ const { auth } = adoSettings(cfg.sources)
146
+ const base = adoBase(ref)
147
+ const { data: its } = await http<{ value?: { id: number }[] }>(`${base}/pullRequests/${ref.number}/iterations?api-version=7.1`, { auth })
148
+ const last = its?.value?.at(-1)?.id
149
+ if (!last) return []
150
+ const { data: ch } = await http<{ changeEntries?: { item?: { path?: string; isFolder?: boolean }; changeType?: string }[] }>(`${base}/pullRequests/${ref.number}/iterations/${last}/changes?api-version=7.1&$top=500`, { auth })
151
+ const paths = (ch?.changeEntries || []).filter((c) => c.item?.path && !c.item.isFolder && !/delete/i.test(c.changeType || "")).map((c) => c.item!.path!.replace(/^\//, ""))
152
+ const out: ChangedSource[] = []
153
+ for (const p of paths) {
154
+ if (!CODE.test(p) || out.length >= MAX_FILES) {
155
+ out.push({ path: p, added: "" })
156
+ continue
157
+ }
158
+ try {
159
+ const q = new URLSearchParams({ path: `/${p}`, "versionDescriptor.version": pr.headSha, "versionDescriptor.versionType": "commit", includeContent: "true", "api-version": "7.1" })
160
+ const { data } = await http<{ content?: string } | string>(`${base}/items?${q}`, { auth, headers: { Accept: "application/json" } })
161
+ const content = typeof data === "string" ? data : data?.content || ""
162
+ out.push({ path: p, added: content.slice(0, MAX_BYTES) })
163
+ } catch {
164
+ out.push({ path: p, added: "" })
165
+ }
166
+ }
167
+ return out
168
+ }
169
+
170
+ /** The pull request and its changed files with their added code: from a matching clone, else the API. */
171
+ export async function prSources(cfg: AppConfig, input: string, opts: { productRepo?: string; cwd: string }): Promise<{ pr: PrInfo; files: ChangedSource[]; via: "clone" | "api"; clone?: string; notes: string[] }> {
172
+ const notes: string[] = []
173
+ const candidates = [opts.productRepo, opts.cwd].filter((d): d is string => Boolean(d)).map((d) => path.resolve(opts.cwd, d))
174
+ let ref: PrRef | null = null
175
+ for (const dir of candidates) {
176
+ const top = git(dir, ["rev-parse", "--show-toplevel"])
177
+ if (top.status !== 0) continue
178
+ const root = top.stdout.trim()
179
+ const remotes = await gitRemotes(root)
180
+ try {
181
+ ref = parsePrRef(input, remotes)
182
+ } catch {
183
+ continue
184
+ }
185
+ const remote = remotes.find((r) => r.ref.platform === ref!.platform && (r.ref.repo || "").toLowerCase() === (ref!.repo || "").toLowerCase())
186
+ if (!remote) continue
187
+ const pr = await fetchPr(ref, cfg, root)
188
+ const { base, dropRefs } = await fetchPrBranches(root, remote.name, pr)
189
+ try {
190
+ const diff = git(root, ["diff", "--no-color", "--no-ext-diff", "-U0", "-M", "--src-prefix=a/", "--dst-prefix=b/", base, pr.headSha]).stdout
191
+ return { pr, files: addedByFile(diff), via: "clone", clone: root, notes }
192
+ } finally {
193
+ await dropRefs()
194
+ }
195
+ }
196
+ if (!ref) ref = parsePrRef(input, [])
197
+ const pr = await fetchPr(ref, cfg, opts.cwd)
198
+ notes.push("No local clone of the product repository: its changes were read through the API, without its imports, so a shared component is tied to pages only by what it names.")
199
+ if (ref.platform === "github") return { pr, files: addedByFile(await gh(["pr", "diff", String(ref.number), "--repo", ref.repo!], opts.cwd)), via: "api", notes }
200
+ return { pr, files: await adoChanges(cfg, ref, pr), via: "api", notes }
201
+ }
202
+
203
+ // ---- Following a shared component to its pages (with a clone) ----
204
+
205
+ const PAGE_FILE = /(^|\/)(pages|app|routes|views|screens|containers|features)\//i
206
+
207
+ function pagesThatImport(clone: string, changed: string[]): Map<string, string> {
208
+ const files = git(clone, ["ls-files"]).stdout.split("\n").filter((f) => f && /\.[cm]?[jt]sx?$|\.vue$|\.svelte$/.test(f) && !/node_modules\//.test(f)).slice(0, 8000)
209
+ const set = new Set(files)
210
+ const importers = new Map<string, string[]>()
211
+ for (const f of files) {
212
+ let text = ""
213
+ try {
214
+ const full = path.join(clone, f)
215
+ if (statSync(full).size <= MAX_BYTES) text = readFileSync(full, "utf8")
216
+ } catch {}
217
+ for (const dep of importsOf(f, text, set)) importers.set(dep, [...(importers.get(dep) || []), f])
218
+ }
219
+ // A page or view file that (through other modules) imports a changed file: page file → the changed file it reaches.
220
+ const reached = new Map<string, string>()
221
+ for (const start of changed) {
222
+ const queue = [start]
223
+ const seen = new Set([start])
224
+ while (queue.length && seen.size < 400) {
225
+ const f = queue.shift()!
226
+ for (const imp of importers.get(f) || []) {
227
+ if (seen.has(imp)) continue
228
+ seen.add(imp)
229
+ if (PAGE_FILE.test(imp) && !isTestFile(imp)) reached.set(imp, start)
230
+ queue.push(imp)
231
+ }
232
+ }
233
+ }
234
+ return reached
235
+ }
236
+
237
+ // ---- The whole analysis ----
238
+
239
+ /** Tests in the automation repository that visit a page: by its path, its title, or a changed selector they use. */
240
+ function testsForPages(repo: string, pages: ImpactedPage[], selectors: string[]): ImpactedTest[] {
241
+ const out = new Map<string, ImpactedTest>()
242
+ const files = git(repo, ["ls-files", "--cached", "--others", "--exclude-standard"]).stdout.split("\n").filter((f) => f && /\.([cm]?[jt]sx?|py|cs|java|kt)$/.test(f) && !/(^|\/)(node_modules|needs-attention)\//.test(f)).slice(0, 6000)
243
+ const texts = new Map<string, string>()
244
+ for (const f of files) {
245
+ try {
246
+ const full = path.join(repo, f)
247
+ if (statSync(full).size <= MAX_BYTES) texts.set(f, readFileSync(full, "utf8"))
248
+ } catch {}
249
+ }
250
+ const add = (file: string, why: string) => {
251
+ const t = out.get(file) || { file, reasons: [] }
252
+ if (!t.reasons.includes(why)) t.reasons.push(why)
253
+ out.set(file, t)
254
+ }
255
+ // Page objects and helpers that mention the page, then the tests that use them (by name).
256
+ const mentions = new Map<string, string>()
257
+ for (const p of pages) {
258
+ const pathPart = p.page.slice(p.page.indexOf("/"))
259
+ for (const [f, text] of texts) {
260
+ if ((pathPart.length > 1 && text.includes(pathPart)) || (p.title && p.title.length > 4 && text.includes(p.title))) mentions.set(f, p.page)
261
+ }
262
+ }
263
+ for (const s of selectors) for (const [f, text] of texts) if (s.length > 3 && (text.includes(`'${s}'`) || text.includes(`"${s}"`))) mentions.set(f, mentions.get(f) || `the element ${s}`)
264
+ for (const [f, what] of mentions) {
265
+ if (isTestFile(f)) add(f, `visits ${what}`)
266
+ else {
267
+ const stem = path.basename(f).replace(/\.[^.]+$/, "")
268
+ for (const [t, text] of texts) if (isTestFile(t) && stem.length >= 4 && text.includes(stem)) add(t, `uses ${stem}, which covers ${what}`)
269
+ }
270
+ }
271
+ const map = readMappings(repo).cases
272
+ for (const [id, m] of Object.entries(map)) if (m.testFile && out.has(m.testFile)) out.get(m.testFile)!.caseId = id
273
+ return [...out.values()].sort((a, b) => a.file.localeCompare(b.file))
274
+ }
275
+
276
+ export async function analyzeAreas(cfg: AppConfig, input: string, opts: { productRepo?: string; cwd: string }): Promise<AreaImpact> {
277
+ const src = await prSources(cfg, input, opts)
278
+ const notes = [...src.notes]
279
+ const found = src.files.map((f) => {
280
+ const names = namesInCode(f.added)
281
+ const route = fileRoute(f.path)
282
+ return { file: f.path, ...names, routes: [...names.routes, ...(route ? [route] : [])] }
283
+ })
284
+ // With a clone: a changed shared component counts for each page that imports it.
285
+ if (src.clone) {
286
+ const changedCode = src.files.map((f) => f.path).filter((p) => /\.[cm]?[jt]sx?$|\.vue$|\.svelte$/.test(p))
287
+ for (const [pageFile, via] of pagesThatImport(src.clone, changedCode)) {
288
+ let text = ""
289
+ try {
290
+ text = readFileSync(path.join(src.clone, pageFile), "utf8").slice(0, MAX_BYTES)
291
+ } catch {}
292
+ const names = namesInCode(text)
293
+ const route = fileRoute(pageFile)
294
+ found.push({ file: `${via} (through ${pageFile})`, selectors: names.selectors, texts: [], routes: [...names.routes, ...(route ? [route] : [])] })
295
+ }
296
+ }
297
+ const memory = cfg.memory?.dir && existsSync(cfg.memory.dir) ? readPages(cfg.memory.dir) : []
298
+ if (!memory.length) notes.push("Page memory is empty: run the application's flows with ForgeQE first, so changes can be tied to its pages.")
299
+ const pages = pagesFor(memory, found)
300
+ const routes = [...new Set(found.flatMap((f) => f.routes))]
301
+ const selectors = [...new Set(found.flatMap((f) => f.selectors))]
302
+ const tests = testsForPages(opts.cwd, pages, selectors)
303
+
304
+ // Requirements: the work items the pull request is linked to, and those the impacted tests' test cases test.
305
+ const ctx = { requirements: new Map<string, Requirement>() }
306
+ if (cfg.sources.ado) {
307
+ try {
308
+ if (src.pr.workItemIds?.length) await adoRequirements(cfg, src.pr.workItemIds, ctx)
309
+ const caseIds = tests.map((t) => t.caseId).filter((c): c is string => Boolean(c?.startsWith("ADO-"))).map((c) => Number(c.slice(4)))
310
+ for (const id of caseIds.slice(0, 15)) {
311
+ const { data } = await http<{ relations?: { rel: string; url: string }[] }>(`${adoSettings(cfg.sources).org}/_apis/wit/workitems/${id}?$expand=relations&api-version=7.1`, { auth: adoSettings(cfg.sources).auth })
312
+ const tested = (data?.relations || []).filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Reverse").map((r) => Number(r.url.split("/").pop()))
313
+ if (tested.length) await adoRequirements(cfg, tested, ctx)
314
+ }
315
+ } catch (err) {
316
+ notes.push(`Some work items could not be read: ${(err as Error).message.slice(0, 200)}`)
317
+ }
318
+ }
319
+ const requirements = [...ctx.requirements.values()]
320
+ // Manual test cases: test cases of the impacted requirements that no automated test covers yet.
321
+ const automated = new Set(Object.entries(readMappings(opts.cwd).cases).filter(([, m]) => m.status !== "needs_attention").map(([id]) => id))
322
+ const manual: ManualCase[] = []
323
+ for (const r of requirements) for (const tc of r.testedBy || []) if (!automated.has(tc) && !manual.some((m) => m.id === tc)) manual.push({ id: tc, requirement: r.id })
324
+ if (manual.length && cfg.sources.ado) {
325
+ try {
326
+ const { org, auth } = adoSettings(cfg.sources)
327
+ const { data } = await http<{ value?: { id: number; fields: Record<string, string> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: manual.slice(0, 200).map((m) => Number(m.id.slice(4))), fields: ["System.Title", "System.TeamProject"], errorPolicy: "omit" } })
328
+ for (const w of data?.value || []) {
329
+ const m = manual.find((x) => x.id === `ADO-${w.id}`)
330
+ if (m) Object.assign(m, { title: w.fields["System.Title"], url: `${org}/${encodeURIComponent(w.fields["System.TeamProject"] || "")}/_workitems/edit/${w.id}` })
331
+ }
332
+ } catch {}
333
+ }
334
+ const tied = new Set(pages.flatMap((p) => p.files.map((f) => f.split(" (through")[0])))
335
+ const unmapped = src.files.map((f) => f.path).filter((p) => UI.test(p) && !tied.has(p))
336
+ return { pr: src.pr, via: src.via, files: src.files.map((f) => f.path), requirements, pages, routes, tests, manual, unmapped, notes }
337
+ }