@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,280 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig, LoginConfig } from "../config.ts"
5
+ import type { Card } from "../card.ts"
6
+ import { fileBugs } from "../cases/bugs.ts"
7
+ import { classifyFailure, FAILURE_LABEL, type Failure } from "../cases/failure.ts"
8
+ import { goalFor } from "../cases/goal.ts"
9
+ import type { CaseResult, TestCase, WriteBack } from "../cases/types.ts"
10
+ import { browserErrors, problemLine } from "../report/data.ts"
11
+ import { executeRun } from "../run.ts"
12
+ import type { AgentEvent, RunResult, Usage } from "../types.ts"
13
+ import { addUsage, costOf } from "../usage.ts"
14
+ import type { Charter } from "./charters.ts"
15
+ import type { Story } from "./story.ts"
16
+
17
+ /**
18
+ * Exploratory testing of a story: each charter runs in a real browser (as its role when it names one), and each
19
+ * expected result is judged on the live page. A charter that finds a functional defect runs again: a defect is
20
+ * confirmed only when the second run fails the same way. What does not reproduce, the browser errors seen on the way,
21
+ * and what the browser agent noticed beyond the steps are observations, not defects. Charters that ran clean or
22
+ * found a confirmed defect become proposed test cases; bugs are filed for confirmed defects only.
23
+ */
24
+
25
+ export type CharterStatus = "passed" | "defect" | "not_reproduced" | "could_not_explore" | "stopped"
26
+
27
+ export type CharterResult = {
28
+ charter: Charter
29
+ status: CharterStatus
30
+ summary: string
31
+ runDir: string
32
+ reproRunDir?: string
33
+ failure?: Failure
34
+ /** What the browser agent noticed beyond the steps, and the browser errors on the way. */
35
+ observations: string[]
36
+ finalUrl?: string
37
+ usage: Usage
38
+ /** The run as a test case result, for bugs and reports. */
39
+ caseResult: CaseResult
40
+ }
41
+
42
+ export type ProposedCase = { title: string; steps: { action: string; expected: string }[]; preconditions?: string[]; description?: string; from: string; kind: "regression" | "defect" }
43
+
44
+ export type Exploration = {
45
+ dir: string
46
+ story: Story
47
+ results: CharterResult[]
48
+ proposed: ProposedCase[]
49
+ bugs: WriteBack[]
50
+ usage: { plan: Usage; browser: Usage }
51
+ report: string
52
+ }
53
+
54
+ export const NOTICED = "Also noticed:"
55
+
56
+ /** The goal for one charter: the charter as a test case, plus the request to report what else looks wrong. */
57
+ export function charterCase(story: Story, c: Charter): TestCase {
58
+ return {
59
+ id: `EXP-${story.id === "story" ? "" : `${story.id}-`}${c.id}`,
60
+ title: `${story.id === "story" ? story.title : story.id} exploratory: ${c.title}`,
61
+ source: "text",
62
+ description: [`Exploratory charter (${c.category.replace("_", " ")}): ${c.mission}`, c.criterion ? `Acceptance criterion: ${c.criterion}` : "", story.url ? `Story: ${story.url}` : ""].filter(Boolean).join("\n"),
63
+ ...(c.role ? { preconditions: [`Signed in as the ${c.role} login`] } : {}),
64
+ steps: c.steps,
65
+ }
66
+ }
67
+
68
+ export function charterGoal(story: Story, c: Charter): string {
69
+ return [
70
+ goalFor(charterCase(story, c)),
71
+ "",
72
+ `This is exploratory testing. Do the steps as written, with the data given, even when it is invalid: that is the point. While you do them, watch for anything else that looks wrong (an error message or page, a broken layout, wrong or lost data, a missing validation, a slow page) and list it at the end of your summary after "${NOTICED}", one item per line, or "${NOTICED} nothing".`,
73
+ c.role ? `Sign in as the ${c.role} login before the first step.` : "",
74
+ ]
75
+ .filter(Boolean)
76
+ .join("\n")
77
+ }
78
+
79
+ /** The browser agent's "Also noticed:" lines. */
80
+ export function noticed(summary: string): string[] {
81
+ const at = summary.lastIndexOf(NOTICED)
82
+ if (at < 0) return []
83
+ return summary
84
+ .slice(at + NOTICED.length)
85
+ .split("\n")
86
+ .map((l) => l.replace(/^\s*[-*•\d.)]+\s*/, "").trim())
87
+ .filter((l) => l && !/^nothing\.?$/i.test(l) && !/^none\.?$/i.test(l))
88
+ .slice(0, 10)
89
+ }
90
+
91
+ /** Only the role's login on its sites: two logins on one site would leave the agent with both sessions. */
92
+ export function loginsFor(logins: LoginConfig[], role?: string): LoginConfig[] {
93
+ if (!role) return logins
94
+ const mine = logins.find((l) => l.name === role)
95
+ if (!mine) return logins
96
+ const hosts = (l: LoginConfig) => l.domains.map((d) => d.replace(/^\w+:\/\//, "").split("/")[0].replace(/^\*\./, ""))
97
+ const taken = new Set(hosts(mine))
98
+ return logins.filter((l) => l === mine || !hosts(l).some((h) => taken.has(h)))
99
+ }
100
+
101
+ const NO_USAGE: Usage = { input: 0, output: 0 }
102
+ const STATUS = { complete: "passed", fail: "failed", stopped: "stopped" } as const
103
+
104
+ export type ExploreEvents = {
105
+ onRunStart?: (c: Charter, goal: string, repro: boolean) => void
106
+ onRunEvent?: (c: Charter, e: AgentEvent) => void
107
+ onRunDone?: (c: Charter, r: { result?: RunResult; error?: string; runDir: string; ms: number }) => void
108
+ onInfo?: (text: string) => void
109
+ }
110
+
111
+ export async function exploreCharters(opts: {
112
+ config: AppConfig
113
+ story: Story
114
+ charters: Charter[]
115
+ dir: string
116
+ url?: string
117
+ headed?: boolean
118
+ maxSteps?: number
119
+ signal?: AbortSignal
120
+ execute?: typeof executeRun
121
+ events?: ExploreEvents
122
+ }): Promise<CharterResult[]> {
123
+ const execute = opts.execute || executeRun
124
+ const ev = opts.events || {}
125
+ const out: CharterResult[] = []
126
+ for (const [i, c] of opts.charters.entries()) {
127
+ if (opts.signal?.aborted) break
128
+ const goal = charterGoal(opts.story, c)
129
+ const config: AppConfig = c.role ? { ...opts.config, logins: loginsFor(opts.config.logins, c.role) } : opts.config
130
+ const go = async (runDir: string, repro: boolean): Promise<{ run?: RunResult; error?: string }> => {
131
+ mkdirSync(runDir, { recursive: true })
132
+ ev.onRunStart?.(c, goal, repro)
133
+ const t0 = Date.now()
134
+ try {
135
+ const { result } = await execute({ config, goal, url: opts.url, headed: opts.headed, maxSteps: opts.maxSteps, caseSteps: c.steps.length, outDir: runDir, signal: opts.signal, onEvent: (e) => ev.onRunEvent?.(c, e) })
136
+ ev.onRunDone?.(c, { result, runDir, ms: Date.now() - t0 })
137
+ return { run: result }
138
+ } catch (err) {
139
+ const error = (err as Error).message || String(err)
140
+ ev.onRunDone?.(c, { error, runDir, ms: Date.now() - t0 })
141
+ return { error }
142
+ }
143
+ }
144
+ const runDir = path.join(opts.dir, `${String(i + 1).padStart(2, "0")}-${c.id}`)
145
+ let { run, error } = await go(runDir, false)
146
+ let failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
147
+ let usage = run?.usage || NO_USAGE
148
+ if (failure?.type === "environment" && !opts.signal?.aborted) {
149
+ ev.onInfo?.(`${c.id}: ${FAILURE_LABEL.environment} (${failure.reason}); running it once more`)
150
+ ;({ run, error } = await go(runDir, false))
151
+ failure = opts.signal?.aborted ? undefined : classifyFailure(run, error)
152
+ usage = addUsage(usage, run?.usage)
153
+ }
154
+ let status: CharterStatus = opts.signal?.aborted ? "stopped" : !failure ? (run?.status === "complete" ? "passed" : "stopped") : failure.type === "functional_defect" ? "defect" : "could_not_explore"
155
+ let reproRunDir: string | undefined
156
+ const observations = [...noticed(run?.summary || ""), ...browserErrors(runDir).map(problemLine)]
157
+ if (status === "defect" && !opts.signal?.aborted) {
158
+ // Confirmed only when it happens again: a defect that does not reproduce is an observation.
159
+ ev.onInfo?.(`${c.id}: possible defect (${failure!.reason}); running it again to confirm`)
160
+ reproRunDir = `${runDir}-repro`
161
+ const again = await go(reproRunDir, true)
162
+ usage = addUsage(usage, again.run?.usage)
163
+ const second = classifyFailure(again.run, again.error)
164
+ const same = second?.type === "functional_defect" && (second.step === undefined || failure!.step === undefined || second.step === failure!.step)
165
+ if (!same) {
166
+ status = "not_reproduced"
167
+ observations.unshift(`Not reproduced on a second run: ${failure!.reason}`)
168
+ }
169
+ }
170
+ const tc = charterCase(opts.story, c)
171
+ const caseResult: CaseResult = {
172
+ case: tc,
173
+ status: run ? STATUS[run.status] : opts.signal?.aborted ? "stopped" : "error",
174
+ summary: run?.summary || error || "",
175
+ startedAt: new Date().toISOString(),
176
+ finishedAt: new Date().toISOString(),
177
+ ms: 0,
178
+ tokens: usage.input + usage.output,
179
+ usage,
180
+ cost: costOf(usage, opts.config.llm.price),
181
+ runDir,
182
+ finalUrl: run?.finalUrl || undefined,
183
+ ...(run?.caseReport?.steps.length ? { stepResults: run.caseReport.steps } : {}),
184
+ ...(failure ? { failure } : {}),
185
+ }
186
+ out.push({ charter: c, status, summary: run?.summary || error || "", runDir, ...(reproRunDir ? { reproRunDir } : {}), ...(failure ? { failure } : {}), observations: [...new Set(observations)].slice(0, 15), ...(run?.finalUrl ? { finalUrl: run.finalUrl } : {}), usage, caseResult })
187
+ }
188
+ return out
189
+ }
190
+
191
+ /** Charters that ran clean become regression cases; confirmed defects become cases that check the correct behaviour. */
192
+ export function proposedCases(results: CharterResult[]): ProposedCase[] {
193
+ return results
194
+ .filter((r) => r.status === "passed" || r.status === "defect")
195
+ .map((r) => ({
196
+ title: r.charter.title,
197
+ steps: r.charter.steps.map((s) => ({ action: s.data ? `${s.action} (data: ${s.data})` : s.action, expected: s.expected })),
198
+ ...(r.charter.role ? { preconditions: [`Signed in as ${r.charter.role}`] } : {}),
199
+ description: `${r.charter.mission}${r.status === "defect" ? " Found a defect when explored: this case fails until it is fixed." : ""}`,
200
+ from: r.charter.id,
201
+ kind: r.status === "defect" ? "defect" : "regression",
202
+ }))
203
+ }
204
+
205
+ /** File bugs for confirmed defects only, through the configured tracker (one per charter, commented on when it is still open). */
206
+ export async function fileConfirmed(config: AppConfig, story: Story, results: CharterResult[]): Promise<WriteBack[]> {
207
+ const confirmed = results.filter((r) => r.status === "defect")
208
+ if (!confirmed.length || !config.sources.bugs) return []
209
+ return fileBugs(
210
+ confirmed.map((r) => r.caseResult),
211
+ config.sources,
212
+ `exploratory testing of ${story.id === "story" ? story.title : `${story.id} ${story.title}`}`,
213
+ )
214
+ }
215
+
216
+ const LABEL: Record<CharterStatus, string> = { passed: "✓ as expected", defect: "✗ defect (confirmed)", not_reproduced: "? not reproduced", could_not_explore: "– could not explore", stopped: "■ stopped" }
217
+
218
+ export function explorationMarkdown(x: Omit<Exploration, "report">): string {
219
+ const s = x.story
220
+ const defects = x.results.filter((r) => r.status === "defect")
221
+ const lines = [
222
+ `# Exploratory testing: ${s.id === "story" ? "" : `${s.id} `}${s.title}`,
223
+ "",
224
+ s.url ? `Story: ${s.url}` : "",
225
+ `${x.results.length} charter${x.results.length === 1 ? "" : "s"} · ${defects.length} confirmed defect${defects.length === 1 ? "" : "s"} · ${x.results.filter((r) => r.status === "not_reproduced").length} not reproduced · ${x.proposed.length} proposed test cases`,
226
+ "",
227
+ "## Charters",
228
+ "",
229
+ "| | Charter | Kind | Role | Result |",
230
+ "|---|---|---|---|---|",
231
+ ...x.results.map((r) => `| ${r.charter.id} | ${r.charter.title} | ${r.charter.category.replace("_", " ")} | ${r.charter.role || ""} | ${LABEL[r.status]}${r.failure && r.status !== "defect" ? `: ${FAILURE_LABEL[r.failure.type]}` : ""} |`),
232
+ ]
233
+ if (defects.length) {
234
+ lines.push("", "## Confirmed defects", "")
235
+ for (const r of defects) {
236
+ lines.push(`### ${r.charter.id}: ${r.charter.title}`, "", r.failure?.reason || r.summary, "", ...r.charter.steps.map((st, i) => `${i + 1}. ${st.action}${st.data ? ` (data: ${st.data})` : ""} → expected: ${st.expected}`), "", `Evidence: ${r.runDir}${r.reproRunDir ? ` and ${r.reproRunDir}` : ""}`)
237
+ const bug = x.bugs.find((b) => b.caseId === r.caseResult.case.id)
238
+ if (bug) lines.push(`Bug: ${bug.text}${bug.url ? ` ${bug.url}` : ""}${bug.error ? ` (${bug.error})` : ""}`)
239
+ lines.push("")
240
+ }
241
+ }
242
+ const obs = x.results.filter((r) => r.observations.length)
243
+ if (obs.length) {
244
+ lines.push("", "## Observations (not confirmed defects)", "")
245
+ for (const r of obs) lines.push(`- ${r.charter.id} ${r.charter.title}:`, ...r.observations.map((o) => ` - ${o}`))
246
+ }
247
+ const blocked = x.results.filter((r) => r.status === "could_not_explore")
248
+ if (blocked.length) lines.push("", "## Could not explore", "", ...blocked.map((r) => `- ${r.charter.id} ${r.charter.title}: ${r.failure ? `${FAILURE_LABEL[r.failure.type]}: ${r.failure.reason}` : r.summary}`))
249
+ if (x.proposed.length) {
250
+ lines.push("", "## Proposed test cases", "")
251
+ for (const p of x.proposed) lines.push(`### ${p.title}${p.kind === "defect" ? " (fails until the defect is fixed)" : ""}`, "", ...(p.preconditions || []).map((c) => `Precondition: ${c}`), ...p.steps.map((st, i) => `${i + 1}. ${st.action} → ${st.expected}`), "")
252
+ }
253
+ return `${lines.filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n").trim()}\n`
254
+ }
255
+
256
+ export function explorationCard(x: Exploration, cwd: string): Card {
257
+ const defects = x.results.filter((r) => r.status === "defect")
258
+ const body: string[] = x.results.map((r) => `${LABEL[r.status].split(" ")[0]} ${r.charter.id} ${r.charter.title}${r.charter.role ? ` [${r.charter.role}]` : ""}${r.status === "defect" || r.status === "could_not_explore" ? `\n ${r.failure?.reason || r.summary}` : ""}`)
259
+ const observations = x.results.reduce((n, r) => n + r.observations.length, 0)
260
+ if (observations) body.push("", `${observations} observation${observations === 1 ? "" : "s"} (not confirmed): see the report`)
261
+ if (x.bugs.length) body.push("", ...x.bugs.map((b) => `${b.error ? "✗" : "✓"} ${b.text}${b.url ? ` ${b.url}` : ""}${b.error ? `: ${b.error}` : ""}`))
262
+ if (x.proposed.length) body.push("", `${x.proposed.length} proposed test case${x.proposed.length === 1 ? "" : "s"}: ${x.proposed.map((p) => p.title).slice(0, 6).join("; ")}${x.proposed.length > 6 ? "; …" : ""}`)
263
+ const rel = path.relative(cwd, x.report)
264
+ body.push("", `Report ${rel && !rel.startsWith("..") ? rel : x.report}`)
265
+ return {
266
+ status: defects.length ? "fail" : x.results.some((r) => r.status === "passed") ? "ok" : "stopped",
267
+ title: `Explored ${x.story.id === "story" ? x.story.title : x.story.id}: ${defects.length ? `${defects.length} confirmed defect${defects.length === 1 ? "" : "s"}` : "no confirmed defects"}`,
268
+ stats: `${x.results.length} charter${x.results.length === 1 ? "" : "s"}`,
269
+ body,
270
+ }
271
+ }
272
+
273
+ /** Save the exploration: exploration.md and exploration.json in its folder. */
274
+ export function saveExploration(x: Omit<Exploration, "report">): string {
275
+ mkdirSync(x.dir, { recursive: true })
276
+ const report = path.join(x.dir, "exploration.md")
277
+ writeFileSync(report, explorationMarkdown(x))
278
+ writeFileSync(path.join(x.dir, "exploration.json"), JSON.stringify({ story: { ...x.story, scripted: x.story.scripted.map((c) => c.id) }, results: x.results.map(({ caseResult, ...r }) => r), proposed: x.proposed, bugs: x.bugs, usage: x.usage }, null, 2))
279
+ return report
280
+ }
@@ -0,0 +1,106 @@
1
+ import type { AppConfig } from "../config.ts"
2
+ import { adoRequirements, type Requirement } from "../cases/context.ts"
3
+ import { adfToText, http } from "../cases/http.ts"
4
+ import { jiraApi } from "../cases/jira.ts"
5
+ import { loadCases } from "../cases/index.ts"
6
+ import { searchRequirements } from "../cases/rag.ts"
7
+ import type { TestCase } from "../cases/types.ts"
8
+
9
+ /**
10
+ * The story to explore, with what it needs to be explored well: its description and acceptance criteria, the feature
11
+ * and epic above it, the test cases that already script it (exploration goes beyond them), and passages from the
12
+ * requirements search when one is set up.
13
+ */
14
+
15
+ export type Story = {
16
+ /** "ADO-123", "SHOP-12", or "story" for text. */
17
+ id: string
18
+ system: "ado" | "jira" | "text"
19
+ title: string
20
+ url?: string
21
+ description: string
22
+ acceptance: string
23
+ /** The feature and epic above it, nearest first. */
24
+ parents: Requirement[]
25
+ /** Test cases that already test it: their steps are the scripted coverage. */
26
+ scripted: TestCase[]
27
+ passages: string[]
28
+ errors: string[]
29
+ }
30
+
31
+ const MAX_SCRIPTED = 10
32
+
33
+ /** ado:123, #123, an Azure DevOps work item link; jira:SHOP-12, SHOP-12, a Jira browse link; anything else is the story's text. */
34
+ export function storyRef(ref: string): { system: "ado" | "jira" | "text"; key: string } {
35
+ const r = ref.trim()
36
+ const ado = r.match(/^(?:ado|azure):#?(\d+)$/i) || r.match(/^#(\d+)$/) || r.match(/_workitems\/edit\/(\d+)/) || (/^\d+$/.test(r) ? [r, r] : null)
37
+ if (ado) return { system: "ado", key: ado[1] }
38
+ const jira = r.match(/^jira:([A-Z][A-Z0-9_]+-\d+)$/i) || r.match(/^([A-Z][A-Z0-9_]+-\d+)$/) || r.match(/\/browse\/([A-Z][A-Z0-9_]+-\d+)/)
39
+ if (jira) return { system: "jira", key: jira[1].toUpperCase() }
40
+ return { system: "text", key: r }
41
+ }
42
+
43
+ async function adoStory(cfg: AppConfig, id: number, story: Story): Promise<void> {
44
+ const reqs = new Map<string, Requirement>()
45
+ const { chain } = await adoRequirements(cfg, [id], { requirements: reqs })
46
+ const own = reqs.get(`ADO-${id}`)
47
+ if (!own) throw new Error(`Azure DevOps work item ${id} was not found.`)
48
+ Object.assign(story, { id: own.id, title: own.title, url: own.url, description: own.description || "", acceptance: own.acceptance || "" })
49
+ story.parents = chain.slice(1).map((k) => reqs.get(k)!).filter(Boolean)
50
+ const tested = (own.testedBy || []).slice(0, MAX_SCRIPTED).map((k) => k.slice(4))
51
+ if (tested.length) {
52
+ try {
53
+ story.scripted = await loadCases([`ado:${tested.join(",")}`], cfg.sources)
54
+ } catch (err) {
55
+ story.errors.push(`scripted test cases: ${(err as Error).message.slice(0, 200)}`)
56
+ }
57
+ }
58
+ }
59
+
60
+ async function jiraStory(cfg: AppConfig, key: string, story: Story): Promise<void> {
61
+ const j = jiraApi(cfg.sources, "Reading the story from Jira")
62
+ type Issue = { key: string; fields: Record<string, any> }
63
+ const read = async (k: string) => (await http<Issue>(`${j.api}/issue/${encodeURIComponent(k)}?fields=summary,description,issuetype,parent,issuelinks`, { auth: j.auth })).data
64
+ const issue = await read(key)
65
+ const text = (d: unknown) => (typeof d === "string" ? d : adfToText(d))
66
+ const description = text(issue.fields.description)
67
+ // Acceptance criteria usually sit in the description under a heading; keep them apart when they do.
68
+ const ac = description.split(/\n\s*(?:#+\s*)?acceptance criteria:?\s*\n/i)
69
+ Object.assign(story, { id: key, title: String(issue.fields.summary || ""), url: `${j.url}/browse/${key}`, description: ac[0].trim(), acceptance: (ac[1] || "").trim() })
70
+ let parent = issue.fields.parent?.key as string | undefined
71
+ for (let level = 0; parent && level < 2; level++) {
72
+ const p = await read(parent)
73
+ story.parents.push({ id: p.key, type: String(p.fields.issuetype?.name || "Issue"), title: String(p.fields.summary || ""), url: `${j.url}/browse/${p.key}`, description: text(p.fields.description).slice(0, 2000) })
74
+ parent = p.fields.parent?.key
75
+ }
76
+ const links = (issue.fields.issuelinks || []) as { type: { name: string; inward: string; outward: string }; inwardIssue?: { key: string }; outwardIssue?: { key: string } }[]
77
+ const tests = links.filter((l) => /test/i.test(`${l.type.name} ${l.type.inward} ${l.type.outward}`)).map((l) => (l.inwardIssue || l.outwardIssue)?.key).filter((k): k is string => Boolean(k))
78
+ if (tests.length) {
79
+ try {
80
+ story.scripted = await loadCases(tests.slice(0, MAX_SCRIPTED).map((k) => `jira:${k}`), cfg.sources)
81
+ } catch (err) {
82
+ story.errors.push(`scripted test cases: ${(err as Error).message.slice(0, 200)}`)
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function readStory(cfg: AppConfig, ref: string): Promise<Story> {
88
+ const { system, key } = storyRef(ref)
89
+ const story: Story = { id: "story", system, title: "", description: "", acceptance: "", parents: [], scripted: [], passages: [], errors: [] }
90
+ if (system === "ado") await adoStory(cfg, Number(key), story)
91
+ else if (system === "jira") await jiraStory(cfg, key, story)
92
+ else {
93
+ const [first, ...rest] = key.split("\n")
94
+ const body = rest.join("\n")
95
+ const ac = body.split(/\n?\s*acceptance criteria:?\s*\n/i)
96
+ Object.assign(story, { title: first.trim().slice(0, 200), description: ac[0].trim(), acceptance: (ac[1] || "").trim() })
97
+ }
98
+ if (cfg.rag) {
99
+ try {
100
+ story.passages = (await searchRequirements(cfg.rag, `${story.title}\n${story.acceptance || story.description}`.slice(0, 1000))).map((p) => `${p.source ? `${p.source}: ` : ""}${p.text}`.slice(0, 800)).slice(0, 5)
101
+ } catch (err) {
102
+ story.errors.push(`RAG: ${(err as Error).message.slice(0, 200)}`)
103
+ }
104
+ }
105
+ return story
106
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,51 @@
1
+ export function normalizeHttpUrl(raw: string | undefined | null): string | null {
2
+ if (raw == null) return null
3
+ let u = String(raw).trim()
4
+ if (!u) return null
5
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(u)) u = `https://${u.replace(/^\/+/, "")}`
6
+ try {
7
+ const parsed = new URL(u)
8
+ if (!parsed.hostname) return null
9
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null
10
+ return parsed.toString()
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ export function extractUrls(text: string): string[] {
17
+ return text.match(/https?:\/\/[^\s<>"']+/g) ?? []
18
+ }
19
+
20
+ function hostMatches(hostname: string, pattern: string): boolean {
21
+ const h = hostname.toLowerCase().replace(/\.$/, "")
22
+ const p = pattern.trim().toLowerCase()
23
+ if (!p) return false
24
+ if (p.startsWith("*.")) {
25
+ const suffix = p.slice(1)
26
+ return h.endsWith(suffix) || h === p.slice(2)
27
+ }
28
+ return h === p || h.endsWith(`.${p}`)
29
+ }
30
+
31
+ /** Empty allow-list means every host is allowed. Returns an error string when blocked. */
32
+ export function hostAllowed(url: string, allowed: string[]): string | null {
33
+ if (!allowed.length) return null
34
+ let host = ""
35
+ try {
36
+ host = new URL(url).hostname
37
+ } catch {
38
+ return `Invalid URL: ${url}`
39
+ }
40
+ if (!host) return null
41
+ if (allowed.some((pattern) => hostMatches(host, pattern))) return null
42
+ return `Host not allowed: ${host}`
43
+ }
44
+
45
+ export function assertGoalUrlsAllowed(goal: string, allowed: string[]): string | null {
46
+ for (const url of extractUrls(goal)) {
47
+ const err = hostAllowed(url, allowed)
48
+ if (err) return err
49
+ }
50
+ return null
51
+ }