@capagents/forgeqe 1.1.3 → 1.1.5

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 (60) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +7 -5
  3. package/browserstack.example.yaml +15 -3
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +25 -18
  6. package/package.json +2 -2
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -4
  13. package/py/forgeqe_engine/browser.py +79 -12
  14. package/py/forgeqe_engine/service.py +12 -0
  15. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  16. package/skills/playwright-typescript/SKILL.md +2 -1
  17. package/src/bi.ts +382 -302
  18. package/src/brain/index.ts +1 -1
  19. package/src/capabilities/guide.ts +3 -3
  20. package/src/capabilities/registry.ts +34 -6
  21. package/src/cases/bugs.ts +209 -22
  22. package/src/cases/defectHistory.ts +193 -0
  23. package/src/cases/defects.ts +255 -0
  24. package/src/cases/github.ts +162 -0
  25. package/src/cases/goal.ts +1 -1
  26. package/src/cases/index.ts +23 -4
  27. package/src/cases/predict.ts +372 -0
  28. package/src/cases/rca.ts +258 -0
  29. package/src/cases/readiness.ts +38 -1
  30. package/src/cases/repro.ts +202 -0
  31. package/src/cases/types.ts +8 -1
  32. package/src/cli/help.ts +16 -7
  33. package/src/cli/index.ts +56 -15
  34. package/src/code/graph/graph.ts +511 -0
  35. package/src/code/graph/locators.ts +398 -0
  36. package/src/code/graph/parse.ts +439 -0
  37. package/src/code/graph/tools.ts +113 -0
  38. package/src/code/handoff.ts +93 -18
  39. package/src/code/loop.ts +30 -5
  40. package/src/code/profile.ts +14 -3
  41. package/src/code/replay.ts +1 -1
  42. package/src/code/review.ts +22 -0
  43. package/src/config.ts +160 -56
  44. package/src/mcp/attachtools.ts +223 -0
  45. package/src/mcp/commands.ts +1 -1
  46. package/src/mcp/defecttools.ts +106 -0
  47. package/src/mcp/devicetools.ts +125 -0
  48. package/src/mcp/external.ts +42 -1
  49. package/src/mcp/tools.ts +92 -9
  50. package/src/monitor/run.ts +1 -1
  51. package/src/report/data.ts +14 -0
  52. package/src/report/html.ts +15 -3
  53. package/src/report/template.html +44 -43
  54. package/src/run.ts +9 -2
  55. package/src/suite/report.ts +44 -11
  56. package/src/suite/run.ts +163 -16
  57. package/src/tui/App.tsx +9 -3
  58. package/src/usage.ts +1 -1
  59. package/testmu.example.yaml +14 -4
  60. package/src/cache.ts +0 -92
package/src/bi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process"
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
3
  import path from "node:path"
4
4
 
5
5
  import type { Readiness } from "./cases/readiness.ts"
@@ -7,11 +7,32 @@ import { adoSettings } from "./cases/ado.ts"
7
7
  import { FAILURE_ACTION } from "./cases/failure.ts"
8
8
  import { http } from "./cases/http.ts"
9
9
  import { jiraApi } from "./cases/jira.ts"
10
- import { cachedJson } from "./cache.ts"
11
10
  import type { CaseResult, WriteBack } from "./cases/types.ts"
12
11
  import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
12
+ import { reportHtml } from "./mcp/reporttools.ts"
13
+ import { duration } from "./report/html.ts"
13
14
  import { counts as suiteCounts } from "./suite/report.ts"
14
15
 
16
+ /**
17
+ * forgeqe bi: CSV datasets from ForgeQE's own reports (and optional ADO/Jira queries), rendered as a static site.
18
+ * The site is built with the open-source Evidence line (@evidence-dev/evidence 40.x, CSV source, pinned below):
19
+ * the current Evidence Studio CLI has no local build and keeps data in its cloud. When Node/npm is missing or the
20
+ * Evidence build fails, ForgeQE writes its own self-contained page from the same data instead.
21
+ */
22
+
23
+ /** The legacy Evidence packages the generated project installs; exact versions so builds are repeatable. */
24
+ const EVIDENCE_PACKAGES = {
25
+ "@evidence-dev/core-components": "5.4.2",
26
+ "@evidence-dev/csv": "1.0.16",
27
+ "@evidence-dev/evidence": "40.1.8",
28
+ }
29
+ /** Evidence names each CSV's table after its file, under this source: forgeqe.suites, forgeqe.<dataset id>. */
30
+ const SOURCE = "forgeqe"
31
+ /** In the front matter of every page ForgeQE owns; a page without it is the team's and is never overwritten. */
32
+ const MANAGED = "# Managed by forgeqe bi: regenerated on every sync."
33
+ /** ADO's WIQL limit: a query that returns this many items was probably cut off. */
34
+ const ADO_WIQL_MAX = 20000
35
+
15
36
  type BuildOverrides = { evidenceDir?: string; dataDir?: string; buildDir?: string }
