@capagents/forgeqe 1.1.2 → 1.1.4

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.
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"
@@ -9,8 +9,30 @@ import { http } from "./cases/http.ts"
9
9
  import { jiraApi } from "./cases/jira.ts"
10
10
  import type { CaseResult, WriteBack } from "./cases/types.ts"
11
11
  import type { AppConfig, BiConfig, BiDatasetConfig } from "./config.ts"
12
+ import { reportHtml } from "./mcp/reporttools.ts"
13
+ import { duration } from "./report/html.ts"
12
14
  import { counts as suiteCounts } from "./suite/report.ts"
13
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
+
14
36
  type BuildOverrides = { evidenceDir?: string; dataDir?: string; buildDir?: string }
15
37
  type Paths = {
16
38
  evidenceDir: string
@@ -18,7 +40,6 @@ type Paths = {
18
40
  buildDir: string
19
41
  sourceDir: string
20
42
  pagesDir: string
21
- externalDataDir: string
22
43
  }
23
44
 
24
45
  type SuiteFile = {
@@ -149,7 +170,10 @@ type ExternalDatasetRow = {
149
170
  page: string
150
171
  }
151
172
 
152
- 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[] }
153
177
 
154
178
  export type BiSyncResult = {
155
179
  evidenceDir: string
@@ -162,77 +186,132 @@ export type BiSyncResult = {
162
186
  dataFiles: string[]
163
187
  pageFiles: string[]
164
188
  summaryFile: string
189
+ /** Things the user should know: a project ForgeQE did not create, a dataset that hit a limit. */
190
+ notes: string[]
165
191
  }
166
192
 
167
- 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
168
201
 
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 }
202
+ export function setBiRunnerForTests(runner?: CommandRunner): void {
203
+ runnerOverride = runner
174
204
  }
175
205
 
176
- 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[] }> {
177
207
  const bi = cfg.bi || defaultBiConfig(cfg)
178
208
  const paths = resolvePaths(bi, overrides)
179
- await ensureEvidenceProject(paths.evidenceDir)
180
- 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
+ }
218
+
219
+ export async function syncBiData(cfg: AppConfig, overrides: BuildOverrides = {}): Promise<BiSyncResult> {
220
+ return (await syncData(cfg, overrides)).result
221
+ }
181
222
 
182
- mkdirSync(paths.dataDir, { recursive: true })
183
- mkdirSync(paths.buildDir, { recursive: true })
184
- mkdirSync(paths.externalDataDir, { recursive: true })
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 })
185
228
 
186
229
  const suites = collectSuiteData(cfg)
187
230
  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),
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 })),
200
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)]
201
261
 
202
262
  const summaryFile = path.join(paths.buildDir, "summary.md")
203
- writeFileSync(summaryFile, summaryMarkdown({ ...cfg, bi }, paths, suites, readiness, external), "utf8")
263
+ writeFileSync(summaryFile, summaryMarkdown(bi, paths, suites, readiness, external), "utf8")
204
264
 
205
265
  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,
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,
216
284
  }
217
285
  }
218
286
 
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") }
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
+ }
223
302
  }
224
303
 
225
304
  function resolvePaths(cfg: BiConfig, overrides: BuildOverrides): Paths {
226
305
  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)
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))
229
309
  return {
230
310
  evidenceDir,
231
311
  dataDir,
232
- buildDir,
233
- sourceDir: path.join(evidenceDir, "sources", "forgeqe"),
312
+ buildDir: path.resolve(overrides.buildDir || cfg.buildDir),
313
+ sourceDir,
234
314
  pagesDir: path.join(evidenceDir, "pages"),
235
- externalDataDir: path.join(dataDir, "external"),
236
315
  }
237
316
  }
238
317
 
