@capagents/forgeqe 1.1.1 → 1.1.3

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 (71) hide show
  1. package/HOW_TO_USE.md +25 -9
  2. package/README.md +30 -1
  3. package/browserstack.example.yaml +14 -0
  4. package/docs/security-data-flow.html +154 -86
  5. package/docs/user-guide.html +329 -135
  6. package/evals/prompts/brain/capability-question.yaml +12 -0
  7. package/evals/prompts/brain/failed-runs-cause.yaml +12 -0
  8. package/evals/prompts/brain/release-route.yaml +14 -0
  9. package/evals/prompts/brain/report-request.yaml +13 -0
  10. package/evals/prompts/brain/secret-request.yaml +9 -0
  11. package/evals/prompts/explore-charters/lockout.yaml +18 -0
  12. package/evals/prompts/explore-charters/transfer-limit.yaml +16 -0
  13. package/evals/prompts/refine/current-release.yaml +26 -0
  14. package/evals/prompts/refine/missing-item.yaml +10 -0
  15. package/evals/prompts/refine/question-stays-question.yaml +13 -0
  16. package/evals/prompts/refine/report-request.yaml +19 -0
  17. package/forgeqe.example.yaml +51 -0
  18. package/package.json +5 -2
  19. package/prompts/README.md +28 -7
  20. package/prompts/api-scenarios.md +56 -24
  21. package/prompts/api-tests.md +67 -51
  22. package/prompts/brain.md +277 -268
  23. package/prompts/browser-capture.md +8 -7
  24. package/prompts/browser-case-report.md +13 -12
  25. package/prompts/browser-checks.md +17 -18
  26. package/prompts/browser-efficiency.md +4 -1
  27. package/prompts/browser-error-recovery.md +5 -4
  28. package/prompts/browser-extra.md +1 -0
  29. package/prompts/browser-goal-verification.md +8 -7
  30. package/prompts/browser-skill-select.md +28 -1
  31. package/prompts/conventions.md +10 -0
  32. package/prompts/domain-rule.md +17 -14
  33. package/prompts/eval-judge.md +36 -13
  34. package/prompts/explore-charters.md +53 -22
  35. package/prompts/find-tests.md +45 -30
  36. package/prompts/heal.md +42 -32
  37. package/prompts/pr-review.md +44 -28
  38. package/prompts/refine.md +73 -40
  39. package/prompts/skill-rule.md +4 -1
  40. package/prompts/test-agent.md +61 -45
  41. package/prompts/test-reviewer.md +64 -37
  42. package/prompts/test-rules.md +59 -56
  43. package/py/forgeqe_engine/browser.py +111 -1
  44. package/py/forgeqe_engine/capture.py +8 -1
  45. package/py/forgeqe_engine/checks.py +20 -8
  46. package/py/forgeqe_engine/prompts.py +9 -0
  47. package/py/forgeqe_engine/report.py +15 -8
  48. package/py/forgeqe_engine/service.py +50 -27
  49. package/py/forgeqe_engine/skills.py +31 -7
  50. package/src/auth/signin.ts +30 -1
  51. package/src/bi.ts +914 -0
  52. package/src/cache.ts +92 -0
  53. package/src/capabilities/guide.ts +322 -140
  54. package/src/capabilities/registry.ts +3 -3
  55. package/src/childEnv.ts +4 -0
  56. package/src/cli/help.ts +18 -4
  57. package/src/cli/index.ts +149 -15
  58. package/src/config.ts +272 -0
  59. package/src/engine/bridge.ts +32 -2
  60. package/src/evals/prompts.ts +288 -0
  61. package/src/heal/loop.ts +3 -1
  62. package/src/init.ts +18 -0
  63. package/src/mcp/server.ts +8 -0
  64. package/src/mcp/tools.ts +30 -4
  65. package/src/monitor/run.ts +3 -1
  66. package/src/prompts.ts +6 -3
  67. package/src/promptstyle.ts +159 -0
  68. package/src/run.ts +4 -1
  69. package/src/ste.ts +8 -2
  70. package/src/suite/run.ts +3 -1
  71. package/testmu.example.yaml +14 -0