16
37
  type Paths = {
17
38
  evidenceDir: string
@@ -19,7 +40,6 @@ type Paths = {
19
40
  buildDir: string
20
41
  sourceDir: string
21
42
  pagesDir: string
22
- externalDataDir: string
23
43
  }
24
44
 
25
45
  type SuiteFile = {
@@ -150,7 +170,10 @@ type ExternalDatasetRow = {
150
170
  page: string
151
171
  }
152
172
 
153
- type ExternalExport = { row: ExternalDatasetRow; files: string[] }
173
+ type ExternalExport = { row: ExternalDatasetRow; headers: readonly string[]; rows: Record<string, unknown>[] }
174
+
175
+ type SuiteData = { suites: SuiteRow[]; cases: CaseRow[]; steps: StepRow[]; writebacks: WriteBackRow[] }
176
+ type ReadinessData = { summary: ReadinessRow[]; stories: ReadinessStoryRow[]; cases: ReadinessCaseRow[] }
154
177
 
155
178
  export type BiSyncResult = {
156
179
  evidenceDir: string
@@ -163,77 +186,132 @@ export type BiSyncResult = {
163
186
  dataFiles: string[]
164
187
  pageFiles: string[]
165
188
  summaryFile: string
189
+ /** Things the user should know: a project ForgeQE did not create, a dataset that hit a limit. */
190
+ notes: string[]
166
191
  }
167
192
 
168
- export type BiBuildResult = BiSyncResult & { indexFile: string }
193
+ /** evidence: the Evidence site was built; static: ForgeQE's own page, because Evidence could not build (or was not asked). */
194
+ export type BiEngine = "auto" | "evidence" | "static"
195
+
196
+ export type BiBuildResult = BiSyncResult & { indexFile: string; engine: "evidence" | "static"; fallbackReason?: string }
197
+
198
+ /** Runs one command (npm, npx) in a folder; tests replace it. */
199
+ type CommandRunner = (command: string, args: string[], cwd: string) => Promise<void>
200
+ let runnerOverride: CommandRunner | undefined
169
201
 
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 }
202
+ export function setBiRunnerForTests(runner?: CommandRunner): void {
203
+ runnerOverride = runner
175
204
  }
176
205
 
177
- export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
206
+ export async function initBiProject(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<{ evidenceDir: string; files: string[]; notes: string[] }> {
178
207
  const bi = cfg.bi || defaultBiConfig(cfg)
179
208
  const paths = resolvePaths(bi, overrides)
180
- await ensureEvidenceProject(paths.evidenceDir)
181
- const pageFiles = writeManagedEvidenceFiles({ ...cfg, bi }, paths)
209
+ const notes: string[] = []
210
+ const files = [...scaffoldEvidenceProject(paths, notes), ...writeManagedEvidenceFiles(bi, paths, new Set())]
211
+ try {
212
+ await installEvidence(paths)
213
+ } catch (err) {
214
+ notes.push(`Evidence packages not installed (${errorText(err)}); forgeqe bi build will try again, or write ForgeQE's own page.`)
215
+ }
216
+ return { evidenceDir: paths.evidenceDir, files, notes }
217
+ }
182
218
 
183
- mkdirSync(paths.dataDir, { recursive: true })
184
- mkdirSync(paths.buildDir, { recursive: true })
185
- mkdirSync(paths.externalDataDir, { recursive: true })
219
+ export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
220
+ return (await syncData(cfg, overrides)).result
221
+ }
222
+
223
+ async function syncData(cfg: AppConfig, overrides: BuildOverrides): Promise<{ result: BiSyncResult; bi: BiConfig; paths: Paths; suites: SuiteData; readiness: ReadinessData; external: ExternalExport[] }> {
224
+ const bi = cfg.bi || defaultBiConfig(cfg)
225
+ const paths = resolvePaths(bi, overrides)
226
+ const notes: string[] = []
227
+ for (const dir of [paths.dataDir, paths.sourceDir, paths.buildDir]) mkdirSync(dir, { recursive: true })
186
228
 
187
229
  const suites = collectSuiteData(cfg)
188
230
  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),
231
+ const external = await exportExternalDatasets({ ...cfg, bi }, notes)
232
+
233
+ const tables: { name: string; headers: readonly string[]; rows: Record<string, unknown>[] }[] = [
234
+ { name: "suites", headers: SUITE_HEADERS, rows: suites.suites },
235
+ { name: "case_results", headers: CASE_HEADERS, rows: suites.cases },
236
+ { name: "step_results", headers: STEP_HEADERS, rows: suites.steps },
237
+ { name: "writebacks", headers: WRITEBACK_HEADERS, rows: suites.writebacks },
238
+ { name: "readiness", headers: READINESS_HEADERS, rows: readiness.summary },
239
+ { name: "readiness_stories", headers: READINESS_STORY_HEADERS, rows: readiness.stories },
240
+ { name: "readiness_cases", headers: READINESS_CASE_HEADERS, rows: readiness.cases },
241
+ { name: "external_datasets", headers: EXTERNAL_HEADERS, rows: external.map((e) => e.row) },
242
+ ...external.map((e) => ({ name: e.row.dataset_id, headers: e.headers, rows: e.rows })),
201
243
  ]
244
+ // Evidence's CSV source reads every CSV in its own folder (a data_dir elsewhere is copied there), and it cannot
245
+ // build over a table with no rows: empty tables stay out of that folder, and their page sections say "None yet.".
246
+ const separate = path.resolve(paths.dataDir) !== path.resolve(paths.sourceDir)
247
+ const dataFiles: string[] = []
248
+ const has = new Set<string>()
249
+ for (const t of tables) {
250
+ const file = path.join(paths.dataDir, `${t.name}.csv`)
251
+ const inSource = path.join(paths.sourceDir, `${t.name}.csv`)
252
+ if (t.rows.length) has.add(t.name)
253
+ if (t.rows.length || separate) dataFiles.push(writeCsv(file, t.headers, t.rows))
254
+ else rmSync(file, { force: true })
255
+ if (separate) {
256
+ if (t.rows.length) copyFileSync(file, inSource)
257
+ else rmSync(inSource, { force: true })
258
+ }
259
+ }
260
+ const pageFiles = [...scaffoldEvidenceProject(paths, notes), ...writeManagedEvidenceFiles(bi, paths, has)]
202
261
 
203
262
  const summaryFile = path.join(paths.buildDir, "summary.md")
204
- writeFileSync(summaryFile, summaryMarkdown({ ...cfg, bi }, paths, suites, readiness, external), "utf8")
263
+ writeFileSync(summaryFile, summaryMarkdown(bi, paths, suites, readiness, external), "utf8")
205
264
 
206
265
  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,
266
+ result: {
267
+ evidenceDir: paths.evidenceDir,
268
+ dataDir: paths.dataDir,
269
+ buildDir: paths.buildDir,
270
+ suites: suites.suites.length,
271
+ caseResults: suites.cases.length,
272
+ readinessReports: readiness.summary.length,
273
+ externalDatasets: external.length,
274
+ dataFiles,
275
+ pageFiles,
276
+ summaryFile,
277
+ notes,
278
+ },
279
+ bi,
280
+ paths,
281
+ suites,
282
+ readiness,
283
+ external,
217
284
  }
218
285
  }
