@capagents/forgeqe 1.1.1 → 1.1.2

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