package/src/bi.ts ADDED
@@ -0,0 +1,914 @@
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { Readiness } from "./cases/readiness.ts"
6
+ import { adoSettings } from "./cases/ado.ts"
7
+ import { FAILURE_ACTION } from "./cases/failure.ts"
8
+ import { http } from "./cases/http.ts"
9
+ import { jiraApi } from "./cases/jira.ts"
10
+ import { cachedJson } from "./cache.ts"
11
+ import type { CaseResult, WriteBack } from "./cases/types.ts"
12
+ import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
13
+ import { counts as suiteCounts } from "./suite/report.ts"
14
+
15
+ type BuildOverrides = { evidenceDir?: string; dataDir?: string; buildDir?: string }
16
+ type Paths = {
17
+ evidenceDir: string
18
+ dataDir: string
19
+ buildDir: string
20
+ sourceDir: string
21
+ pagesDir: string
22
+ externalDataDir: string
23
+ }
24
+
25
+ type SuiteFile = {
26
+ name?: string
27
+ startedAt?: string
28
+ ms?: number
29
+ stopped?: boolean
30
+ results?: CaseResult[]
31
+ writeBack?: WriteBack[]
32
+ }
33
+
34
+ type SuiteRow = {
35
+ suite_id: string
36
+ suite_scope: "suite" | "monitor"
37
+ suite_name: string
38
+ started_at: string
39
+ duration_ms: number
40
+ total_cases: number
41
+ passed_cases: number
42
+ failed_cases: number
43
+ error_cases: number
44
+ stopped_cases: number
45
+ coded_cases: number
46
+ coded_passed_cases: number
47
+ }
48
+
49
+ type CaseRow = {
50
+ suite_id: string
51
+ suite_scope: "suite" | "monitor"
52
+ case_id: string
53
+ case_title: string
54
+ case_source: string
55
+ case_status: string
56
+ started_at: string
57
+ finished_at: string
58
+ duration_ms: number
59
+ tokens: number
60
+ final_url: string
61
+ run_dir: string
62
+ group: string
63
+ tags: string
64
+ requirements: string
65
+ failure_type: string
66
+ failure_action: string
67
+ bug_label: string
68
+ bug_url: string
69
+ code_status: string
70
+ code_test_file: string
71
+ code_command: string
72
+ }
73
+
74
+ type StepRow = {
75
+ suite_id: string
76
+ suite_scope: "suite" | "monitor"
77
+ case_id: string
78
+ step_number: number
79
+ action: string
80
+ data: string
81
+ expected: string
82
+ status: string
83
+ actual: string
84
+ cause: string
85
+ }
86
+
87
+ type WriteBackRow = {
88
+ suite_id: string
89
+ suite_scope: "suite" | "monitor"
90
+ case_id: string
91
+ source: string
92
+ text: string
93
+ url: string
94
+ error: string
95
+ }
96
+
97
+ type ReadinessRow = {
98
+ report_id: string
99
+ scope: string
100
+ verdict: string
101
+ pass_rate: number | ""
102
+ stories: number
103
+ untested_stories: number
104
+ cases: number
105
+ automated_cases: number
106
+ executed_cases: number
107
+ passed_cases: number
108
+ failed_cases: number
109
+ blocked_cases: number
110
+ not_run_cases: number
111
+ flaky_cases: number
112
+ quarantined_cases: number
113
+ open_bugs: number
114
+ blockers: string
115
+ risks: string
116
+ notes: string
117
+ }
118
+
119
+ type ReadinessStoryRow = {
120
+ report_id: string
121
+ story_id: number
122
+ story_type: string
123
+ story_title: string
124
+ state: string
125
+ url: string
126
+ area: string
127
+ case_count: number
128
+ }
129
+
130
+ type ReadinessCaseRow = {
131
+ report_id: string
132
+ story_id: number
133
+ case_id: number
134
+ case_title: string
135
+ automated: string
136
+ outcome: string
137
+ from: string
138
+ when: string
139
+ flaky: string
140
+ quarantined: string
141
+ url: string
142
+ }
143
+
144
+ type ExternalDatasetRow = {
145
+ dataset_id: string
146
+ title: string
147
+ source_kind: string
148
+ row_count: number
149
+ refreshed_at: string
150
+ page: string
151
+ }
152
+
153
+ type ExternalExport = { row: ExternalDatasetRow; files: string[] }
154
+
155
+ export type BiSyncResult = {
156
+ evidenceDir: string
157
+ dataDir: string
158
+ buildDir: string
159
+ suites: number
160
+ caseResults: number
161
+ readinessReports: number
162
+ externalDatasets: number
163
+ dataFiles: string[]
164
+ pageFiles: string[]
165
+ summaryFile: string
166
+ }
167
+
168
+ export type BiBuildResult = BiSyncResult & { indexFile: string }
169
+
170
+ export async function initBiProject(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<{ evidenceDir: string; files: string[] }> {
171
+ const paths = resolvePaths(cfg.bi || defaultBiConfig(cfg), overrides)
172
+ await ensureEvidenceProject(paths.evidenceDir)
173
+ const files = writeManagedEvidenceFiles(cfg, paths)
174
+ return { evidenceDir: paths.evidenceDir, files }
175
+ }
176
+
177
+ export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
178
+ const bi = cfg.bi || defaultBiConfig(cfg)
179
+ const paths = resolvePaths(bi, overrides)
180
+ await ensureEvidenceProject(paths.evidenceDir)
181
+ const pageFiles = writeManagedEvidenceFiles({ ...cfg, bi }, paths)
182
+
183
+ mkdirSync(paths.dataDir, { recursive: true })
184
+ mkdirSync(paths.buildDir, { recursive: true })
185
+ mkdirSync(paths.externalDataDir, { recursive: true })
186
+
187
+ const suites = collectSuiteData(cfg)
188
+ const readiness = collectReadiness(cfg)
189
+ const external = await exportExternalDatasets({ ...cfg, bi }, paths)
190
+
191
+ const dataFiles = [
192
+ writeCsv(path.join(paths.dataDir, "suites.csv"), SUITE_HEADERS, suites.suites),
193
+ writeCsv(path.join(paths.dataDir, "case_results.csv"), CASE_HEADERS, suites.cases),
194
+ writeCsv(path.join(paths.dataDir, "step_results.csv"), STEP_HEADERS, suites.steps),
195
+ writeCsv(path.join(paths.dataDir, "writebacks.csv"), WRITEBACK_HEADERS, suites.writebacks),
196
+ writeCsv(path.join(paths.dataDir, "readiness.csv"), READINESS_HEADERS, readiness.summary),
197
+ writeCsv(path.join(paths.dataDir, "readiness_stories.csv"), READINESS_STORY_HEADERS, readiness.stories),
198
+ writeCsv(path.join(paths.dataDir, "readiness_cases.csv"), READINESS_CASE_HEADERS, readiness.cases),
199
+ writeCsv(path.join(paths.dataDir, "external_datasets.csv"), EXTERNAL_HEADERS, external.map((e) => e.row)),
200
+ ...external.flatMap((e) => e.files),
201
+ ]
202
+
203
+ const summaryFile = path.join(paths.buildDir, "summary.md")
204
+ writeFileSync(summaryFile, summaryMarkdown({ ...cfg, bi }, paths, suites, readiness, external), "utf8")
205
+
206
+ return {
207
+ evidenceDir: paths.evidenceDir,
208
+ dataDir: paths.dataDir,
209
+ buildDir: paths.buildDir,
210
+ suites: suites.suites.length,
211
+ caseResults: suites.cases.length,
212
+ readinessReports: readiness.summary.length,
213
+ externalDatasets: external.length,
214
+ dataFiles,
215
+ pageFiles,
216
+ summaryFile,
217
+ }
218
+ }
219
+
220
+ export async function buildBi(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiBuildResult> {
221
+ const synced = await syncBiData(cfg, overrides)
222
+ await runEvidence(["build"], synced.evidenceDir, { EVIDENCE_BUILD_DIR: synced.buildDir })
223
+ return { ...synced, indexFile: path.join(synced.buildDir, "index.html") }
224
+ }
225
+
226
+ function resolvePaths(cfg: BiConfig, overrides: BuildOverrides): Paths {
227
+ const evidenceDir = path.resolve(overrides.evidenceDir || cfg.evidenceDir)
228
+ const dataDir = path.resolve(overrides.dataDir || cfg.dataDir)
229
+ const buildDir = path.resolve(overrides.buildDir || cfg.buildDir)
230
+ return {
231
+ evidenceDir,
232
+ dataDir,
233
+ buildDir,
234
+ sourceDir: path.join(evidenceDir, "sources", "forgeqe"),
235
+ pagesDir: path.join(evidenceDir, "pages"),
236
+ externalDataDir: path.join(dataDir, "external"),
237
+ }
238
+ }
239
+
240
+ function defaultBiConfig(cfg: AppConfig): BiConfig {
241
+ const evidenceDir = path.resolve("evidence")
242
+ return {
243
+ title: "ForgeQE BI",
244
+ evidenceDir,
245
+ dataDir: path.join(evidenceDir, "sources", "forgeqe", "data"),
246
+ buildDir: path.join(cfg.export.outDir, "bi"),
247
+ datasets: [],
248
+ }
249
+ }
250
+
251
+ async function ensureEvidenceProject(dir: string): Promise<void> {
252
+ if (existsSync(path.join(dir, "evidence.config.yaml"))) return
253
+ mkdirSync(path.dirname(dir), { recursive: true })
254
+ await runEvidence(["init", dir], process.cwd())
255
+ }
256
+
257
+ function writeManagedEvidenceFiles(cfg: AppConfig, paths: Paths): string[] {
258
+ const bi = cfg.bi || defaultBiConfig(cfg)
259
+ mkdirSync(paths.sourceDir, { recursive: true })
260
+ mkdirSync(paths.pagesDir, { recursive: true })
261
+
262
+ const files: string[] = []
263
+ files.push(writeText(path.join(paths.sourceDir, "connection.yaml"), `name: forgeqe\ntype: duckdb\noptions:\n filename: ":memory:"\n`))
264
+
265
+ const rel = (file: string) => sqlPath(path.relative(paths.sourceDir, file))
266
+ const managedQueries: Array<[string, string]> = [
267
+ ["suites.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "suites.csv"))}', header = true)`],
268
+ ["case_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "case_results.csv"))}', header = true)`],
269
+ ["step_results.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "step_results.csv"))}', header = true)`],
270
+ ["writebacks.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "writebacks.csv"))}', header = true)`],
271
+ ["readiness.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness.csv"))}', header = true)`],
272
+ ["readiness_stories.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_stories.csv"))}', header = true)`],
273
+ ["readiness_cases.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "readiness_cases.csv"))}', header = true)`],
274
+ ["external_datasets.sql", `select * from read_csv_auto('${rel(path.join(paths.dataDir, "external_datasets.csv"))}', header = true)`],
275
+ ]
276
+ for (const dataset of bi.datasets) {
277
+ managedQueries.push([`${dataset.id}.sql`, `select * from read_csv_auto('${rel(path.join(paths.externalDataDir, `${dataset.id}.csv`))}', header = true)`])
278
+ }
279
+ for (const [name, sql] of managedQueries) files.push(writeText(path.join(paths.sourceDir, name), `${sql}\n`))
280
+
281
+ files.push(writeText(path.join(paths.pagesDir, "home.md"), homePage({ ...cfg, bi })))
282
+ files.push(writeText(path.join(paths.pagesDir, "forgeqe-runs.md"), runsPage()))
283
+ files.push(writeText(path.join(paths.pagesDir, "forgeqe-readiness.md"), readinessPage()))
284
+ files.push(writeText(path.join(paths.pagesDir, "forgeqe-external.md"), externalPage()))
285
+ for (const dataset of bi.datasets) {
286
+ files.push(writeText(path.join(paths.pagesDir, `forgeqe-dataset-${dataset.id}.md`), datasetPage(dataset)))
287
+ }
288
+ return files
289
+ }
290
+
291
+ function homePage(cfg: AppConfig): string {
292
+ const bi = cfg.bi || defaultBiConfig(cfg)
293
+ const datasetLinks = bi.datasets.length
294
+ ? bi.datasets.map((d) => `- [${d.title}](./forgeqe-dataset-${d.id})`).join("\n")
295
+ : "- No external datasets are configured yet. Add them under `bi.datasets` in `forgeqe.yaml`."
296
+ return [
297
+ "# ForgeQE BI",
298
+ "",
299
+ "This static site is generated by `forgeqe bi build`. ForgeQE exports CSV datasets, Evidence queries them with DuckDB, and the build writes static HTML with no always-on backend.",
300
+ "",
301
+ "## Pages",
302
+ "",
303
+ "- [Suite and monitor history](./forgeqe-runs)",
304
+ "- [Release readiness history](./forgeqe-readiness)",
305
+ "- [External datasets](./forgeqe-external)",
306
+ datasetLinks,
307
+ "",
308
+ sqlBlock(
309
+ "suite_summary",
310
+ `select
311
+ count(*) as suites,
312
+ coalesce(sum(total_cases), 0) as total_cases,
313
+ coalesce(sum(passed_cases), 0) as passed_cases,
314
+ coalesce(sum(failed_cases), 0) as failed_cases,
315
+ coalesce(sum(error_cases), 0) as error_cases
316
+ from forgeqe.suites`,
317
+ ),
318
+ "",
319
+ "{% table data=\"suite_summary\" /%}",
320
+ "",
321
+ "## Case results by source",
322
+ "",
323
+ sqlBlock(
324
+ "cases_by_source",
325
+ `select
326
+ case_source,
327
+ count(*) as total_results,
328
+ sum(case when case_status = 'passed' then 1 else 0 end) as passed_results,
329
+ sum(case when case_status = 'failed' then 1 else 0 end) as failed_results,
330
+ sum(case when case_status = 'error' then 1 else 0 end) as error_results
331
+ from forgeqe.case_results
332
+ group by 1
333
+ order by total_results desc, case_source`,
334
+ ),
335
+ "",
336
+ "{% table data=\"cases_by_source\" /%}",
337
+ "",
338
+ "## External datasets",
339
+ "",
340
+ sqlBlock(
341
+ "external_datasets_home",
342
+ `select dataset_id, title, source_kind, row_count, refreshed_at, page
343
+ from forgeqe.external_datasets
344
+ order by title`,
345
+ ),
346
+ "",
347
+ "{% table data=\"external_datasets_home\" /%}",
348
+ "",
349
+ ].join("\n")
350
+ }
351
+
352
+ function runsPage(): string {
353
+ return [
354
+ "# Suite and Monitor History",
355
+ "",
356
+ sqlBlock(
357
+ "suite_timeline",
358
+ `select
359
+ started_at,
360
+ suite_scope,
361
+ suite_name,
362
+ total_cases,
363
+ passed_cases,
364
+ failed_cases,
365
+ error_cases,
366
+ stopped_cases,
367
+ duration_ms
368
+ from forgeqe.suites
369
+ order by started_at desc`,
370
+ ),
371
+ "",
372
+ "{% table data=\"suite_timeline\" /%}",
373
+ "",
374
+ "## Slowest case results",
375
+ "",
376
+ sqlBlock(
377
+ "slowest_cases",
378
+ `select
379
+ suite_id,
380
+ case_id,
381
+ case_title,
382
+ case_source,
383
+ case_status,
384
+ duration_ms,
385
+ tokens
386
+ from forgeqe.case_results
387
+ order by duration_ms desc
388
+ limit 50`,
389
+ ),
390
+ "",
391
+ "{% table data=\"slowest_cases\" /%}",
392
+ "",
393
+ ].join("\n")
394
+ }
395
+
396
+ function readinessPage(): string {
397
+ return [
398
+ "# Release Readiness History",
399
+ "",
400
+ sqlBlock(
401
+ "readiness_summary",
402
+ `select
403
+ report_id,
404
+ scope,
405
+ verdict,
406
+ pass_rate,
407
+ stories,
408
+ cases,
409
+ open_bugs,
410
+ blockers,
411
+ risks
412
+ from forgeqe.readiness
413
+ order by report_id desc`,
414
+ ),
415
+ "",
416
+ "{% table data=\"readiness_summary\" /%}",
417
+ "",
418
+ "## Stories in the latest readiness reports",
419
+ "",
420
+ sqlBlock(
421
+ "readiness_story_table",
422
+ `select
423
+ report_id,
424
+ story_id,
425
+ story_type,
426
+ story_title,
427
+ state,
428
+ area,
429
+ case_count
430
+ from forgeqe.readiness_stories
431
+ order by report_id desc, story_id`,
432
+ ),
433
+ "",
434
+ "{% table data=\"readiness_story_table\" /%}",
435
+ "",
436
+ ].join("\n")
437
+ }
438
+
439
+ function externalPage(): string {
440
+ return [
441
+ "# External Datasets",
442
+ "",
443
+ "ForgeQE refreshes these datasets from Azure DevOps or Jira during `forgeqe bi sync` and `forgeqe bi build`.",
444
+ "",
445
+ sqlBlock(
446
+ "external_datasets_table",
447
+ `select dataset_id, title, source_kind, row_count, refreshed_at, page
448
+ from forgeqe.external_datasets
449
+ order by title`,
450
+ ),
451
+ "",
452
+ "{% table data=\"external_datasets_table\" /%}",
453
+ "",
454
+ ].join("\n")
455
+ }
456
+
457
+ function datasetPage(dataset: BiDatasetConfig): string {
458
+ return [
459
+ `# ${dataset.title}`,
460
+ "",
461
+ `Source kind: \`${dataset.kind}\``,
462
+ "",
463
+ sqlBlock("dataset_stats", `select count(*) as rows from forgeqe.${dataset.id}`),
464
+ "",
465
+ "{% table data=\"dataset_stats\" /%}",
466
+ "",
467
+ sqlBlock("dataset_preview", `select * from forgeqe.${dataset.id} limit 100`),
468
+ "",
469
+ "{% table data=\"dataset_preview\" /%}",
470
+ "",
471
+ ].join("\n")
472
+ }
473
+
474
+ function collectSuiteData(cfg: AppConfig): { suites: SuiteRow[]; cases: CaseRow[]; steps: StepRow[]; writebacks: WriteBackRow[] } {
475
+ const suites: SuiteRow[] = []
476
+ const cases: CaseRow[] = []
477
+ const steps: StepRow[] = []
478
+ const writebacks: WriteBackRow[] = []
479
+ for (const [root, scope] of [
480
+ [cfg.suite.outDir, "suite" as const],
481
+ [cfg.monitor.outDir, "monitor" as const],
482
+ ] as Array<[string, "suite" | "monitor"]>) {
483
+ for (const dir of suiteDirs(root)) {
484
+ const suite = readJson<SuiteFile>(path.join(dir, "suite.json"))
485
+ if (!suite) continue
486
+ const results = suite.results || []
487
+ const c = suiteCounts(results)
488
+ suites.push({
489
+ suite_id: path.basename(dir),
490
+ suite_scope: scope,
491
+ suite_name: suite.name || path.basename(dir),
492
+ started_at: suite.startedAt || "",
493
+ duration_ms: Number(suite.ms || 0),
494
+ total_cases: c.total,
495
+ passed_cases: c.passed,
496
+ failed_cases: c.failed,
497
+ error_cases: c.errors,
498
+ stopped_cases: c.stopped,
499
+ coded_cases: c.coded,
500
+ coded_passed_cases: c.codePassed,
501
+ })
502
+ for (const result of results) {
503
+ cases.push({
504
+ suite_id: path.basename(dir),
505
+ suite_scope: scope,
506
+ case_id: result.case.id,
507
+ case_title: result.case.title,
508
+ case_source: result.case.source,
509
+ case_status: result.status,
510
+ started_at: result.startedAt || "",
511
+ finished_at: result.finishedAt || "",
512
+ duration_ms: Number(result.ms || 0),
513
+ tokens: Number(result.tokens || 0),
514
+ final_url: result.finalUrl || "",
515
+ run_dir: result.runDir || "",
516
+ group: result.case.group || "",
517
+ tags: (result.case.tags || []).join("; "),
518
+ requirements: (result.case.requirements || []).join("; "),
519
+ failure_type: result.failure?.type || "",
520
+ failure_action: result.failure ? FAILURE_ACTION[result.failure.type] : "",
521
+ bug_label: result.bug?.label || "",
522
+ bug_url: result.bug?.url || "",
523
+ code_status: result.code?.status || "",
524
+ code_test_file: result.code?.testFile || "",
525
+ code_command: result.code?.command || "",
526
+ })
527
+ const verdicts = new Map((result.stepResults || []).map((v) => [v.step, v]))
528
+ result.case.steps.forEach((step, i) => {
529
+ const verdict = verdicts.get(i + 1)
530
+ steps.push({
531
+ suite_id: path.basename(dir),
532
+ suite_scope: scope,
533
+ case_id: result.case.id,
534
+ step_number: i + 1,
535
+ action: step.action,
536
+ data: step.data || "",
537
+ expected: step.expected || "",
538
+ status: verdict?.status || "",
539
+ actual: verdict?.actual || "",
540
+ cause: verdict?.cause || "",
541
+ })
542
+ })
543
+ }
544
+ for (const post of suite.writeBack || []) {
545
+ writebacks.push({
546
+ suite_id: path.basename(dir),
547
+ suite_scope: scope,
548
+ case_id: post.caseId || "",
549
+ source: post.source,
550
+ text: post.text,
551
+ url: post.url || "",
552
+ error: post.error || "",
553
+ })
554
+ }
555
+ }
556
+ }
557
+ suites.sort((a, b) => a.started_at.localeCompare(b.started_at))
558
+ cases.sort((a, b) => a.started_at.localeCompare(b.started_at) || a.case_id.localeCompare(b.case_id))
559
+ return { suites, cases, steps, writebacks }
560
+ }
561
+
562
+ function collectReadiness(cfg: AppConfig): { summary: ReadinessRow[]; stories: ReadinessStoryRow[]; cases: ReadinessCaseRow[] } {
563
+ const out = { summary: [] as ReadinessRow[], stories: [] as ReadinessStoryRow[], cases: [] as ReadinessCaseRow[] }
564
+ const root = path.join(cfg.export.outDir, "readiness")
565
+ if (!existsSync(root)) return out
566
+ for (const name of readdirSync(root).sort()) {
567
+ const report = readJson<Readiness>(path.join(root, name, "readiness.json"))
568
+ if (!report?.scope || !report.counts) continue
569
+ out.summary.push({
570
+ report_id: name,
571
+ scope: report.scope,
572
+ verdict: report.verdict,
573
+ pass_rate: report.passRate ?? "",
574
+ stories: report.counts.stories,
575
+ untested_stories: report.counts.untested,
576
+ cases: report.counts.cases,
577
+ automated_cases: report.counts.automated,
578
+ executed_cases: report.counts.executed,
579
+ passed_cases: report.counts.passed,
580
+ failed_cases: report.counts.failed,
581
+ blocked_cases: report.counts.blocked,
582
+ not_run_cases: report.counts.notRun,
583
+ flaky_cases: report.counts.flaky,
584
+ quarantined_cases: report.counts.quarantined,
585
+ open_bugs: report.bugs.length,
586
+ blockers: report.blockers.join(" | "),
587
+ risks: report.risks.join(" | "),
588
+ notes: report.notes.join(" | "),
589
+ })
590
+ for (const story of report.stories) {
591
+ out.stories.push({
592
+ report_id: name,
593
+ story_id: story.id,
594
+ story_type: story.type,
595
+ story_title: story.title,
596
+ state: story.state,
597
+ url: story.url,
598
+ area: story.area || "",
599
+ case_count: story.cases.length,
600
+ })
601
+ for (const tc of story.cases) {
602
+ out.cases.push({
603
+ report_id: name,
604
+ story_id: story.id,
605
+ case_id: tc.id,
606
+ case_title: tc.title,
607
+ automated: tc.automated ? "yes" : "no",
608
+ outcome: tc.outcome,
609
+ from: tc.from || "",
610
+ when: tc.when || "",
611
+ flaky: tc.flaky ? "yes" : "no",
612
+ quarantined: tc.quarantined ? "yes" : "no",
613
+ url: tc.url,
614
+ })
615
+ }
616
+ }
617
+ }
618
+ return out
619
+ }
620
+
621
+ async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<ExternalExport[]> {
622
+ const bi = cfg.bi || defaultBiConfig(cfg)
623
+ const exports: ExternalExport[] = []
624
+ for (const dataset of bi.datasets) {
625
+ if (dataset.kind === "ado_wiql") {
626
+ const rows = await fetchAdoRows(cfg, dataset)
627
+ const file = path.join(paths.externalDataDir, `${dataset.id}.csv`)
628
+ exports.push({
629
+ row: { dataset_id: dataset.id, title: dataset.title, source_kind: dataset.kind, row_count: rows.length, refreshed_at: new Date().toISOString(), page: `forgeqe-dataset-${dataset.id}` },
630
+ files: [writeCsv(file, ADO_DATASET_HEADERS, rows)],
631
+ })
632
+ } else {
633
+ const rows = await fetchJiraRows(cfg, dataset)
634
+ const file = path.join(paths.externalDataDir, `${dataset.id}.csv`)
635
+ exports.push({
636
+ row: { dataset_id: dataset.id, title: dataset.title, source_kind: dataset.kind, row_count: rows.length, refreshed_at: new Date().toISOString(), page: `forgeqe-dataset-${dataset.id}` },
637
+ files: [writeCsv(file, JIRA_DATASET_HEADERS, rows)],
638
+ })
639
+ }
640
+ }
641
+ return exports
642
+ }
643
+
644
+ async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "ado_wiql" }>): Promise<Record<string, string | number>[]> {
645
+ const { auth, org, project: configured } = adoSettings(cfg.sources)
646
+ const project = dataset.project || configured
647
+ return cachedJson(cfg.cache, {
648
+ namespace: "bi:ado_wiql",
649
+ keyParts: [org, project || "", dataset.wiql],
650
+ ttlSeconds: cfg.cache?.biTtlSeconds,
651
+ load: async () => {
652
+ const base = project ? `${org}/${encodeURIComponent(project)}` : org
653
+ const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
654
+ `${base}/_apis/wit/wiql?api-version=7.1&$top=500`,
655
+ { auth, body: { query: dataset.wiql } },
656
+ )
657
+ const ids = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id]).filter((id): id is number => typeof id === "number")])].slice(0, 500)
658
+ if (!ids.length) return []
659
+ const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
660
+ const rows: Record<string, string | number>[] = []
661
+ for (let i = 0; i < ids.length; i += 200) {
662
+ const batch = await http<{ value?: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: ids.slice(i, i + 200), fields } })
663
+ for (const item of batch.data.value || []) {
664
+ rows.push({
665
+ id: item.id,
666
+ project: String(item.fields["System.TeamProject"] || project || ""),
667
+ type: String(item.fields["System.WorkItemType"] || ""),
668
+ title: String(item.fields["System.Title"] || ""),
669
+ state: String(item.fields["System.State"] || ""),
670
+ assigned_to: displayName(item.fields["System.AssignedTo"]),
671
+ changed_at: String(item.fields["System.ChangedDate"] || ""),
672
+ iteration: String(item.fields["System.IterationPath"] || ""),
673
+ })
674
+ }
675
+ }
676
+ return rows
677
+ },
678
+ })
679
+ }
680
+
681
+ async function fetchJiraRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "jira_jql" }>): Promise<Record<string, string>[]> {
682
+ const jira = jiraApi(cfg.sources)
683
+ const fields = "summary,status,statuscategory,issuetype,priority,assignee,reporter,labels,components,parent,created,updated"
684
+ return cachedJson(cfg.cache, {
685
+ namespace: "bi:jira_jql",
686
+ keyParts: [jira.url, jira.cloud, dataset.jql, fields],
687
+ ttlSeconds: cfg.cache?.biTtlSeconds,
688
+ load: async () => {
689
+ const rows: Record<string, string>[] = []
690
+ if (jira.cloud) {
691
+ let next: string | undefined
692
+ do {
693
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
694
+ const { data } = await http<{ issues?: any[]; nextPageToken?: string; isLast?: boolean }>(`${jira.api}/search/jql?${q}`, { auth: jira.auth })
695
+ for (const issue of data.issues || []) rows.push(jiraRow(jira.url, issue))
696
+ next = data.isLast === false || data.nextPageToken ? data.nextPageToken : undefined
697
+ } while (next)
698
+ } else {
699
+ let startAt = 0
700
+ for (;;) {
701
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", startAt: String(startAt) })
702
+ const { data } = await http<{ issues?: any[]; total?: number }>(`${jira.api}/search?${q}`, { auth: jira.auth })
703
+ const page = data.issues || []
704
+ for (const issue of page) rows.push(jiraRow(jira.url, issue))
705
+ startAt += page.length
706
+ if (!page.length || startAt >= Number(data.total || 0)) break
707
+ }
708
+ }
709
+ return rows
710
+ },
711
+ })
712
+ }
713
+
714
+ function jiraRow(baseUrl: string, issue: Record<string, any>): Record<string, string> {
715
+ const fields = (issue.fields || {}) as Record<string, any>
716
+ return {
717
+ key: String(issue.key || ""),
718
+ summary: String(fields.summary || ""),
719
+ status: String(fields.status?.name || ""),
720
+ status_category: String(fields.status?.statusCategory?.name || fields.statuscategory?.name || ""),
721
+ issue_type: String(fields.issuetype?.name || ""),
722
+ priority: String(fields.priority?.name || ""),
723
+ assignee: displayName(fields.assignee),
724
+ reporter: displayName(fields.reporter),
725
+ parent_key: String(fields.parent?.key || ""),
726
+ parent_summary: String(fields.parent?.fields?.summary || ""),
727
+ labels: (Array.isArray(fields.labels) ? fields.labels : []).join("; "),
728
+ components: (Array.isArray(fields.components) ? fields.components.map((c: { name?: string }) => c.name).filter(Boolean) : []).join("; "),
729
+ created_at: String(fields.created || ""),
730
+ updated_at: String(fields.updated || ""),
731
+ url: issue.key ? `${baseUrl}/browse/${issue.key}` : "",
732
+ }
733
+ }
734
+
735
+ function displayName(value: unknown): string {
736
+ return value && typeof value === "object" ? String((value as { displayName?: string; name?: string }).displayName || (value as { name?: string }).name || "") : String(value || "")
737
+ }
738
+
739
+ function suiteDirs(root: string): string[] {
740
+ if (!existsSync(root)) return []
741
+ return readdirSync(root)
742
+ .map((name) => path.join(root, name))
743
+ .filter((dir) => existsSync(path.join(dir, "suite.json")))
744
+ }
745
+
746
+ function writeText(file: string, body: string): string {
747
+ mkdirSync(path.dirname(file), { recursive: true })
748
+ writeFileSync(file, body, "utf8")
749
+ return file
750
+ }
751
+
752
+ function writeCsv(file: string, headers: readonly string[], rows: Record<string, unknown>[]): string {
753
+ mkdirSync(path.dirname(file), { recursive: true })
754
+ const lines = [headers.join(","), ...rows.map((row) => headers.map((h) => csvCell(row[h])).join(","))]
755
+ writeFileSync(file, `${lines.join("\n")}\n`, "utf8")
756
+ return file
757
+ }
758
+
759
+ function csvCell(value: unknown): string {
760
+ const text =
761
+ value === undefined || value === null
762
+ ? ""
763
+ : typeof value === "string"
764
+ ? value
765
+ : typeof value === "number" || typeof value === "boolean"
766
+ ? String(value)
767
+ : Array.isArray(value)
768
+ ? value.join("; ")
769
+ : JSON.stringify(value)
770
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
771
+ }
772
+
773
+ function readJson<T>(file: string): T | null {
774
+ if (!existsSync(file)) return null
775
+ try {
776
+ return JSON.parse(readFileSync(file, "utf8")) as T
777
+ } catch {
778
+ return null
779
+ }
780
+ }
781
+
782
+ function sqlPath(file: string): string {
783
+ return file.split(path.sep).join("/").replace(/'/g, "''")
784
+ }
785
+
786
+ function sqlBlock(name: string, sql: string): string {
787
+ return `\`\`\`sql ${name}\n${sql}\n\`\`\``
788
+ }
789
+
790
+ function summaryMarkdown(
791
+ cfg: AppConfig,
792
+ paths: Paths,
793
+ suites: { suites: SuiteRow[]; cases: CaseRow[]; steps: StepRow[]; writebacks: WriteBackRow[] },
794
+ readiness: { summary: ReadinessRow[] },
795
+ external: ExternalExport[],
796
+ ): string {
797
+ const bi = cfg.bi || defaultBiConfig(cfg)
798
+ return [
799
+ `# ${bi.title}`,
800
+ "",
801
+ `Generated at ${new Date().toISOString()}.`,
802
+ "",
803
+ `- Evidence project: \`${paths.evidenceDir}\``,
804
+ `- Static build: \`${paths.buildDir}\``,
805
+ `- Data export: \`${paths.dataDir}\``,
806
+ `- Suite runs exported: ${suites.suites.length}`,
807
+ `- Case results exported: ${suites.cases.length}`,
808
+ `- Release readiness reports exported: ${readiness.summary.length}`,
809
+ `- External datasets exported: ${external.length}`,
810
+ "",
811
+ external.length ? "## External datasets" : "## External datasets",
812
+ "",
813
+ ...(external.length ? external.map((d) => `- ${d.row.title} (\`${d.row.dataset_id}\`): ${d.row.row_count} row${d.row.row_count === 1 ? "" : "s"}`) : ["- None configured. Add datasets under `bi.datasets` in `forgeqe.yaml`."]),
814
+ "",
815
+ ].join("\n")
816
+ }
817
+
818
+ async function runEvidence(args: string[], cwd: string, extraEnv: Record<string, string> = {}): Promise<void> {
819
+ const evidence = Bun.which("evidence")
820
+ const command = evidence ? evidence : Bun.which("npx")
821
+ if (!command) throw new Error("Evidence CLI not found and npx is unavailable. Install Node.js or the Evidence CLI first.")
822
+ const fullArgs = evidence ? args : ["-p", "evidence", "evidence", ...args]
823
+ await new Promise<void>((resolve, reject) => {
824
+ const child = spawn(command, fullArgs, {
825
+ cwd,
826
+ stdio: "inherit",
827
+ shell: process.platform === "win32",
828
+ env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1", ...extraEnv },
829
+ })
830
+ child.on("error", reject)
831
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Evidence command failed (${[command, ...fullArgs].join(" ")}).`))))
832
+ })
833
+ }
834
+
835
+ const SUITE_HEADERS = [
836
+ "suite_id",
837
+ "suite_scope",
838
+ "suite_name",
839
+ "started_at",
840
+ "duration_ms",
841
+ "total_cases",
842
+ "passed_cases",
843
+ "failed_cases",
844
+ "error_cases",
845
+ "stopped_cases",
846
+ "coded_cases",
847
+ "coded_passed_cases",
848
+ ] as const
849
+
850
+ const CASE_HEADERS = [
851
+ "suite_id",
852
+ "suite_scope",
853
+ "case_id",
854
+ "case_title",
855
+ "case_source",
856
+ "case_status",
857
+ "started_at",
858
+ "finished_at",
859
+ "duration_ms",
860
+ "tokens",
861
+ "final_url",
862
+ "run_dir",
863
+ "group",
864
+ "tags",
865
+ "requirements",
866
+ "failure_type",
867
+ "failure_action",
868
+ "bug_label",
869
+ "bug_url",
870
+ "code_status",
871
+ "code_test_file",
872
+ "code_command",
873
+ ] as const
874
+
875
+ const STEP_HEADERS = [
876
+ "suite_id",
877
+ "suite_scope",
878
+ "case_id",
879
+ "step_number",
880
+ "action",
881
+ "data",
882
+ "expected",
883
+ "status",
884
+ "actual",
885
+ "cause",
886
+ ] as const
887
+
888
+ const WRITEBACK_HEADERS = ["suite_id", "suite_scope", "case_id", "source", "text", "url", "error"] as const
889
+ const READINESS_HEADERS = [
890
+ "report_id",
891
+ "scope",
892
+ "verdict",
893
+ "pass_rate",
894
+ "stories",
895
+ "untested_stories",
896
+ "cases",
897
+ "automated_cases",
898
+ "executed_cases",
899
+ "passed_cases",
900
+ "failed_cases",
901
+ "blocked_cases",
902
+ "not_run_cases",
903
+ "flaky_cases",
904
+ "quarantined_cases",
905
+ "open_bugs",
906
+ "blockers",
907
+ "risks",
908
+ "notes",
909
+ ] as const
910
+ const READINESS_STORY_HEADERS = ["report_id", "story_id", "story_type", "story_title", "state", "url", "area", "case_count"] as const
911
+ const READINESS_CASE_HEADERS = ["report_id", "story_id", "case_id", "case_title", "automated", "outcome", "from", "when", "flaky", "quarantined", "url"] as const
912
+ const EXTERNAL_HEADERS = ["dataset_id", "title", "source_kind", "row_count", "refreshed_at", "page"] as const
913
+ const ADO_DATASET_HEADERS = ["id", "project", "type", "title", "state", "assigned_to", "changed_at", "iteration"] as const
914
+ const JIRA_DATASET_HEADERS = ["key", "summary", "status", "status_category", "issue_type", "priority", "assignee", "reporter", "parent_key", "parent_summary", "labels", "components", "created_at", "updated_at", "url"] as const