219
286
 
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") }
287
+ export async function buildBi(cfg: AppConfig, overrides: BuildOverrides = {}, engine: BiEngine = "auto"): Promise<BiBuildResult> {
288
+ const synced = await syncData(cfg, overrides)
289
+ const indexFile = path.join(synced.paths.buildDir, "index.html")
290
+ const writeStatic = (reason: string): BiBuildResult => {
291
+ writeFileSync(indexFile, staticReport(synced.bi, synced.paths, synced.suites, synced.readiness, synced.external, reason), "utf8")
292
+ return { ...synced.result, indexFile, engine: "static", fallbackReason: reason }
293
+ }
294
+ if (engine === "static") return writeStatic("asked for with --static")
295
+ try {
296
+ await buildWithEvidence(synced.paths)
297
+ return { ...synced.result, indexFile, engine: "evidence" }
298
+ } catch (err) {
299
+ if (engine === "evidence") throw err
300
+ return writeStatic(errorText(err))
301
+ }
224
302
  }
225
303
 
226
304
  function resolvePaths(cfg: BiConfig, overrides: BuildOverrides): Paths {
227
305
  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)
306
+ const sourceDir = path.join(evidenceDir, "sources", SOURCE)
307
+ // A new evidence dir from the command line carries its own data folder unless one is given too.
308
+ const dataDir = path.resolve(overrides.dataDir || (overrides.evidenceDir ? sourceDir : cfg.dataDir))
230
309
  return {
231
310
  evidenceDir,
232
311
  dataDir,
233
- buildDir,
234
- sourceDir: path.join(evidenceDir, "sources", "forgeqe"),
312
+ buildDir: path.resolve(overrides.buildDir || cfg.buildDir),
313
+ sourceDir,
235
314
  pagesDir: path.join(evidenceDir, "pages"),
236
- externalDataDir: path.join(dataDir, "external"),
237
315
  }
