@capagents/forgeqe 0.0.0-stage → 1.0.1

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 +781 -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 +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,502 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { callContract, xmlEscape, type RunOptions } from "./run.ts"
5
+ import { getPath } from "./schema.ts"
6
+ import { groupCall, parseJson, type NetworkCall } from "./traffic.ts"
7
+ import type { Contract, ContractSet, JsonSchema, Scenario, ScenarioResult, ScenarioRun, ScenarioStep, StepResult } from "./types.ts"
8
+
9
+ /**
10
+ * End-to-end API scenarios: calls in order, each later call using values earlier answers gave. They come from a
11
+ * browser run (the calls the application made, with the values that flowed from one answer into a later request found
12
+ * and turned into variables), from an API description (each resource's lifecycle, browsing from a list to an item,
13
+ * the description's links, and negative cases), or from a test case or plain English (planned by the agent: plan.ts).
14
+ * ForgeQE runs them itself: no browser, no model.
15
+ */
16
+
17
+ const TEMPLATE = /\{\{\s*(\$?\w+)\s*\}\}/g
18
+ const WHOLE = /^\{\{\s*(\$?\w+)\s*\}\}$/
19
+ export const BUILTINS = ["$unique", "$uuid", "$now", "$randomInt"]
20
+
21
+ class MissingVar extends Error {}
22
+
23
+ function lookup(name: string, vars: Record<string, unknown>): unknown {
24
+ if (name in vars) return vars[name]
25
+ throw new MissingVar(`uses {{${name}}}, which no earlier step saved`)
26
+ }
27
+
28
+ /** A value with {{name}} filled in. A string that is only {{name}} becomes the value itself, keeping its type. */
29
+ export function fill(v: unknown, vars: Record<string, unknown>): unknown {
30
+ if (typeof v === "string") {
31
+ const whole = v.match(WHOLE)
32
+ if (whole) return lookup(whole[1], vars)
33
+ return v.replace(TEMPLATE, (_, n) => String(lookup(n, vars)))
34
+ }
35
+ if (Array.isArray(v)) return v.map((x) => fill(x, vars))
36
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x, vars)]))
37
+ return v
38
+ }
39
+
40
+ /** The variables a step uses. */
41
+ export function usedVars(step: Pick<ScenarioStep, "path" | "query" | "headers" | "body">): string[] {
42
+ const text = JSON.stringify([step.path, step.query || {}, step.headers || {}, step.body ?? null])
43
+ return [...new Set([...text.matchAll(TEMPLATE)].map((m) => m[1]))]
44
+ }
45
+
46
+ /** What is wrong with a scenario before it runs: variables used before any step saves them. */
47
+ export function scenarioProblems(s: Scenario): string[] {
48
+ const known = new Set(BUILTINS)
49
+ const out: string[] = []
50
+ for (const step of s.steps) {
51
+ for (const v of usedVars(step)) if (!known.has(v)) out.push(`${step.name} uses {{${v}}}, which no earlier step saves`)
52
+ for (const v of Object.keys(step.save || {})) known.add(v)
53
+ }
54
+ return out
55
+ }
56
+
57
+ function freshVars(): Record<string, unknown> {
58
+ return { $unique: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, $uuid: crypto.randomUUID(), $now: new Date().toISOString(), $randomInt: Math.floor(Math.random() * 1_000_000) }
59
+ }
60
+
61
+ function stepContract(step: ScenarioStep, vars: Record<string, unknown>, ops: Map<string, Contract>): Contract {
62
+ const op = step.operation ? ops.get(step.operation) : undefined
63
+ const pathFilled = step.path.replace(TEMPLATE, (_, n) => encodeURIComponent(String(lookup(n, vars))))
64
+ return {
65
+ id: step.name,
66
+ protocol: step.protocol,
67
+ name: step.name,
68
+ method: step.method,
69
+ path: step.path,
70
+ example: {
71
+ path: pathFilled,
72
+ ...(step.query ? { query: fill(step.query, vars) as Record<string, string> } : {}),
73
+ ...(step.headers ? { headers: fill(step.headers, vars) as Record<string, string> } : {}),
74
+ ...(step.body !== undefined ? { body: fill(step.body, vars) } : {}),
75
+ },
76
+ expect: step.expect,
77
+ ...(op?.auth ? { auth: op.auth } : {}),
78
+ writes: false,
79
+ source: { kind: "run", ref: "" },
80
+ }
81
+ }
82
+
83
+ async function runSteps(steps: ScenarioStep[], vars: Record<string, unknown>, base: string, headers: Record<string, string>, ops: Map<string, Contract>, opts: RunOptions): Promise<{ results: StepResult[]; ok: boolean; error?: string }> {
84
+ const results: StepResult[] = []
85
+ let failed = false
86
+ let error: string | undefined
87
+ for (const step of steps) {
88
+ if (failed || opts.signal?.aborted) {
89
+ results.push({ id: step.name, name: step.name, status: "skipped", checks: [], reason: opts.signal?.aborted ? "stopped" : "an earlier step failed" })
90
+ continue
91
+ }
92
+ try {
93
+ const c = stepContract(step, vars, ops)
94
+ const { result, body } = await callContract(c, base, step.noAuth ? {} : headers, opts)
95
+ for (const [name, p] of Object.entries(step.save || {})) {
96
+ const v = getPath(body, p)
97
+ if (v === undefined || v === null) result.checks.push({ name: `save ${name}`, ok: false, detail: `nothing at ${p} in the answer` })
98
+ else vars[name] = v
99
+ }
100
+ if (result.checks.some((x) => !x.ok)) result.status = "failed"
101
+ results.push({ ...result, name: step.name })
102
+ if (result.status !== "passed") failed = true
103
+ } catch (err) {
104
+ const msg = err instanceof Error ? (err.name === "TimeoutError" ? `no answer in ${opts.timeoutSeconds || 30}s` : err.message) : String(err)
105
+ results.push({ id: step.name, name: step.name, status: "error", checks: [], reason: msg })
106
+ failed = true
107
+ error = msg
108
+ }
109
+ }
110
+ return { results, ok: !failed, error }
111
+ }
112
+
113
+ /** Run each scenario: its steps in order, values passed on, and its cleanup when it fails part-way. */
114
+ export async function runScenarios(set: ContractSet, opts: RunOptions): Promise<ScenarioRun> {
115
+ const base = (opts.baseUrl || set.baseUrl || "").replace(/\/+$/, "")
116
+ const headers = opts.headers || {}
117
+ const ops = new Map(set.contracts.map((c) => [c.id, c]))
118
+ const only = (opts.only || []).map((s) => s.toLowerCase()).filter(Boolean)
119
+ const results: ScenarioResult[] = []
120
+ for (const s of set.scenarios || []) {
121
+ if (only.length && !only.some((o) => `${s.id} ${s.name} ${s.caseId || ""}`.toLowerCase().includes(o))) continue
122
+ if (opts.signal?.aborted) break
123
+ const started = Date.now()
124
+ const problems = scenarioProblems(s)
125
+ let r: ScenarioResult
126
+ if (s.writes && !opts.allowWrites) r = { id: s.id, name: s.name, status: "skipped", steps: [], reason: "changes data: run with allow_writes on a test environment", ms: 0 }
127
+ else if (problems.length) r = { id: s.id, name: s.name, status: "error", steps: [], reason: problems.join("; "), ms: 0 }
128
+ else if (!base && !s.steps.every((x) => /^https?:\/\//.test(x.path))) r = { id: s.id, name: s.name, status: "skipped", steps: [], reason: "no base URL: pass base_url or set api.base_url", ms: 0 }
129
+ else {
130
+ const vars = freshVars()
131
+ const run = await runSteps(s.steps, vars, base, headers, ops, opts)
132
+ let cleanup: StepResult[] | undefined
133
+ if (!run.ok && s.cleanup?.length) {
134
+ // Only the cleanup steps whose values exist: what the scenario did not create, it does not delete.
135
+ const ready = s.cleanup.filter((c) => usedVars(c).every((v) => v in vars))
136
+ if (ready.length) cleanup = (await runSteps(ready, vars, base, headers, ops, { ...opts, signal: undefined })).results
137
+ }
138
+ r = { id: s.id, name: s.name, status: run.error ? "error" : run.ok ? "passed" : "failed", steps: run.results, ...(cleanup ? { cleanup } : {}), ms: Date.now() - started }
139
+ }
140
+ results.push(r)
141
+ }
142
+ const counts = { passed: 0, failed: 0, skipped: 0, error: 0 }
143
+ for (const r of results) counts[r.status]++
144
+ const run: ScenarioRun = { set: set.id, baseUrl: base, at: new Date().toISOString(), results, counts, dir: opts.dir }
145
+ mkdirSync(opts.dir, { recursive: true })
146
+ writeFileSync(path.join(opts.dir, "scenario-results.json"), JSON.stringify(run, null, 2))
147
+ writeFileSync(path.join(opts.dir, "scenarios-junit.xml"), scenarioJunit(run))
148
+ return run
149
+ }
150
+
151
+ export function scenarioJunit(run: ScenarioRun): string {
152
+ const suites = run.results.map((r) => {
153
+ const cases = r.steps.length
154
+ ? r.steps.map((st) => {
155
+ const head = ` <testcase classname="${xmlEscape(r.name)}" name="${xmlEscape(st.name)}" time="${((st.ms || 0) / 1000).toFixed(3)}"`
156
+ if (st.status === "passed") return `${head}/>`
157
+ if (st.status === "skipped") return `${head}><skipped message="${xmlEscape(st.reason || "")}"/></testcase>`
158
+ const msg = st.reason || st.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail || "failed"}`).join("; ")
159
+ return `${head}><${st.status === "error" ? "error" : "failure"} message="${xmlEscape(msg)}"/></testcase>`
160
+ })
161
+ : [` <testcase classname="${xmlEscape(r.name)}" name="${xmlEscape(r.name)}">${r.status === "skipped" ? `<skipped message="${xmlEscape(r.reason || "")}"/>` : `<error message="${xmlEscape(r.reason || "")}"/>`}</testcase>`]
162
+ const failures = r.steps.filter((s) => s.status === "failed").length
163
+ return ` <testsuite name="${xmlEscape(r.name)}" tests="${cases.length}" failures="${failures}" time="${(r.ms / 1000).toFixed(3)}">\n${cases.join("\n")}\n </testsuite>`
164
+ })
165
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join("\n")}\n</testsuites>\n`
166
+ }
167
+
168
+ // -- helpers --------------------------------------------------------------------------------------
169
+
170
+ export function slug(s: string): string {
171
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "scenario"
172
+ }
173
+
174
+ function uniqueIds(scenarios: Scenario[], taken: string[] = []): Scenario[] {
175
+ const seen = new Set(taken)
176
+ for (const s of scenarios) {
177
+ let id = s.id
178
+ for (let n = 2; seen.has(id); n++) id = `${s.id}-${n}`
179
+ s.id = id
180
+ seen.add(id)
181
+ }
182
+ return scenarios
183
+ }
184
+
185
+ const camel = (s: string) => s.replace(/[-_\s]+(\w)/g, (_, c: string) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase())
186
+
187
+ const singular = (w: string) => (/ies$/.test(w) ? w.replace(/ies$/, "y") : /(ss|us)$/.test(w) ? w : w.replace(/s$/, ""))
188
+
189
+ /** The resource a path names: its last segment that is not a parameter or a version. */
190
+ function resourceOf(p: string): string {
191
+ const segs = p.split("/").filter((x) => x && !x.includes("{") && !/^(api|v\d+(\.\d+)?|rest|graphql)$/i.test(x) && !/\d/.test(x))
192
+ return segs[segs.length - 1] || "item"
193
+ }
194
+
195
+ /** Unique data for a create: an email or username that would clash on the next run gets {{$unique}} in it. */
196
+ function uniqueData(body: unknown): unknown {
197
+ if (Array.isArray(body)) return body.map(uniqueData)
198
+ if (!body || typeof body !== "object") return body
199
+ return Object.fromEntries(
200
+ Object.entries(body).map(([k, v]) => {
201
+ if (typeof v === "string" && !v.includes("{{")) {
202
+ if (/^e?-?mail(address)?$/i.test(k.replace(/_/g, "")) && v.includes("@")) return [k, v.replace("@", "+{{$unique}}@")]
203
+ if (/^(user_?name|login|handle)$/i.test(k)) return [k, `${v}-{{$unique}}`]
204
+ }
205
+ return [k, uniqueData(v)]
206
+ }),
207
+ )
208
+ }
209
+
210
+ // -- from a browser run ---------------------------------------------------------------------------
211
+
212
+ const ID_LEAF = /(^|[_-]|[a-z])(id|Id|ID|key|Key|uuid|Uuid|UUID|token|code|Code|number|Number|no|No|ref|Ref|slug)$/
213
+ const ID_SHAPED = (s: string) =>
214
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s) || /^[0-9a-f]{16,}$/i.test(s) || (/\d/.test(s) && /[A-Za-z]/.test(s) && /^[\w-]{6,}$/.test(s))
215
+
216
+ type Produced = { step: number; path: string; leaf: string; value: string; name?: string }
217
+
218
+ /** Every scalar in an answer with its path (all array elements, up to 20 each). */
219
+ function scalarsOf(v: unknown, at = "", depth = 0, out: [string, unknown][] = []): [string, unknown][] {
220
+ if (depth > 5 || out.length > 400) return out
221
+ if (Array.isArray(v)) v.slice(0, 20).forEach((x, i) => scalarsOf(x, `${at}[${i}]`, depth + 1, out))
222
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) scalarsOf(x, at ? `${at}.${k}` : k, depth + 1, out)
223
+ else if (at && (typeof v === "string" || typeof v === "number")) out.push([at, v])
224
+ return out
225
+ }
226
+
227
+ const leafOf = (p: string) => p.split(/[.[]/).pop()!.replace(/]$/, "")
228
+
229
+ function candidate(leaf: string, value: unknown): boolean {
230
+ if (typeof value === "number") return Number.isInteger(value) && value > 1 && ID_LEAF.test(leaf)
231
+ if (typeof value !== "string" || !value || value === "<redacted>" || value.length > 200) return false
232
+ return ID_SHAPED(value) || (ID_LEAF.test(leaf) && /^[\w.:-]{1,80}$/.test(value) && /\d/.test(value))
233
+ }
234
+
235
+ /** A request field may take a produced value: an id-like field, the same field name, or a value that can only be an id. */
236
+ const accepts = (leaf: string, p: Produced) => ID_LEAF.test(leaf) || leaf === p.leaf || ID_SHAPED(p.value)
237
+
238
+ /**
239
+ * One scenario from a run's calls, in the order the application made them. A value an answer gave that a later
240
+ * request sends (in its path, query, or body) becomes a variable: saved from that answer, used in that request. The
241
+ * same GET made again (polling, refreshes) is kept once; at most 40 steps.
242
+ */
243
+ export function scenarioFromRun(calls: NetworkCall[], set: ContractSet, opts: { goal?: string; runId?: string; thirdParty?: boolean } = {}): Scenario | null {
244
+ const base = set.baseUrl
245
+ const ordered = calls
246
+ .filter((c) => !c.failed && c.response?.status && (opts.thirdParty || !c.third_party))
247
+ .sort((a, b) => a.t - b.t)
248
+ const steps: ScenarioStep[] = []
249
+ const producers = new Map<string, Produced>()
250
+ const names = new Map<string, string>()
251
+ const flows: string[] = []
252
+ const seenGets = new Set<string>()
253
+ let dropped = 0
254
+ const varFor = (p: Produced): string => {
255
+ if (p.name) return p.name
256
+ const key = `${p.step}:${p.path}`
257
+ const have = names.get(key)
258
+ if (have) return (p.name = have)
259
+ let name = /^id$/i.test(p.leaf) ? `${camel(singular(resourceOf(steps[p.step].path)))}Id` : camel(p.leaf)
260
+ const taken = new Set(names.values())
261
+ for (let n = 2; taken.has(name); n++) name = `${name.replace(/\d+$/, "")}${n}`
262
+ names.set(key, name)
263
+ steps[p.step].save = { ...(steps[p.step].save || {}), [name]: p.path }
264
+ flows.push(`${name}: from step ${p.step + 1} (${steps[p.step].name})`)
265
+ return (p.name = name)
266
+ }
267
+ const replaceIn = (value: unknown, at: string): unknown => {
268
+ if (Array.isArray(value)) return value.map((x, i) => replaceIn(x, `${at}[${i}]`))
269
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, replaceIn(x, at ? `${at}.${k}` : k)]))
270
+ if (typeof value !== "string" && typeof value !== "number") return value
271
+ const p = producers.get(String(value))
272
+ return p && accepts(leafOf(at), p) ? `{{${varFor(p)}}}` : value
273
+ }
274
+ for (const c of ordered) {
275
+ let u: URL
276
+ try {
277
+ u = new URL(c.url)
278
+ } catch {
279
+ continue
280
+ }
281
+ const g = groupCall(c)
282
+ if (!g) continue
283
+ if (c.method === "GET" && seenGets.has(c.url)) continue
284
+ if (steps.length >= 40) {
285
+ dropped++
286
+ continue
287
+ }
288
+ if (c.method === "GET") seenGets.add(c.url)
289
+ const contract = set.contracts.find((x) => x.id === g.key)
290
+ const segs = u.pathname.split("/").map((seg) => {
291
+ const p = producers.get(decodeURIComponent(seg))
292
+ return p && seg ? `{{${varFor(p)}}}` : seg
293
+ })
294
+ const pathname = segs.join("/")
295
+ const query: Record<string, string> = {}
296
+ for (const [k, v] of u.searchParams) query[k] = String(replaceIn(v, k))
297
+ let body: unknown = c.request.body === undefined ? undefined : (parseJson(c.request.body) ?? c.request.body)
298
+ if (typeof body === "string") {
299
+ let text = body
300
+ for (const [value, p] of producers) if (ID_SHAPED(value) && text.includes(value)) text = text.split(value).join(`{{${varFor(p)}}}`)
301
+ body = text
302
+ } else if (body !== undefined) body = replaceIn(body, "")
303
+ if (c.method === "POST" && /regist|sign-?up|users?$|accounts?$|customers?$/i.test(u.pathname)) body = uniqueData(body)
304
+ const headers = contract?.example.headers
305
+ const status = c.response.status
306
+ const expect: Contract["expect"] = contract && contract.expect.status === status ? { ...contract.expect } : { status }
307
+ const origin = u.origin === base ? "" : u.origin
308
+ steps.push({
309
+ name: g.protocol === "rest" ? `${c.method} ${g.path}` : g.key,
310
+ ...(contract ? { operation: contract.id } : {}),
311
+ protocol: g.protocol,
312
+ method: c.method,
313
+ path: `${origin}${pathname}`,
314
+ ...(Object.keys(query).length ? { query } : {}),
315
+ ...(headers ? { headers } : {}),
316
+ ...(body !== undefined ? { body } : {}),
317
+ expect,
318
+ })
319
+ const answer = parseJson(c.response.body)
320
+ for (const [p, v] of scalarsOf(answer)) {
321
+ const leaf = leafOf(p)
322
+ // The first answer that gave a value owns it: a later answer that echoes it (a GET of the same cart) is not a new source.
323
+ if (candidate(leaf, v) && !producers.has(String(v))) producers.set(String(v), { step: steps.length - 1, path: p, leaf, value: String(v) })
324
+ }
325
+ }
326
+ if (!steps.length) return null
327
+ const notes = [...new Set(flows)]
328
+ if (dropped) notes.push(`${dropped} more calls left out: scenarios keep the first 40`)
329
+ if (steps.some((s) => JSON.stringify(s.body ?? "").includes("{{$unique}}"))) notes.push("Emails and usernames sent to sign-up endpoints get {{$unique}}, so each run creates a new one.")
330
+ return {
331
+ id: "flow",
332
+ name: opts.goal ? `API flow: ${opts.goal.slice(0, 80)}` : "API flow of the browser run",
333
+ kind: "flow",
334
+ source: { kind: "run", ref: opts.runId || "" },
335
+ steps,
336
+ writes: steps.some((s) => !["GET", "HEAD", "OPTIONS"].includes(s.method) || (s.operation ? set.contracts.find((x) => x.id === s.operation)?.writes : false)),
337
+ ...(notes.length ? { notes } : {}),
338
+ }
339
+ }
340
+
341
+ // -- from an API description ----------------------------------------------------------------------
342
+
343
+ function stepOf(c: Contract, over: Partial<ScenarioStep> = {}): ScenarioStep {
344
+ return {
345
+ name: c.id,
346
+ operation: c.id,
347
+ protocol: c.protocol,
348
+ method: c.method,
349
+ path: c.example.path,
350
+ ...(c.example.query ? { query: c.example.query } : {}),
351
+ ...(c.example.headers ? { headers: c.example.headers } : {}),
352
+ ...(c.example.body !== undefined ? { body: c.example.body } : {}),
353
+ expect: c.expect,
354
+ ...over,
355
+ }
356
+ }
357
+
358
+ /** The contract's example path with the segment of {param} in its template replaced by {{param}}. */
359
+ function pathWithVar(c: Contract, param: string, value = `{{${param}}}`): string {
360
+ const tpl = c.path.split("/")
361
+ const concrete = c.example.path.split("/")
362
+ if (tpl.length !== concrete.length) return c.path.replace(`{${param}}`, value)
363
+ return concrete.map((seg, i) => (tpl[i] === `{${param}}` ? value : seg)).join("/")
364
+ }
365
+
366
+ const props = (s: JsonSchema | undefined): Record<string, JsonSchema> => s?.properties || s?.allOf?.reduce((a, x) => ({ ...a, ...(x.properties || {}) }), {} as Record<string, JsonSchema>) || {}
367
+
368
+ /** Where an item's id is in an answer: the path parameter's own name, else id, else <resource>Id. */
369
+ function idField(schema: JsonSchema | undefined, param: string, resource: string): string | null {
370
+ const p = props(schema)
371
+ for (const k of [param, "id", `${singular(resource)}Id`, `${singular(resource)}_id`]) if (k in p) return k
372
+ return Object.keys(p).find((k) => /(^|_)id$/i.test(k)) || null
373
+ }
374
+
375
+ /** The path in a list answer to its first item's id: [0].id, or items[0].id when the list is in a field. */
376
+ function firstItemId(schema: JsonSchema | undefined, param: string, resource: string): string | null {
377
+ if (!schema) return null
378
+ if (schema.items) {
379
+ const f = idField(schema.items, param, resource)
380
+ return f ? `[0].${f}` : null
381
+ }
382
+ for (const [k, s] of Object.entries(props(schema))) {
383
+ if (s.items) {
384
+ const f = idField(s.items, param, resource)
385
+ if (f) return `${k}[0].${f}`
386
+ }
387
+ }
388
+ return null
389
+ }
390
+
391
+ const bodyPath = (expr: string): string | null => {
392
+ const m = expr.match(/^\$response\.body#\/(.*)$/)
393
+ return m ? m[1].split("/").map((s) => (/^\d+$/.test(s) ? `[${s}]` : s)).join(".").replace(/\.\[/g, "[") : null
394
+ }
395
+
396
+ /**
397
+ * Scenarios from a description's REST operations, with no model: for each resource with an item path
398
+ * (/pets and /pets/{petId}), its lifecycle (create, read, update, read, delete, read gives 404) and browsing (the list,
399
+ * then its first item); the description's links (an answer's value into the next operation); and negative cases: an
400
+ * id that does not exist gives 404, no credentials give 401 or 403, an empty body gives 400 or 422.
401
+ */
402
+ export function scenariosFromSpec(set: ContractSet, opts: { negative?: boolean } = {}): { scenarios: Scenario[]; notes: string[] } {
403
+ const rest = set.contracts.filter((c) => c.protocol === "rest")
404
+ const notes: string[] = []
405
+ if (set.contracts.length > rest.length) notes.push("Scenarios from a description cover REST operations; GraphQL, SOAP and gRPC scenarios come from a browser run, a test case, or plain English.")
406
+ const by = new Map<string, Map<string, Contract>>()
407
+ for (const c of rest) {
408
+ const m = by.get(c.path) || new Map<string, Contract>()
409
+ m.set(c.method, c)
410
+ by.set(c.path, m)
411
+ }
412
+ const out: Scenario[] = []
413
+ const covered = new Set<string>()
414
+ const src = { kind: set.source.kind, ref: set.source.ref }
415
+ for (const [item, ops] of by) {
416
+ const m = item.match(/^(.*)\/\{(\w+)\}$/)
417
+ if (!m) continue
418
+ const [, collection, param] = m
419
+ const coll = by.get(collection) || new Map<string, Contract>()
420
+ const resource = resourceOf(collection)
421
+ const label = singular(resource)
422
+ const create = coll.get("POST")
423
+ const list = coll.get("GET")
424
+ const read = ops.get("GET")
425
+ const update = ops.get("PUT") || ops.get("PATCH")
426
+ const del = ops.get("DELETE")
427
+ const at = (c: Contract) => pathWithVar(c, param)
428
+ if (create && (read || update || del)) {
429
+ const link = create.links?.map((l) => l.parameters[param]).map((e) => (e ? bodyPath(e) : null)).find(Boolean)
430
+ const idPath = link || idField(create.expect.schema, param, resource)
431
+ if (!idPath) notes.push(`${create.id}: its answer has no id field ForgeQE can tell, so the ${label} lifecycle is not made`)
432
+ else {
433
+ const steps: ScenarioStep[] = [stepOf(create, { name: `Create a ${label}`, body: uniqueData(create.example.body), save: { [param]: idPath } })]
434
+ if (read) steps.push(stepOf(read, { name: `Read the ${label}`, path: at(read) }))
435
+ if (update) {
436
+ const body = update.example.body && typeof update.example.body === "object" && !Array.isArray(update.example.body) ? Object.fromEntries(Object.entries(update.example.body as Record<string, unknown>).map(([k, v]) => [k, k === idPath || k === param ? `{{${param}}}` : v])) : update.example.body
437
+ steps.push(stepOf(update, { name: `Update the ${label}`, path: at(update), ...(body !== undefined ? { body } : {}) }))
438
+ if (read) steps.push(stepOf(read, { name: `Read the ${label} after the update`, path: at(read) }))
439
+ }
440
+ if (del) {
441
+ steps.push(stepOf(del, { name: `Delete the ${label}`, path: at(del) }))
442
+ if (read) steps.push(stepOf(read, { name: `The deleted ${label} is gone`, path: at(read), expect: { status: 404, statusIn: [404, 410] } }))
443
+ }
444
+ const made = create.needsData?.some((n) => n.startsWith("request body")) ? [`The create body is made from the schema: give real values in scenarios in contracts.json if the API rejects it.`] : []
445
+ out.push({ id: `${slug(label)}-lifecycle`, name: `${label} lifecycle: create, read${update ? ", update" : ""}${del ? ", delete" : ""}`, kind: "lifecycle", source: src, steps, ...(del ? { cleanup: [stepOf(del, { name: `Clean up: delete the ${label}`, path: at(del), expect: { status: del.expect.status, statusIn: [del.expect.status, 200, 202, 204, 404] } })] } : {}), writes: true, ...(made.length ? { notes: made } : {}) })
446
+ for (const c of [create, read, update, del]) if (c) covered.add(c.id)
447
+ }
448
+ }
449
+ if (list && read) {
450
+ const first = firstItemId(list.expect.schema, param, resource)
451
+ if (first)
452
+ out.push({
453
+ id: `${slug(label)}-browse`,
454
+ name: `Browse ${resource}: the list, then its first ${label}`,
455
+ kind: "browse",
456
+ source: src,
457
+ steps: [stepOf(list, { name: `List ${resource}`, save: { [param]: first } }), stepOf(read, { name: `Open the first ${label}`, path: at(read) })],
458
+ writes: false,
459
+ notes: [`Needs at least one ${label} in the list.`, ...(list.needsData?.length ? [`The list needs real values for ${list.needsData.join(", ")}.`] : [])],
460
+ })
461
+ }
462
+ if (opts.negative !== false) {
463
+ if (read) {
464
+ const sample = read.example.path.split("/")[read.path.split("/").indexOf(`{${param}}`)] || ""
465
+ const missing = /^[0-9a-f-]{36}$/i.test(sample) ? "00000000-0000-4000-8000-000000000000" : /^\d+$/.test(sample) || !sample ? "999999999" : `does-not-exist-{{$unique}}`
466
+ out.push({ id: `${slug(label)}-not-found`, name: `A ${label} that does not exist gives 404`, kind: "negative", source: src, steps: [stepOf(read, { name: `Read a ${label} that does not exist`, path: pathWithVar(read, param, missing), expect: { status: 404, statusIn: [404, 410] } })], writes: false })
467
+ }
468
+ const guarded = [list, read].find((c) => c?.auth && !c.needsData?.length)
469
+ if (guarded)
470
+ out.push({ id: `${slug(label)}-no-auth`, name: `${guarded.id} without credentials is refused`, kind: "negative", source: src, steps: [stepOf(guarded, { name: `${guarded.id} without credentials`, noAuth: true, expect: { status: 401, statusIn: [401, 403] } })], writes: false })
471
+ if (create?.requestRequired?.length)
472
+ out.push({ id: `${slug(label)}-invalid-body`, name: `Creating a ${label} without its required fields is refused`, kind: "negative", source: src, steps: [stepOf(create, { name: `Create a ${label} with an empty body`, body: {}, expect: { status: 400, statusIn: [400, 422] } })], writes: true, notes: [`Required: ${create.requestRequired.join(", ")}. If the API accepts the empty body, this creates a ${label}.`] })
473
+ }
474
+ }
475
+ // The description's links: an answer's value into the next operation.
476
+ const byOpId = new Map(rest.filter((c) => c.operationId).map((c) => [c.operationId!, c]))
477
+ for (const a of rest) {
478
+ for (const l of a.links || []) {
479
+ const b = byOpId.get(l.operationId)
480
+ if (!b || (covered.has(a.id) && covered.has(b.id))) continue
481
+ const save: Record<string, string> = {}
482
+ let p = b.example.path
483
+ const query = { ...(b.example.query || {}) }
484
+ for (const [param, expr] of Object.entries(l.parameters)) {
485
+ const from = bodyPath(expr)
486
+ if (!from) continue
487
+ save[param] = from
488
+ if (b.path.includes(`{${param}}`)) p = pathWithVar({ ...b, example: { ...b.example, path: p } }, param)
489
+ else query[param] = `{{${param}}}`
490
+ }
491
+ if (!Object.keys(save).length) continue
492
+ out.push({ id: `${slug(a.operationId || a.id)}-then-${slug(l.operationId)}`, name: `${a.operationId || a.id}, then ${l.operationId} with its answer`, kind: "linked", source: src, steps: [stepOf(a, { save, ...(a.writes ? { body: uniqueData(a.example.body) } : {}) }), stepOf(b, { path: p, ...(Object.keys(query).length ? { query } : {}) })], writes: a.writes || b.writes })
493
+ }
494
+ }
495
+ return { scenarios: uniqueIds(out), notes }
496
+ }
497
+
498
+ /** Add scenarios to a set, with ids unique in it. */
499
+ export function addScenarios(set: ContractSet, scenarios: Scenario[], replaceKinds: string[] = []): ContractSet {
500
+ const kept = (set.scenarios || []).filter((s) => !replaceKinds.includes(s.kind))
501
+ return { ...set, scenarios: [...kept, ...uniqueIds(scenarios, kept.map((s) => s.id))] }
502
+ }