@@ -241,231 +320,161 @@ function defaultBiConfig(cfg: AppConfig): BiConfig {
241
320
  return {
242
321
  title: "ForgeQE BI",
243
322
  evidenceDir,
244
- dataDir: path.join(evidenceDir, "sources", "forgeqe", "data"),
323
+ dataDir: path.join(evidenceDir, "sources", SOURCE),
245
324
  buildDir: path.join(cfg.export.outDir, "bi"),
246
325
  datasets: [],
247
326
  }
248
327
  }
249
328
 
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())
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
254
366
  }
255
367
 
256
- function writeManagedEvidenceFiles(cfg: AppConfig, paths: Paths): string[] {
257
- 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[] {
258
370
  mkdirSync(paths.sourceDir, { recursive: true })
259
371
  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)))
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}`))
286
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))
287
384
  return files
288
385
  }
289
386
 
290
- function homePage(cfg: AppConfig): string {
291
- const bi = cfg.bi || defaultBiConfig(cfg)
387
+ function homePage(bi: BiConfig, has: Set<string>): string {
292
388
  const datasetLinks = bi.datasets.length
293
- ? 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")
294
390
  : "- No external datasets are configured yet. Add them under `bi.datasets` in `forgeqe.yaml`."
295
391
  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.",
392
+ "Generated by `forgeqe bi build` from ForgeQE's suite, monitor and release-readiness reports.",
299
393
  "",
300
394
  "## Pages",
301
395
  "",
302
- "- [Suite and monitor history](./forgeqe-runs)",
303
- "- [Release readiness history](./forgeqe-readiness)",
304
- "- [External datasets](./forgeqe-external)",
396
+ "- [Suite and monitor history](/forgeqe-runs)",
397
+ "- [Release readiness history](/forgeqe-readiness)",
398
+ "- [External datasets](/forgeqe-external)",
305
399
  datasetLinks,
306
400
  "",
307
- sqlBlock(
308
- "suite_summary",
401
+ query(has, "suite_summary",
309
402
  `select
310
403
  count(*) as suites,
311
404
  coalesce(sum(total_cases), 0) as total_cases,
312
405
  coalesce(sum(passed_cases), 0) as passed_cases,
313
406
  coalesce(sum(failed_cases), 0) as failed_cases,
314
407
  coalesce(sum(error_cases), 0) as error_cases
315
- from forgeqe.suites`,
316
- ),
317
- "",
318
- "{% table data=\"suite_summary\" /%}",
408
+ from ${SOURCE}.suites`),
319
409
  "",
320
410
  "## Case results by source",
321
411
  "",
322
- sqlBlock(
323
- "cases_by_source",
412
+ query(has, "cases_by_source",
324
413
  `select
325
414
  case_source,
326
415
  count(*) as total_results,
327
416
  sum(case when case_status = 'passed' then 1 else 0 end) as passed_results,
328
417
  sum(case when case_status = 'failed' then 1 else 0 end) as failed_results,
329
418
  sum(case when case_status = 'error' then 1 else 0 end) as error_results
330
- from forgeqe.case_results
419
+ from ${SOURCE}.case_results
331
420
  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\" /%}",
421
+ order by total_results desc, case_source`),
347
422
  "",
348
423
  ].join("\n")
349
424
  }
350
425
 
351
- function runsPage(): string {
426
+ function runsPage(has: Set<string>): string {
352
427
  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\" /%}",
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`),
372
432
  "",
373
433
  "## Slowest case results",
374
434
  "",
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
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
386
438
  order by duration_ms desc
387
- limit 50`,
388
- ),
389
- "",
390
- "{% table data=\"slowest_cases\" /%}",
439
+ limit 50`),
391
440
  "",
392
441
  ].join("\n")
393
442
  }
394
443
 
395
- function readinessPage(): string {
444
+ function readinessPage(has: Set<string>): string {
396
445
  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
- ),
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`),
414
450
  "",