238
316
  }
239
317
 
@@ -242,231 +320,161 @@ function defaultBiConfig(cfg: AppConfig): BiConfig {
242
320
  return {
243
321
  title: "ForgeQE BI",
244
322
  evidenceDir,
245
- dataDir: path.join(evidenceDir, "sources", "forgeqe", "data"),
323
+ dataDir: path.join(evidenceDir, "sources", SOURCE),
246
324
  buildDir: path.join(cfg.export.outDir, "bi"),
247
325
  datasets: [],
248
326
  }
249
327
  }
250
328
 
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())
329
+ /** The Evidence project's own files, written only when missing so a team's changes to them are kept. */
330
+ function scaffoldEvidenceProject(paths: Paths, notes: string[]): string[] {
331
+ const files: string[] = []
332
+ const missing = (name: string) => !existsSync(path.join(paths.evidenceDir, name))
333
+ const pkg = {
334
+ name: "forgeqe-bi",
335
+ version: "0.0.1",
336
+ private: true,
337
+ type: "module",
338
+ scripts: { sources: "evidence sources", build: "evidence build", dev: "evidence dev", preview: "evidence preview" },
339
+ engines: { node: ">=18.0.0" },
340
+ dependencies: EVIDENCE_PACKAGES,
341
+ // Evidence 40.1.8 peers on TypeScript 5.4.2 exactly; without this pin npm resolves 7.x and refuses to install.
342
+ devDependencies: { typescript: "5.4.2" },
343
+ }
344
+ if (missing("package.json")) files.push(writeText(path.join(paths.evidenceDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`))
345
+ if (missing(".npmrc")) files.push(writeText(path.join(paths.evidenceDir, ".npmrc"), "loglevel=error\naudit=false\nfund=false\n"))
346
+ if (missing(".gitignore")) files.push(writeText(path.join(paths.evidenceDir, ".gitignore"), "node_modules/\nbuild/\n.evidence/template/\n"))
347
+ const configFile = path.join(paths.evidenceDir, "evidence.config.yaml")
348
+ if (!existsSync(configFile)) {
349
+ files.push(
350
+ writeText(
351
+ configFile,
352
+ [
353
+ "plugins:",
354
+ " components:",
355
+ ' "@evidence-dev/core-components": {}',
356
+ " datasources:",
357
+ ' "@evidence-dev/csv": {}',
358
+ "",
359
+ ].join("\n"),
360
+ ),
361
+ )
362
+ } else if (!readFileSync(configFile, "utf8").includes("@evidence-dev/csv")) {
363
+ notes.push(`${configFile} does not load the @evidence-dev/csv source; add it under plugins.datasources so Evidence reads ForgeQE's CSV files.`)
364
+ }
365
+ return files
255
366
  }
256
367
 
257
- function writeManagedEvidenceFiles(cfg: AppConfig, paths: Paths): string[] {
258
- const bi = cfg.bi || defaultBiConfig(cfg)
368
+ /** has: the tables with rows; Evidence cannot build a page over an empty table, so those sections say so instead. */
369
+ function writeManagedEvidenceFiles(bi: BiConfig, paths: Paths, has: Set<string>): string[] {
259
370
  mkdirSync(paths.sourceDir, { recursive: true })
260
371
  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)))
372
+ const files = [writeText(path.join(paths.sourceDir, "connection.yaml"), `name: ${SOURCE}\ntype: csv\noptions: {}\n`)]
373
+ // Evidence reads front matter only at the top of a page, so the marker is a YAML comment there; title names the page in the menu.
374
+ const page = (name: string, title: string, body: string) => {
375
+ const file = path.join(paths.pagesDir, name)
376
+ if (existsSync(file) && !readFileSync(file, "utf8").split("\n", 3).includes(MANAGED)) return
377
+ files.push(writeText(file, `---\n${MANAGED}\ntitle: ${JSON.stringify(title)}\n---\n\n${body}`))
287
378
  }
379
+ page("index.md", bi.title, homePage(bi, has))
380
+ page("forgeqe-runs.md", "Suite and monitor history", runsPage(has))
381
+ page("forgeqe-readiness.md", "Release readiness history", readinessPage(has))
382
+ page("forgeqe-external.md", "External datasets", externalPage(has))
383
+ for (const dataset of bi.datasets) page(`forgeqe-dataset-${dataset.id}.md`, dataset.title, datasetPage(dataset, has))
288
384
  return files
289
385
  }
290
386
 
291
- function homePage(cfg: AppConfig): string {
292
- const bi = cfg.bi || defaultBiConfig(cfg)
387
+ function homePage(bi: BiConfig, has: Set<string>): string {
293
388
  const datasetLinks = bi.datasets.length
294
- ? bi.datasets.map((d) => `- [${d.title}](./forgeqe-dataset-${d.id})`).join("\n")
389
+ ? bi.datasets.map((d) => `- [${d.title}](/forgeqe-dataset-${d.id})`).join("\n")
295
390
  : "- No external datasets are configured yet. Add them under `bi.datasets` in `forgeqe.yaml`."
296
391
  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.",
392
+ "Generated by `forgeqe bi build` from ForgeQE's suite, monitor and release-readiness reports.",
300
393
  "",
301
394
  "## Pages",
302
395
  "",
303
- "- [Suite and monitor history](./forgeqe-runs)",
304
- "- [Release readiness history](./forgeqe-readiness)",
305
- "- [External datasets](./forgeqe-external)",
396
+ "- [Suite and monitor history](/forgeqe-runs)",
397
+ "- [Release readiness history](/forgeqe-readiness)",
398
+ "- [External datasets](/forgeqe-external)",
306
399
  datasetLinks,
307
400
  "",
308
- sqlBlock(
309
- "suite_summary",
401
+ query(has, "suite_summary",
310
402
  `select
311
403
  count(*) as suites,
312
404
  coalesce(sum(total_cases), 0) as total_cases,
313
405
  coalesce(sum(passed_cases), 0) as passed_cases,
314
406
  coalesce(sum(failed_cases), 0) as failed_cases,
315
407
  coalesce(sum(error_cases), 0) as error_cases
316
- from forgeqe.suites`,
317
- ),
318
- "",
319
- "{% table data=\"suite_summary\" /%}",
408
+ from ${SOURCE}.suites`),
320
409
  "",
321
410
  "## Case results by source",
322
411
  "",
323
- sqlBlock(
324
- "cases_by_source",
412
+ query(has, "cases_by_source",
325
413
  `select
326
414
  case_source,
327
415
  count(*) as total_results,
328
416
  sum(case when case_status = 'passed' then 1 else 0 end) as passed_results,
329
417
  sum(case when case_status = 'failed' then 1 else 0 end) as failed_results,
330
418
  sum(case when case_status = 'error' then 1 else 0 end) as error_results
331
- from forgeqe.case_results
419
+ from ${SOURCE}.case_results
332
420
  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\" /%}",
421
+ order by total_results desc, case_source`),
348
422
  "",
349
423
  ].join("\n")
350
424
  }
351
425
 
352
- function runsPage(): string {
426
+ function runsPage(has: Set<string>): string {
353
427
  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\" /%}",
428
+ query(has, "suite_timeline",
429
+ `select started_at, suite_scope, suite_name, total_cases, passed_cases, failed_cases, error_cases, stopped_cases, duration_ms
430
+ from ${SOURCE}.suites
431
+ order by started_at desc`),
373
432
  "",
374
433
  "## Slowest case results",
375
434
  "",
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
435
+ query(has, "slowest_cases",
436
+ `select suite_id, case_id, case_title, case_source, case_status, duration_ms, tokens
437
+ from ${SOURCE}.case_results
387
438
  order by duration_ms desc
388
- limit 50`,
389
- ),
390
- "",
391
- "{% table data=\"slowest_cases\" /%}",
439
+ limit 50`),
392
440
  "",
393
441
  ].join("\n")
394
442
  }
395
443
 
396
- function readinessPage(): string {
444
+ function readinessPage(has: Set<string>): string {
397
445
  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
- ),
446
+ query(has, "readiness_summary",
447
+ `select report_id, scope, verdict, pass_rate, stories, cases, open_bugs, blockers, risks
448
+ from ${SOURCE}.readiness
449
+ order by report_id desc`),
415
450
  "",
416
- "{% table data=\"readiness_summary\" /%}",
451
+ "## Stories in the readiness reports",
417
452
  "",
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\" /%}",
453
+ query(has, "readiness_story_table",
454
+ `select report_id, story_id, story_type, story_title, state, area, case_count
455
+ from ${SOURCE}.readiness_stories
456
+ order by report_id desc, story_id`),
435
457
  "",
436
458
  ].join("\n")
437
459
  }
438
460
 