415
- "{% table data=\"readiness_summary\" /%}",
451
+ "## Stories in the readiness reports",
416
452
  "",
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\" /%}",
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`),
434
457
  "",
435
458
  ].join("\n")
436
459
  }
437
460
 
438
- function externalPage(): string {
461
+ function externalPage(has: Set<string>): string {
439
462
  return [
440
- "# External Datasets",
441
- "",
442
463
  "ForgeQE refreshes these datasets from Azure DevOps or Jira during `forgeqe bi sync` and `forgeqe bi build`.",
443
464
  "",
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\" /%}",
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`),
452
469
  "",
453
470
  ].join("\n")
454
471
  }
455
472
 
456
- function datasetPage(dataset: BiDatasetConfig): string {
473
+ function datasetPage(dataset: BiDatasetConfig, has: Set<string>): string {
457
474
  return [
458
- `# ${dataset.title}`,
459
- "",
460
475
  `Source kind: \`${dataset.kind}\``,
461
476
  "",
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\" /%}",
477
+ query(has, "dataset_rows", `select * from ${SOURCE}.${dataset.id}`),
469
478
  "",
470
479
  ].join("\n")
471
480
  }
@@ -617,25 +626,20 @@ function collectReadiness(cfg: AppConfig): { summary: ReadinessRow[]; stories: R
617
626
  return out
618
627
  }
619
628
 
620
- async function exportExternalDatasets(cfg: AppConfig, paths: Paths): Promise<ExternalExport[]> {
629
+ async function exportExternalDatasets(cfg: AppConfig, notes: string[]): Promise<ExternalExport[]> {
621
630
  const bi = cfg.bi || defaultBiConfig(cfg)
622
631
  const exports: ExternalExport[] = []
623
632
  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
- })
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.`)
638
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
+ })
639
643
  }
640
644
  return exports
641
645
  }
@@ -645,10 +649,10 @@ async function fetchAdoRows(cfg: AppConfig, dataset: Extract<BiDatasetConfig, {
645
649
  const project = dataset.project || configured
646
650
  const base = project ? `${org}/${encodeURIComponent(project)}` : org
647
651
  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`,
652
+ `${base}/_apis/wit/wiql?api-version=7.1&$top=${ADO_WIQL_MAX}`,
649
653
  { auth, body: { query: dataset.wiql } },
650
654
  )
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)
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")])]
652
656
  if (!ids.length) return []
653
657
  const fields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath", "System.TeamProject"]
654
658
  const rows: Record<string, string | number>[] = []
@@ -764,22 +768,18 @@ function readJson<T>(file: string): T | null {
764
768
  }
765
769
  }
766
770
 
767
- function sqlPath(file: string): string {
768
- return file.split(path.sep).join("/").replace(/'/g, "''")
769
- }
770
-
771
771
  function sqlBlock(name: string, sql: string): string {
772
772
  return `\`\`\`sql ${name}\n${sql}\n\`\`\``
773
773
  }
774
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)
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 {
783
783
  return [
784
784
  `# ${bi.title}`,
785
785
  "",
@@ -793,27 +793,122 @@ function summaryMarkdown(
793
793
  `- Release readiness reports exported: ${readiness.summary.length}`,
794
794
  `- External datasets exported: ${external.length}`,
795
795
  "",
796
- external.length ? "## External datasets" : "## External datasets",
796
+ "## External datasets",
797
797
  "",
798
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
799
  "",
800
800
  ].join("\n")
801
801
  }
802
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]
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`)
808
903
  await new Promise<void>((resolve, reject) => {
809
- const child = spawn(command, fullArgs, {
904
+ const child = spawn(bin, args, {
810
905
  cwd,
811
906
  stdio: "inherit",
812
907
  shell: process.platform === "win32",
813
- env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1", ...extraEnv },
908
+ env: { ...process.env, DO_NOT_TRACK: "1", EVIDENCE_TELEMETRY_DISABLED: "1" },
814
909
  })
815
910
  child.on("error", reject)
816
- 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}`))))
817
912
  })
818
913
  }
819
914