439
- function externalPage(): string {
461
+ function externalPage(has: Set<string>): string {
440
462
  return [
441
- "# External Datasets",
442
- "",
443
463
  "ForgeQE refreshes these datasets from Azure DevOps or Jira during `forgeqe bi sync` and `forgeqe bi build`.",
444
464
  "",
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\" /%}",
465
+ query(has, "external_datasets_table",
466
+ `select dataset_id, title, source_kind, row_count, refreshed_at
467
+ from ${SOURCE}.external_datasets
468
+ order by title`),
453
469
  "",
454
470
  ].join("\n")
455
471
  }
456
472
 
457
- function datasetPage(dataset: BiDatasetConfig): string {
473
+ function datasetPage(dataset: BiDatasetConfig, has: Set<string>): string {
458
474
  return [
459
- `# ${dataset.title}`,
460
- "",
461
475
  `Source kind: \`${dataset.kind}\``,
462
476
  "",
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\" /%}",
477
+ query(has, "dataset_rows", `select * from ${SOURCE}.${dataset.id}`),
470
478
  "",
471
479
  ].join("\n")
472
480
  }
@@ -618,25 +626,20 @@ function collectReadiness(cfg: AppConfig): { summary: ReadinessRow[]; stories: R
618
626
  return out
619
627
  }
620
628
 
621
- async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<ExternalExport[]> {
629
+ async function exportExternalDatasets(cfg: AppConfig, notes: string[]): Promise<ExternalExport[]> {
622
630
  const bi = cfg.bi || defaultBiConfig(cfg)
623
631
  const exports: ExternalExport[] = []
624
632
  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
- })
633
+ const headers = dataset.kind === "ado_wiql" ? ADO_DATASET_HEADERS : JIRA_DATASET_HEADERS
634
+ const rows: Record<string, unknown>[] = dataset.kind === "ado_wiql" ? await fetchAdoRows(cfg, dataset) : await fetchJiraRows(cfg, dataset)
635
+ if (dataset.kind === "ado_wiql" && rows.length >= ADO_WIQL_MAX) {
636
+ notes.push(`bi.datasets.${dataset.id}: Azure DevOps returns at most ${ADO_WIQL_MAX} work items per query, so this dataset is probably incomplete; narrow its WIQL.`)
639
637
  }
638
+ exports.push({
639
+ 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}` },
640
+ headers,
641
+ rows,
642
+ })
640
643
  }
641
644
  return exports
642
645
  }
@@ -644,71 +647,57 @@ async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<Ext
644
647
  async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "ado_wiql" }>): Promise<Record<string, string | number>[]> {
645
648
  const { auth, org, project: configured } = adoSettings(cfg.sources)
646
649
  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
- })
650
+ const base = project ? `${org}/${encodeURIComponent(project)}` : org
651
+ const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id?: number } | null; target?: { id?: number } | null }[] }>(
652
+ `${base}/_apis/wit/wiql?api-version=7.1&$top=${ADO_WIQL_MAX}`,
653
+ { auth, body: { query: dataset.wiql } },
654
+ )
655
+ 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")])]
656
+ if (!ids.length) return []
657
+ const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
658
+ const rows: Record<string, string | number>[] = []
659
+ for (let i = 0; i < ids.length; i += 200) {
660
+ 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 } })
661
+ for (const item of batch.data.value || []) {
662
+ rows.push({
663
+ id: item.id,
664
+ project: String(item.fields["System.TeamProject"] || project || ""),
665
+ type: String(item.fields["System.WorkItemType"] || ""),
666
+ title: String(item.fields["System.Title"] || ""),
667
+ state: String(item.fields["System.State"] || ""),
668
+ assigned_to: displayName(item.fields["System.AssignedTo"]),
669
+ changed_at: String(item.fields["System.ChangedDate"] || ""),
670
+ iteration: String(item.fields["System.IterationPath"] || ""),
671
+ })
672
+ }
673
+ }
674
+ return rows
679
675
  }
680
676
 
681
677
  async function fetchJiraRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, { kind: "jira_jql" }>): Promise<Record<string, string>[]> {
682
678
  const jira = jiraApi(cfg.sources)
683
679
  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
- })
680
+ const rows: Record<string, string>[] = []
681
+ if (jira.cloud) {
682
+ let next: string | undefined
683
+ do {
684
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", ...(next ? { nextPageToken: next } : {}) })
685
+ const { data } = await http<{ issues?: any[]; nextPageToken?: string; isLast?: boolean }>(`${jira.api}/search/jql?${q}`, { auth: jira.auth })
686
+ for (const issue of data.issues || []) rows.push(jiraRow(jira.url, issue))
687
+ next = data.isLast === false || data.nextPageToken ? data.nextPageToken : undefined
688
+ } while (next)
689
+ } else {
690
+ let startAt = 0
691
+ for (;;) {
692
+ const q = new URLSearchParams({ jql: dataset.jql, fields, maxResults: "100", startAt: String(startAt) })
693
+ const { data } = await http<{ issues?: any[]; total?: number }>(`${jira.api}/search?${q}`, { auth: jira.auth })
694
+ const page = data.issues || []
695
+ for (const issue of page) rows.push(jiraRow(jira.url, issue))
696
+ startAt += page.length
697
+ if (!page.length || startAt >= Number(data.total || 0)) break
698
+ }
699
+ }
700
+ return rows
712
701
  }
713
702
 
714
703
  function jiraRow(baseUrl: string, issue: Record<string, any>): Record<string, string> {
@@ -779,22 +768,18 @@ function readJson<T>(file: string): T | null {
779
768
  }
780
769
  }
781
770
 
782
- function sqlPath(file: string): string {
783
- return file.split(path.sep).join("/").replace(/'/g, "''")
784
- }
785
-
786
771
  function sqlBlock(name: string, sql: string): string {
787
772
  return `\`\`\`sql ${name}\n${sql}\n\`\`\``
788
773
  }
789
774
 
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)
775
+ /** A query and its table on an Evidence page, or "None yet." while the table it reads has no rows. */
776
+ function query(has: Set<string>, name: string, sql: string): string {
777
+ const table = sql.match(new RegExp(`from ${SOURCE}\\.(\\w+)`))?.[1]
778
+ if (table && !has.has(table)) return "None yet."
779
+ return `${sqlBlock(name, sql)}\n\n<DataTable data={${name}} rows=25/>`
780
+ }
781
+
782
+ function summaryMarkdown(bi: BiConfig, paths: Paths, suites: SuiteData, readiness: ReadinessData, external: ExternalExport[]): string {
798
783
  return [
799
784
  `# ${bi.title}`,
800
785
  "",
@@ -808,27 +793,122 @@ function summaryMarkdown(
808
793
  `- Release readiness reports exported: ${readiness.summary.length}`,
809
794
  `- External datasets exported: ${external.length}`,
810
795
  "",
811
- external.length ? "## External datasets" : "## External datasets",
796
+ "## External datasets",
812
797
  "",
813
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`."]),
814
799
  "",
815
800
  ].join("\n")
816
801
  }
817
802
 
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]
803
+ /** ForgeQE's own page from the same data, through the shared report page (reportHtml): no Node, no server. */
804
+ function staticReport(bi: BiConfig, paths: Paths, suites: SuiteData, readiness: ReadinessData, external: ExternalExport[], reason: string): string {
805
+ const passed = suites.cases.filter((c) => c.case_status === "passed").length
806
+ const failed = suites.cases.filter((c) => c.case_status === "failed").length
807
+ const errors = suites.cases.filter((c) => c.case_status === "error").length
808
+ const bySource = new Map<string, { total: number; passed: number; failed: number; errors: number }>()
809
+ for (const c of suites.cases) {
810
+ const s = bySource.get(c.case_source) || { total: 0, passed: 0, failed: 0, errors: 0 }
811
+ s.total++
812
+ if (c.case_status === "passed") s.passed++
813
+ if (c.case_status === "failed") s.failed++
814
+ if (c.case_status === "error") s.errors++
815
+ bySource.set(c.case_source, s)
816
+ }
817
+ const newestFirst = [...suites.suites].reverse().slice(0, 100)
818
+ const slowest = [...suites.cases].sort((a, b) => b.duration_ms - a.duration_ms).slice(0, 50)
819
+ const markdown = [
820
+ `Built by ForgeQE without Evidence (${reason}). The CSV datasets are in \`${paths.dataDir}\`.`,
821
+ "",
822
+ "## Summary",
823
+ "",
824
+ `- Suite runs: ${suites.suites.length}`,
825
+ `- Case results: ${suites.cases.length} (${passed} passed, ${failed} failed, ${errors} error${errors === 1 ? "" : "s"})`,
826
+ `- Release readiness reports: ${readiness.summary.length}`,
827
+ `- External datasets: ${external.length}`,
828
+ "",
829
+ "## Case results by source",
830
+ "",
831
+ mdTable(
832
+ ["Source", "Results", "Passed", "Failed", "Errors"],
833
+ [...bySource].sort((a, b) => b[1].total - a[1].total).map(([source, s]) => [source, s.total, s.passed, s.failed, s.errors]),
834
+ ),
835
+ "",
836
+ "## Suite and monitor history",
837
+ "",
838
+ mdTable(
839
+ ["Started", "Scope", "Suite", "Cases", "Passed", "Failed", "Errors", "Stopped", "Duration"],
840
+ newestFirst.map((s) => [s.started_at, s.suite_scope, s.suite_name, s.total_cases, s.passed_cases, s.failed_cases, s.error_cases, s.stopped_cases, duration(s.duration_ms)]),
841
+ ),
842
+ "",
843
+ "## Slowest case results",
844
+ "",
845
+ mdTable(
846
+ ["Suite", "Case", "Title", "Source", "Status", "Duration", "Tokens"],
847
+ slowest.map((c) => [c.suite_id, c.case_id, c.case_title, c.case_source, c.case_status, duration(c.duration_ms), c.tokens]),
848
+ ),
849
+ "",
850
+ "## Release readiness",
851
+ "",
852
+ mdTable(
853
+ ["Report", "Scope", "Verdict", "Pass rate", "Stories", "Cases", "Open bugs", "Blockers"],
854
+ [...readiness.summary].reverse().map((r) => [r.report_id, r.scope, r.verdict, r.pass_rate === "" ? "" : `${r.pass_rate}%`, r.stories, r.cases, r.open_bugs, r.blockers]),
855
+ ),
856
+ "",
857
+ "## External datasets",
858
+ "",
859
+ ...(external.length
860
+ ? external.flatMap((d) => [
861
+ `### ${d.row.title}`,
862
+ "",
863
+ `\`${d.row.dataset_id}\` · ${d.row.source_kind} · ${d.row.row_count} row${d.row.row_count === 1 ? "" : "s"}${d.rows.length > 100 ? ", first 100 shown" : ""}`,
864
+ "",
865
+ mdTable(d.headers, d.rows.slice(0, 100).map((row) => d.headers.map((h) => row[h]))),
866
+ "",
867
+ ])
868
+ : ["None configured. Add datasets under `bi.datasets` in `forgeqe.yaml`.", ""]),
869
+ ].join("\n")
870
+ return reportHtml(bi.title, markdown, new Date())
871
+ }
872
+
873
+ function mdTable(headers: readonly string[], rows: unknown[][]): string {
874
+ if (!rows.length) return "None yet."
875
+ const cell = (v: unknown) => (v === undefined || v === null ? "" : String(v)).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ")
876
+ return [`| ${headers.map(cell).join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`, ...rows.map((r) => `| ${r.map(cell).join(" | ")} |`)].join("\n")
877
+ }
878
+
879
+ function errorText(err: unknown): string {
880
+ return err instanceof Error ? err.message : String(err)
881
+ }
882
+
883
+ /** npm install in the Evidence project, once: skipped while its packages are there. */
884
+ async function installEvidence(paths: Paths): Promise<void> {
885
+ if (existsSync(path.join(paths.evidenceDir, "node_modules", "@evidence-dev", "evidence", "package.json"))) return
886
+ await run("npm", ["install", "--no-audit", "--no-fund"], paths.evidenceDir)
887
+ }
888
+
889
+ /** evidence sources (CSV → the site's data), then evidence build:strict, then the site copied to the build directory. */
890
+ async function buildWithEvidence(paths: Paths): Promise<void> {
891
+ await installEvidence(paths)
892
+ await run("npx", ["--no-install", "evidence", "sources"], paths.evidenceDir)
893
+ await run("npx", ["--no-install", "evidence", "build:strict"], paths.evidenceDir)
894
+ const site = path.join(paths.evidenceDir, "build")
895
+ if (!existsSync(path.join(site, "index.html"))) throw new Error(`Evidence finished without writing ${path.join(site, "index.html")}`)
896
+ if (path.resolve(site) !== path.resolve(paths.buildDir)) cpSync(site, paths.buildDir, { recursive: true, force: true })
897
+ }
898
+
899
+ async function run(command: string, args: string[], cwd: string): Promise<void> {
900
+ if (runnerOverride) return runnerOverride(command, args, cwd)
901
+ const bin = Bun.which(command)
902
+ if (!bin) throw new Error(`${command} not found; install Node.js 18 or later to build the Evidence site`)
823
903
  await new Promise<void>((resolve, reject) => {
824
- const child = spawn(command, fullArgs, {
904
+ const child = spawn(bin, args, {
825
905
  cwd,
826
906
  stdio: "inherit",
827
907
  shell: process.platform === "win32",
828
- env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1", ...extraEnv },
908
+ env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1" },
829
909
  })
830
910
  child.on("error", reject)
831
- child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Evidence command failed (${[command, ...fullArgs].join(" ")}).`))))
911
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${[command, ...args].join(" ")} exited with code ${code}`))))
832
912
  })
833
913
  }
834
914