executable-stories-formatters 0.11.4 → 0.13.0

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/dist/cli.js CHANGED
@@ -191,6 +191,11 @@ var raw_run_schema_default = {
191
191
  },
192
192
  description: "Ticket/issue references. Each item is either a string ID or an object with id and optional url."
193
193
  },
194
+ covers: {
195
+ type: "array",
196
+ items: { type: "string" },
197
+ description: "Product-code paths/globs this scenario exercises (project-root-relative). Used to map code changes to at-risk scenarios."
198
+ },
194
199
  meta: {
195
200
  type: "object",
196
201
  description: "User-defined metadata for this story."
@@ -416,6 +421,27 @@ var raw_run_schema_default = {
416
421
  required: ["kind", "path", "phase"],
417
422
  additionalProperties: false
418
423
  },
424
+ {
425
+ type: "object",
426
+ description: "Embedded HTML rendered in a sandboxed iframe. Exactly one of path/url/content.",
427
+ properties: {
428
+ kind: { const: "html" },
429
+ path: { type: "string", description: "Local HTML file path (inlined into the report by default)." },
430
+ url: { type: "string", description: "Remote URL rendered via iframe src." },
431
+ content: { type: "string", description: "Inline HTML content rendered via iframe srcdoc." },
432
+ title: { type: "string" },
433
+ height: { oneOf: [{ type: "number" }, { type: "string" }], description: "Iframe height: number \u2192 px, string passed through (e.g. '60vh'). Default 400px." },
434
+ phase: { $ref: "#/$defs/DocPhase" },
435
+ children: { type: "array", items: { $ref: "#/$defs/DocEntry" }, description: "Nested child doc entries for grouping." }
436
+ },
437
+ required: ["kind", "phase"],
438
+ oneOf: [
439
+ { required: ["path"] },
440
+ { required: ["url"] },
441
+ { required: ["content"] }
442
+ ],
443
+ additionalProperties: false
444
+ },
419
445
  {
420
446
  type: "object",
421
447
  description: "Custom documentation entry with arbitrary data.",
@@ -491,6 +517,10 @@ var raw_run_schema_default = {
491
517
  minimum: 0,
492
518
  description: "Step index (0-based)."
493
519
  },
520
+ stepId: {
521
+ type: "string",
522
+ description: "Stable step ID when the framework provides one (correlates with StoryStep.id)."
523
+ },
494
524
  title: {
495
525
  type: "string",
496
526
  description: "Step title/description."
@@ -1237,6 +1267,14 @@ var CucumberJsonFormatter = class {
1237
1267
  }
1238
1268
  const embeddings = [];
1239
1269
  for (const doc of step.docs) {
1270
+ if (doc.kind === "html" && doc.content !== void 0) {
1271
+ embeddings.push({
1272
+ data: Buffer.from(doc.content, "utf8").toString("base64"),
1273
+ mime_type: "text/html",
1274
+ name: doc.title
1275
+ });
1276
+ continue;
1277
+ }
1240
1278
  if (doc.kind !== "screenshot" || !doc.path.startsWith("data:")) {
1241
1279
  continue;
1242
1280
  }
@@ -1261,14 +1299,14 @@ var CucumberJsonFormatter = class {
1261
1299
  duration: 0
1262
1300
  };
1263
1301
  }
1264
- const statusMap = {
1302
+ const statusMap2 = {
1265
1303
  passed: "passed",
1266
1304
  failed: "failed",
1267
1305
  skipped: "skipped",
1268
1306
  pending: "pending"
1269
1307
  };
1270
1308
  const stepResult = {
1271
- status: statusMap[result.status] ?? "undefined",
1309
+ status: statusMap2[result.status] ?? "undefined",
1272
1310
  // Duration in nanoseconds (Cucumber uses nanoseconds)
1273
1311
  duration: result.durationMs * 1e6
1274
1312
  };
@@ -1416,6 +1454,17 @@ ${doc.markdown}`,
1416
1454
  };
1417
1455
  case "screenshot":
1418
1456
  return null;
1457
+ case "html":
1458
+ if (doc.url !== void 0 || doc.path !== void 0) {
1459
+ return {
1460
+ doc_string: {
1461
+ content: `[${doc.title ?? "Embedded HTML"}](${doc.url ?? doc.path})`,
1462
+ content_type: "text/markdown",
1463
+ line: 0
1464
+ }
1465
+ };
1466
+ }
1467
+ return null;
1419
1468
  default:
1420
1469
  return null;
1421
1470
  }
@@ -1514,6 +1563,17 @@ function copyDocEntry(entry) {
1514
1563
  phase: entry.phase,
1515
1564
  ...children
1516
1565
  };
1566
+ case "html":
1567
+ return {
1568
+ kind: "html",
1569
+ ...entry.path !== void 0 ? { path: entry.path } : {},
1570
+ ...entry.url !== void 0 ? { url: entry.url } : {},
1571
+ ...entry.content !== void 0 ? { content: entry.content } : {},
1572
+ ...entry.title !== void 0 ? { title: entry.title } : {},
1573
+ ...entry.height !== void 0 ? { height: entry.height } : {},
1574
+ phase: entry.phase,
1575
+ ...children
1576
+ };
1517
1577
  case "custom":
1518
1578
  return {
1519
1579
  kind: "custom",
@@ -1902,7 +1962,7 @@ function scenarioHasDocs(scenario) {
1902
1962
  import * as fs2 from "fs";
1903
1963
  import * as path3 from "path";
1904
1964
 
1905
- // src/formatters/html/template.ts
1965
+ // src/formatters/html/template-scripts.ts
1906
1966
  var JS_THEME = `
1907
1967
  // Theme management
1908
1968
  function getSystemTheme() {
@@ -2704,6 +2764,23 @@ function parseMarkdownSections(marked) {
2704
2764
  });
2705
2765
  }
2706
2766
  `;
2767
+ var JS_HTML_EMBED = `
2768
+ // Open srcdoc-embedded HTML (doc-html iframes) in a new tab via a blob URL
2769
+ function initHtmlEmbeds() {
2770
+ document.querySelectorAll('.doc-html-open-srcdoc').forEach((btn) => {
2771
+ btn.addEventListener('click', function() {
2772
+ const container = btn.closest('.doc-html');
2773
+ const iframe = container ? container.querySelector('iframe.doc-html-frame') : null;
2774
+ const html = iframe ? iframe.getAttribute('srcdoc') : null;
2775
+ if (!html) return;
2776
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
2777
+ window.open(url, '_blank', 'noopener');
2778
+ });
2779
+ });
2780
+ }
2781
+ `;
2782
+
2783
+ // src/formatters/html/template.ts
2707
2784
  function generateScript(options) {
2708
2785
  const initCalls = [];
2709
2786
  if (options.includeDarkMode) {
@@ -2721,6 +2798,7 @@ function generateScript(options) {
2721
2798
  initCalls.push("initHashScroll();");
2722
2799
  initCalls.push("initToc();");
2723
2800
  initCalls.push("initThemePicker();");
2801
+ initCalls.push("initHtmlEmbeds();");
2724
2802
  const initScript = `
2725
2803
  // Initialize on load
2726
2804
  document.addEventListener('DOMContentLoaded', () => {
@@ -2729,6 +2807,7 @@ document.addEventListener('DOMContentLoaded', () => {
2729
2807
  `;
2730
2808
  let script = options.includeDarkMode ? JS_THEME : "";
2731
2809
  script += JS_CORE;
2810
+ script += JS_HTML_EMBED;
2732
2811
  if (options.additionalJs) {
2733
2812
  script += options.additionalJs;
2734
2813
  }
@@ -4368,6 +4447,82 @@ body {
4368
4447
  opacity: 0.8;
4369
4448
  }
4370
4449
 
4450
+ /* ============================================================================
4451
+ Documentation Entries - Embedded HTML
4452
+ ============================================================================ */
4453
+ .doc-html {
4454
+ margin-bottom: 0.5rem;
4455
+ border: 1px solid var(--border);
4456
+ border-radius: calc(var(--radius) - 2px);
4457
+ overflow: hidden;
4458
+ }
4459
+
4460
+ .doc-html:last-child {
4461
+ margin-bottom: 0;
4462
+ }
4463
+
4464
+ .doc-html-header {
4465
+ display: flex;
4466
+ align-items: center;
4467
+ justify-content: space-between;
4468
+ gap: 0.5rem;
4469
+ padding: 0.375rem 0.75rem;
4470
+ background: var(--muted, transparent);
4471
+ border-bottom: 1px solid var(--border);
4472
+ }
4473
+
4474
+ .doc-html-title {
4475
+ font-size: 0.75rem;
4476
+ font-weight: 600;
4477
+ color: var(--muted-foreground);
4478
+ text-transform: uppercase;
4479
+ letter-spacing: 0.04em;
4480
+ }
4481
+
4482
+ .doc-html-open {
4483
+ font-size: 0.875rem;
4484
+ line-height: 1;
4485
+ padding: 0.125rem 0.375rem;
4486
+ border: 1px solid var(--border);
4487
+ border-radius: calc(var(--radius) - 4px);
4488
+ background: transparent;
4489
+ color: var(--muted-foreground);
4490
+ cursor: pointer;
4491
+ text-decoration: none;
4492
+ }
4493
+
4494
+ .doc-html-open:hover {
4495
+ color: var(--foreground);
4496
+ border-color: var(--foreground);
4497
+ }
4498
+
4499
+ .doc-html-frame {
4500
+ display: block;
4501
+ width: 100%;
4502
+ border: 0;
4503
+ background: #fff;
4504
+ }
4505
+
4506
+ .doc-html-missing {
4507
+ padding: 0.75rem 1rem;
4508
+ border: 1px dashed var(--border);
4509
+ background: var(--muted, transparent);
4510
+ color: var(--muted-foreground);
4511
+ font-size: 0.8125rem;
4512
+ }
4513
+
4514
+ .doc-html-missing-label {
4515
+ font-weight: 600;
4516
+ margin-bottom: 0.25rem;
4517
+ }
4518
+
4519
+ .doc-html-missing-path {
4520
+ font-family: var(--font-mono, ui-monospace, monospace);
4521
+ font-size: 0.75rem;
4522
+ word-break: break-all;
4523
+ opacity: 0.8;
4524
+ }
4525
+
4371
4526
  /* ============================================================================
4372
4527
  Documentation Entries - Visual Check
4373
4528
  ============================================================================ */
@@ -14663,6 +14818,36 @@ function renderDocVideo(entry, deps) {
14663
14818
  ${captionHtml}
14664
14819
  </div>`;
14665
14820
  }
14821
+ function resolveHtmlSource(entry, deps) {
14822
+ if (entry.url !== void 0) return { mode: "src", value: entry.url };
14823
+ if (entry.content !== void 0) return { mode: "srcdoc", value: entry.content };
14824
+ const filePath = entry.path ?? "";
14825
+ if (/^https?:/i.test(filePath)) return { mode: "src", value: filePath };
14826
+ const inlined = deps.readHtmlFile?.(filePath);
14827
+ if (inlined !== void 0) return { mode: "srcdoc", value: inlined };
14828
+ const isAbsoluteFsPath = /^(?:[/\\]|[A-Za-z]:[/\\])/.test(filePath);
14829
+ if (deps.readHtmlFile && isAbsoluteFsPath) return { mode: "missing", value: filePath };
14830
+ return { mode: "src", value: filePath };
14831
+ }
14832
+ function renderDocHtml(entry, deps) {
14833
+ const source = resolveHtmlSource(entry, deps);
14834
+ if (source.mode === "missing") {
14835
+ return `<div class="doc-html doc-html-missing">
14836
+ <div class="doc-html-missing-label">HTML unavailable</div>
14837
+ <div class="doc-html-missing-path">${deps.escapeHtml(source.value)}</div>
14838
+ </div>`;
14839
+ }
14840
+ const heightCss = typeof entry.height === "number" ? `${entry.height}px` : entry.height ?? "400px";
14841
+ const frame = `<iframe class="doc-html-frame" sandbox="allow-scripts" loading="lazy" style="height: ${deps.escapeHtml(heightCss)};" title="${deps.escapeHtml(entry.title ?? "Embedded HTML")}" ${source.mode}="${deps.escapeHtml(source.value)}"></iframe>`;
14842
+ const openBtn = source.mode === "src" ? `<a class="doc-html-open" href="${deps.escapeHtml(source.value)}" target="_blank" rel="noopener noreferrer" title="Open in new tab" aria-label="Open in new tab">&#x2197;</a>` : `<button type="button" class="doc-html-open doc-html-open-srcdoc" title="Open in new tab" aria-label="Open in new tab">&#x2197;</button>`;
14843
+ return `<div class="doc-html">
14844
+ <div class="doc-html-header">
14845
+ <span class="doc-html-title">${deps.escapeHtml(entry.title ?? "HTML")}</span>
14846
+ ${openBtn}
14847
+ </div>
14848
+ ${frame}
14849
+ </div>`;
14850
+ }
14666
14851
  function renderDocCustom(entry, deps) {
14667
14852
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
14668
14853
  const data = entry.data;
@@ -14719,6 +14904,9 @@ function renderDocEntry(entry, deps) {
14719
14904
  case "video":
14720
14905
  html = renderDocVideo(entry, deps);
14721
14906
  break;
14907
+ case "html":
14908
+ html = renderDocHtml(entry, deps);
14909
+ break;
14722
14910
  case "custom":
14723
14911
  html = renderDocCustom(entry, deps);
14724
14912
  break;
@@ -15285,6 +15473,21 @@ function readScreenshotAsDataUri(filePath) {
15285
15473
  return void 0;
15286
15474
  }
15287
15475
  }
15476
+ var HTML_INLINE_WARN_BYTES = 1024 * 1024;
15477
+ function readHtmlFileContent(filePath) {
15478
+ try {
15479
+ if (!fs2.existsSync(filePath)) return void 0;
15480
+ const buf = fs2.readFileSync(filePath);
15481
+ if (buf.byteLength > HTML_INLINE_WARN_BYTES) {
15482
+ console.warn(
15483
+ `[executable-stories] Inlining large HTML file (${Math.round(buf.byteLength / 1024)} KiB) into the report: ${filePath}. Consider --asset-mode copy.`
15484
+ );
15485
+ }
15486
+ return buf.toString("utf8");
15487
+ } catch {
15488
+ return void 0;
15489
+ }
15490
+ }
15288
15491
  function normalizeOptions(options = {}) {
15289
15492
  return {
15290
15493
  title: options.title ?? "Test Results",
@@ -15292,6 +15495,7 @@ function normalizeOptions(options = {}) {
15292
15495
  searchable: options.searchable ?? true,
15293
15496
  startCollapsed: options.startCollapsed ?? false,
15294
15497
  embedScreenshots: options.embedScreenshots ?? true,
15498
+ embedHtmlFiles: options.embedHtmlFiles ?? true,
15295
15499
  syntaxHighlighting: options.syntaxHighlighting ?? true,
15296
15500
  mermaidEnabled: options.mermaidEnabled ?? true,
15297
15501
  markdownEnabled: options.markdownEnabled ?? true,
@@ -15310,7 +15514,10 @@ function createHtmlFormatter(options = {}) {
15310
15514
  markdownEnabled: opts.markdownEnabled,
15311
15515
  mermaidEnabled: opts.mermaidEnabled,
15312
15516
  embedScreenshots: opts.embedScreenshots,
15313
- readScreenshot: (filePath) => readScreenshotAsDataUri(filePath)
15517
+ readScreenshot: (filePath) => readScreenshotAsDataUri(filePath),
15518
+ // When html-file inlining is off (e.g. --asset-mode copy), omit the read
15519
+ // hook so doc-html iframes keep their src path for the asset bundler.
15520
+ ...opts.embedHtmlFiles ? { readHtmlFile: (filePath) => readHtmlFileContent(filePath) } : {}
15314
15521
  };
15315
15522
  const renderDocs = (docs, containerClass) => {
15316
15523
  if (!docs || docs.length === 0) return "";
@@ -15602,6 +15809,8 @@ var JUnitFormatter = class {
15602
15809
  }
15603
15810
  case "screenshot":
15604
15811
  return `${indent}Screenshot: ${entry.alt ?? entry.path}`;
15812
+ case "html":
15813
+ return `${indent}HTML: ${entry.title ?? "Embedded HTML"} (${entry.url ?? entry.path ?? "inline"})`;
15605
15814
  case "custom": {
15606
15815
  const dataStr = JSON.stringify(entry.data, null, 2);
15607
15816
  const lines = [];
@@ -16053,6 +16262,25 @@ var MarkdownFormatter = class {
16053
16262
  lines.push(`${indent}`);
16054
16263
  break;
16055
16264
  }
16265
+ case "html": {
16266
+ const htmlLabel = entry.title ?? "Embedded HTML";
16267
+ if (entry.url !== void 0 || entry.path !== void 0) {
16268
+ lines.push(`${indent}[${htmlLabel}](${entry.url ?? entry.path})`);
16269
+ break;
16270
+ }
16271
+ lines.push(`${indent}<details>`);
16272
+ lines.push(`${indent}<summary>${htmlLabel}</summary>`);
16273
+ lines.push(`${indent}`);
16274
+ lines.push(`${indent}\`\`\`html`);
16275
+ for (const line of (entry.content ?? "").split("\n")) {
16276
+ lines.push(`${indent}${line}`);
16277
+ }
16278
+ lines.push(`${indent}\`\`\``);
16279
+ lines.push(`${indent}`);
16280
+ lines.push(`${indent}</details>`);
16281
+ lines.push(`${indent}`);
16282
+ break;
16283
+ }
16056
16284
  case "custom":
16057
16285
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
16058
16286
  const data = entry.data;
@@ -16227,6 +16455,132 @@ function escapePipe(value) {
16227
16455
  return value.replace(/\|/g, "\\|");
16228
16456
  }
16229
16457
 
16458
+ // src/formatters/traceability-matrix.ts
16459
+ var TraceabilityMatrixFormatter = class {
16460
+ format(run) {
16461
+ const matrix = toTraceabilityMatrix(run);
16462
+ const lines = [];
16463
+ lines.push("# Traceability Matrix");
16464
+ lines.push("");
16465
+ lines.push(`Generated: ${matrix.generatedAt}`);
16466
+ lines.push(`Run: ${matrix.run.startedAt} to ${matrix.run.finishedAt}`);
16467
+ if (matrix.run.branch) lines.push(`Branch: ${matrix.run.branch}`);
16468
+ if (matrix.run.gitSha) lines.push(`Commit: ${matrix.run.gitSha}`);
16469
+ lines.push("");
16470
+ lines.push("| Requirements | Verified | Failing | Scenarios | Untraced |");
16471
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
16472
+ lines.push(
16473
+ `| ${matrix.summary.requirements} | ${matrix.summary.requirementsVerified} | ${matrix.summary.requirementsFailing} | ${matrix.summary.scenarios} | ${matrix.summary.untracedScenarios} |`
16474
+ );
16475
+ lines.push("");
16476
+ for (const req of matrix.requirements) {
16477
+ const heading2 = req.url ? `[${req.ticket}](${req.url})` : req.ticket;
16478
+ lines.push(`## ${heading2}`);
16479
+ lines.push("");
16480
+ lines.push(`Status: ${renderRequirementStatus(req.status)}`);
16481
+ if (req.covers.length > 0) {
16482
+ lines.push(`Covers: ${req.covers.map((path16) => `\`${path16}\``).join(", ")}`);
16483
+ }
16484
+ lines.push("");
16485
+ lines.push("| Status | Scenario | Source | Covers |");
16486
+ lines.push("| --- | --- | --- | --- |");
16487
+ for (const scenario of req.scenarios) {
16488
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
16489
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path16) => `\`${path16}\``).join(", ") : "";
16490
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
16491
+ }
16492
+ lines.push("");
16493
+ }
16494
+ if (matrix.untraced.length > 0) {
16495
+ lines.push("## Untraced scenarios");
16496
+ lines.push("");
16497
+ lines.push("Behavior with no requirement link. Add a `ticket` to each so it appears against a requirement.");
16498
+ lines.push("");
16499
+ lines.push("| Status | Scenario | Source |");
16500
+ lines.push("| --- | --- | --- |");
16501
+ for (const scenario of matrix.untraced) {
16502
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
16503
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` |`);
16504
+ }
16505
+ lines.push("");
16506
+ }
16507
+ return lines.join("\n").trimEnd();
16508
+ }
16509
+ };
16510
+ function toTraceabilityMatrix(run) {
16511
+ const sorted = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id));
16512
+ const byTicket = /* @__PURE__ */ new Map();
16513
+ const untraced = [];
16514
+ for (const tc of sorted) {
16515
+ const tickets = tc.story.tickets ?? [];
16516
+ if (tickets.length === 0) {
16517
+ untraced.push({
16518
+ id: tc.id,
16519
+ title: tc.story.scenario,
16520
+ status: tc.status,
16521
+ sourceFile: tc.sourceFile,
16522
+ sourceLine: tc.sourceLine
16523
+ });
16524
+ continue;
16525
+ }
16526
+ for (const ticket of tickets) {
16527
+ const entry = byTicket.get(ticket.id) ?? { url: ticket.url, cases: [] };
16528
+ if (!entry.url && ticket.url) entry.url = ticket.url;
16529
+ entry.cases.push(tc);
16530
+ byTicket.set(ticket.id, entry);
16531
+ }
16532
+ }
16533
+ const requirements = [...byTicket.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ticket, entry]) => {
16534
+ const scenarios = entry.cases.map((tc) => ({
16535
+ id: tc.id,
16536
+ title: tc.story.scenario,
16537
+ status: tc.status,
16538
+ sourceFile: tc.sourceFile,
16539
+ sourceLine: tc.sourceLine,
16540
+ covers: tc.story.covers ?? []
16541
+ }));
16542
+ const covers = [...new Set(scenarios.flatMap((s) => s.covers))].sort();
16543
+ return { ticket, url: entry.url, status: requirementStatus(entry.cases), scenarios, covers };
16544
+ });
16545
+ return {
16546
+ schemaVersion: "1.0",
16547
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
16548
+ run: {
16549
+ startedAt: new Date(run.startedAtMs).toISOString(),
16550
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
16551
+ gitSha: run.gitSha,
16552
+ branch: run.ci?.branch
16553
+ },
16554
+ summary: {
16555
+ requirements: requirements.length,
16556
+ requirementsVerified: requirements.filter((r) => r.status === "verified").length,
16557
+ requirementsFailing: requirements.filter((r) => r.status === "failing").length,
16558
+ scenarios: run.testCases.length,
16559
+ untracedScenarios: untraced.length
16560
+ },
16561
+ requirements,
16562
+ untraced
16563
+ };
16564
+ }
16565
+ function requirementStatus(cases) {
16566
+ if (cases.some((tc) => tc.status === "failed")) return "failing";
16567
+ if (cases.some((tc) => tc.status === "passed")) return "verified";
16568
+ return "incomplete";
16569
+ }
16570
+ function renderRequirementStatus(status) {
16571
+ switch (status) {
16572
+ case "verified":
16573
+ return "verified (all scenarios passed)";
16574
+ case "failing":
16575
+ return "failing (a scenario failed)";
16576
+ default:
16577
+ return "incomplete (no scenario passed yet)";
16578
+ }
16579
+ }
16580
+ function escapePipe2(value) {
16581
+ return value.replace(/\|/g, "\\|");
16582
+ }
16583
+
16230
16584
  // src/formatters/cucumber-messages/synthesize-feature.ts
16231
16585
  function extractFeatureName(testCases, uri) {
16232
16586
  for (const tc of testCases) {
@@ -17326,6 +17680,8 @@ function formatDocEntry(doc) {
17326
17680
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
17327
17681
  case "video":
17328
17682
  return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
17683
+ case "html":
17684
+ return `${doc.title ? `${escapeHtml2(doc.title)}: ` : ""}${escapeHtml2(doc.url ?? doc.path ?? "(inline html)")}`;
17329
17685
  case "custom":
17330
17686
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
17331
17687
  }
@@ -17784,6 +18140,8 @@ function formatDocEntry2(doc) {
17784
18140
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17785
18141
  case "video":
17786
18142
  return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
18143
+ case "html":
18144
+ return `${doc.title ? `${doc.title}: ` : ""}${doc.url ?? doc.path ?? "(inline html)"}`;
17787
18145
  case "custom":
17788
18146
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17789
18147
  }
@@ -17933,7 +18291,7 @@ import * as path5 from "path";
17933
18291
  function scanHtmlAssets(html) {
17934
18292
  const seen = /* @__PURE__ */ new Set();
17935
18293
  const patterns = [
17936
- /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
18294
+ /<(?:img|video|iframe)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17937
18295
  /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
17938
18296
  /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
17939
18297
  ];
@@ -18012,7 +18370,7 @@ function bundleAssets(htmlPath, options = {}) {
18012
18370
  function replaceAssetRef(html, original, replacement) {
18013
18371
  const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18014
18372
  const srcPattern = new RegExp(
18015
- `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
18373
+ `(<(?:img|video|iframe)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
18016
18374
  "g"
18017
18375
  );
18018
18376
  html = html.replace(srcPattern, `$1${replacement}$2`);
@@ -18400,6 +18758,21 @@ ${tc.errorStack}` : "");
18400
18758
  ])
18401
18759
  );
18402
18760
  break;
18761
+ case "html":
18762
+ if (entry.url !== void 0 || entry.path !== void 0) {
18763
+ const target = entry.url ?? entry.path ?? "";
18764
+ content.push(
18765
+ paragraph([
18766
+ text(entry.title ?? "Embedded HTML", strong()),
18767
+ text(": "),
18768
+ link(target, target)
18769
+ ])
18770
+ );
18771
+ break;
18772
+ }
18773
+ content.push(paragraph([text(entry.title ?? "Embedded HTML", strong())]));
18774
+ content.push(codeBlock(entry.content ?? "", "html"));
18775
+ break;
18403
18776
  case "custom":
18404
18777
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
18405
18778
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -20024,6 +20397,280 @@ function collectDocKinds(testCase) {
20024
20397
  return [...kinds].sort();
20025
20398
  }
20026
20399
 
20400
+ // src/scenario-failure.ts
20401
+ function failingScenarioMessage(tc) {
20402
+ const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
20403
+ return failingStep?.errorMessage ?? tc.errorMessage;
20404
+ }
20405
+
20406
+ // src/check.ts
20407
+ var ICON_PASS = "\u2713";
20408
+ var ICON_FAIL = "\u2717";
20409
+ var ICON_SKIP = "\u2298";
20410
+ var ICON_PENDING = "\u23F3";
20411
+ var ICON_WARN = "\u26A0";
20412
+ function buildCheck(args, _deps = {}) {
20413
+ const { testCases, baseline } = args;
20414
+ const summary = {
20415
+ total: testCases.length,
20416
+ passed: testCases.filter((tc) => tc.status === "passed").length,
20417
+ failed: testCases.filter((tc) => tc.status === "failed").length,
20418
+ skipped: testCases.filter((tc) => tc.status === "skipped").length,
20419
+ pending: testCases.filter((tc) => tc.status === "pending").length
20420
+ };
20421
+ let regressed = 0;
20422
+ let fixed = 0;
20423
+ if (baseline) {
20424
+ for (const tc of testCases) {
20425
+ const before = baseline.get(tc.id);
20426
+ if (before === "passed" && tc.status === "failed") regressed += 1;
20427
+ if (before === "failed" && tc.status === "passed") fixed += 1;
20428
+ }
20429
+ }
20430
+ const failures = testCases.filter((tc) => tc.status === "failed").map((tc) => toFailure(tc, baseline)).sort((a, b) => {
20431
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20432
+ return a.location.localeCompare(b.location);
20433
+ });
20434
+ return {
20435
+ summary,
20436
+ failures,
20437
+ regressed,
20438
+ fixed,
20439
+ comparedToBaseline: baseline !== void 0
20440
+ };
20441
+ }
20442
+ function toFailure(tc, baseline) {
20443
+ const failedIndexes = new Set(
20444
+ tc.stepResults.filter((s) => s.status === "failed").map((s) => s.index)
20445
+ );
20446
+ const steps = tc.story.steps.map((step, index) => ({
20447
+ keyword: step.keyword,
20448
+ text: step.text,
20449
+ failed: failedIndexes.has(index)
20450
+ }));
20451
+ return {
20452
+ id: tc.id,
20453
+ scenario: tc.story.scenario,
20454
+ location: `${tc.sourceFile}:${tc.sourceLine}`,
20455
+ steps,
20456
+ errorMessage: failingScenarioMessage(tc),
20457
+ covers: tc.story.covers ?? [],
20458
+ tickets: (tc.story.tickets ?? []).map((t) => t.id),
20459
+ regressed: baseline?.get(tc.id) === "passed"
20460
+ };
20461
+ }
20462
+ function renderCheck(report, format) {
20463
+ return format === "json" ? JSON.stringify(report, null, 2) : renderCheckText(report);
20464
+ }
20465
+ function renderCheckText(report) {
20466
+ const { summary, failures } = report;
20467
+ const headlineParts = [`${ICON_PASS} ${summary.passed} passed`];
20468
+ if (summary.failed > 0) headlineParts.push(`${ICON_FAIL} ${summary.failed} failed`);
20469
+ if (summary.skipped > 0) headlineParts.push(`${ICON_SKIP} ${summary.skipped} skipped`);
20470
+ if (summary.pending > 0) headlineParts.push(`${ICON_PENDING} ${summary.pending} pending`);
20471
+ const headline = `${headlineParts.join(" ")} (${summary.total} scenarios)`;
20472
+ if (failures.length === 0) {
20473
+ const lines2 = [headline];
20474
+ if (report.comparedToBaseline && report.fixed > 0) {
20475
+ lines2.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20476
+ }
20477
+ lines2.push("All scenarios green.");
20478
+ return lines2.join("\n");
20479
+ }
20480
+ const lines = [headline, ""];
20481
+ for (const f of failures) {
20482
+ lines.push(`${ICON_FAIL} ${f.scenario}${f.regressed ? " (regressed)" : ""}`);
20483
+ lines.push(` ${f.location}`);
20484
+ for (const step of f.steps) {
20485
+ const marker = step.failed ? ` ${ICON_FAIL} ` : " ";
20486
+ lines.push(`${marker}${step.keyword} ${step.text}`);
20487
+ }
20488
+ if (f.errorMessage) {
20489
+ const firstLine = f.errorMessage.split("\n")[0];
20490
+ lines.push(` \u2192 ${firstLine}`);
20491
+ }
20492
+ if (f.covers.length > 0) {
20493
+ lines.push(` covers: ${f.covers.join(", ")}`);
20494
+ }
20495
+ if (f.tickets.length > 0) {
20496
+ lines.push(` ticket: ${f.tickets.join(", ")}`);
20497
+ }
20498
+ lines.push("");
20499
+ }
20500
+ if (report.comparedToBaseline) {
20501
+ if (report.regressed > 0) {
20502
+ lines.push(`${ICON_WARN} ${report.regressed} regressed since baseline (was passing).`);
20503
+ }
20504
+ if (report.fixed > 0) {
20505
+ lines.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20506
+ }
20507
+ if (report.regressed === 0 && report.fixed === 0) {
20508
+ lines.push("No status changes vs. baseline.");
20509
+ }
20510
+ }
20511
+ return lines.join("\n").trimEnd();
20512
+ }
20513
+
20514
+ // src/goal.ts
20515
+ var ACTIVE = ["passed", "failed"];
20516
+ function buildGoal(args, _deps = {}) {
20517
+ const { run, baseline } = args;
20518
+ const cases = run.testCases;
20519
+ const selectors = [
20520
+ ...args.requireTags.map((tag) => ({ label: `tag:${tag}`, match: (tc) => tc.tags.includes(tag) })),
20521
+ ...args.requireTickets.map((id) => ({ label: `ticket:${id}`, match: (tc) => (tc.story.tickets ?? []).some((t) => t.id === id) })),
20522
+ ...args.requireScenarios.map((sel) => ({ label: `scenario:${sel}`, match: (tc) => tc.id === sel || tc.story.scenario === sel }))
20523
+ ];
20524
+ const requirements = selectors.length === 0 ? [evaluate("all scenarios", cases)] : selectors.map((s) => evaluate(s.label, cases.filter(s.match)));
20525
+ const regressions = [];
20526
+ if (baseline && args.enforceNoRegressions) {
20527
+ const before = statusMap(baseline);
20528
+ for (const tc of cases) {
20529
+ if (before.get(tc.id) === "passed" && tc.status === "failed") {
20530
+ regressions.push({ id: tc.id, title: tc.story.scenario });
20531
+ }
20532
+ }
20533
+ }
20534
+ const violations = [];
20535
+ if (baseline && args.enforceRatchet) {
20536
+ const current = new Map(cases.map((tc) => [tc.id, tc]));
20537
+ for (const base of baseline.testCases) {
20538
+ const now = current.get(base.id);
20539
+ if (!now) {
20540
+ violations.push({ id: base.id, title: base.story.scenario, kind: "removed", detail: "scenario no longer present" });
20541
+ continue;
20542
+ }
20543
+ if (ACTIVE.includes(base.status) && (now.status === "skipped" || now.status === "pending")) {
20544
+ violations.push({ id: base.id, title: base.story.scenario, kind: "disabled", detail: `${base.status} -> ${now.status}` });
20545
+ }
20546
+ const baseSteps = base.story.steps.length;
20547
+ const nowSteps = now.story.steps.length;
20548
+ if (nowSteps < baseSteps) {
20549
+ violations.push({ id: base.id, title: base.story.scenario, kind: "weakened", detail: `${baseSteps} steps -> ${nowSteps} steps` });
20550
+ }
20551
+ }
20552
+ }
20553
+ const met = requirements.every((r) => r.met) && regressions.length === 0 && violations.length === 0;
20554
+ return {
20555
+ met,
20556
+ requirements,
20557
+ regressions,
20558
+ regressionsEnforced: Boolean(baseline && args.enforceNoRegressions),
20559
+ ratchet: { enforced: Boolean(baseline && args.enforceRatchet), violations }
20560
+ };
20561
+ }
20562
+ function evaluate(selector, matched) {
20563
+ const passed = matched.filter((tc) => tc.status === "passed").length;
20564
+ const failing = matched.filter((tc) => tc.status !== "passed").map((tc) => tc.story.scenario);
20565
+ return {
20566
+ selector,
20567
+ matched: matched.length,
20568
+ passed,
20569
+ failing,
20570
+ met: matched.length > 0 && failing.length === 0
20571
+ };
20572
+ }
20573
+ function statusMap(run) {
20574
+ return new Map(run.testCases.map((tc) => [tc.id, tc.status]));
20575
+ }
20576
+ function renderGoal(report, format) {
20577
+ if (format === "json") return JSON.stringify(report, null, 2);
20578
+ const lines = [`GOAL: ${report.met ? "met" : "not met"}`];
20579
+ for (const req of report.requirements) {
20580
+ if (req.matched === 0) {
20581
+ lines.push(` ${req.selector}: no matching scenario (no proof)`);
20582
+ continue;
20583
+ }
20584
+ const tail = req.failing.length > 0 ? ` (${req.failing.length} failing)` : "";
20585
+ lines.push(` ${req.selector}: ${req.passed}/${req.matched} scenarios pass${tail}`);
20586
+ }
20587
+ if (report.regressionsEnforced) {
20588
+ if (report.regressions.length === 0) {
20589
+ lines.push(" regressions: 0");
20590
+ } else {
20591
+ lines.push(` regressions: ${report.regressions.length} (${report.regressions.map((r) => r.title).join(", ")})`);
20592
+ }
20593
+ }
20594
+ if (report.ratchet.enforced) {
20595
+ if (report.ratchet.violations.length === 0) {
20596
+ lines.push(" ratchet: clean (0 scenarios removed/weakened)");
20597
+ } else {
20598
+ lines.push(` ratchet: ${report.ratchet.violations.length} removed/weakened`);
20599
+ for (const v of report.ratchet.violations) {
20600
+ lines.push(` ${v.kind}: ${v.title} (${v.detail})`);
20601
+ }
20602
+ }
20603
+ }
20604
+ return lines.join("\n");
20605
+ }
20606
+
20607
+ // src/triage.ts
20608
+ function buildTriage(args, _deps = {}) {
20609
+ const { testCases, baseline } = args;
20610
+ const failing = testCases.filter((tc) => tc.status === "failed");
20611
+ const ranked = failing.map((tc) => {
20612
+ const regressed = baseline?.get(tc.id) === "passed";
20613
+ return {
20614
+ tc,
20615
+ regressed,
20616
+ covers: tc.story.covers ?? []
20617
+ };
20618
+ }).sort((a, b) => {
20619
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20620
+ const la = `${a.tc.sourceFile}:${a.tc.sourceLine}`;
20621
+ const lb = `${b.tc.sourceFile}:${b.tc.sourceLine}`;
20622
+ return la.localeCompare(lb);
20623
+ });
20624
+ const items = ranked.map((entry, index) => ({
20625
+ rank: index + 1,
20626
+ id: entry.tc.id,
20627
+ scenario: entry.tc.story.scenario,
20628
+ status: entry.tc.status,
20629
+ location: `${entry.tc.sourceFile}:${entry.tc.sourceLine}`,
20630
+ covers: entry.covers,
20631
+ tickets: (entry.tc.story.tickets ?? []).map((t) => t.id),
20632
+ errorMessage: failingScenarioMessage(entry.tc),
20633
+ regressed: entry.regressed,
20634
+ reason: entry.regressed ? "regression" : "failing"
20635
+ }));
20636
+ return {
20637
+ total: testCases.length,
20638
+ failing: failing.length,
20639
+ regressions: items.filter((i) => i.regressed).length,
20640
+ needsCovers: items.filter((i) => i.covers.length === 0).length,
20641
+ items
20642
+ };
20643
+ }
20644
+ function renderTriage(report, format) {
20645
+ if (format === "json") return JSON.stringify(report, null, 2);
20646
+ if (report.items.length === 0) {
20647
+ return "Nothing to triage. No failing scenarios.";
20648
+ }
20649
+ const header = report.regressions > 0 ? `${report.items.length} items to triage (${report.regressions} regression${report.regressions === 1 ? "" : "s"})` : `${report.items.length} items to triage`;
20650
+ const lines = [header, ""];
20651
+ for (const item of report.items) {
20652
+ const tag = item.regressed ? "[regression] " : "";
20653
+ lines.push(`${item.rank}. ${tag}${item.scenario}`);
20654
+ lines.push(` ${item.location}`);
20655
+ if (item.errorMessage) {
20656
+ lines.push(` \u2192 ${item.errorMessage.split("\n")[0]}`);
20657
+ }
20658
+ if (item.covers.length > 0) {
20659
+ lines.push(` fix: ${item.covers.join(", ")}`);
20660
+ } else {
20661
+ lines.push(" fix: (no covers declared \u2014 add `covers` to route this to code)");
20662
+ }
20663
+ if (item.tickets.length > 0) {
20664
+ lines.push(` ticket: ${item.tickets.join(", ")}`);
20665
+ }
20666
+ lines.push("");
20667
+ }
20668
+ if (report.needsCovers > 0) {
20669
+ lines.push(`${report.needsCovers} failing scenario(s) have no covers and can't be routed to code automatically.`);
20670
+ }
20671
+ return lines.join("\n").trimEnd();
20672
+ }
20673
+
20027
20674
  // src/review/conventions.ts
20028
20675
  var CHANGE_TAG_PREFIX = "change:";
20029
20676
  var AUDIENCE_TAG_PREFIX = "audience:";
@@ -20825,6 +21472,7 @@ var FORMAT_EXTENSIONS = {
20825
21472
  "behavior-manifest-json": ".behavior-manifest.json",
20826
21473
  markdown: ".md",
20827
21474
  "release-manifest": ".release-manifest.md",
21475
+ "traceability-matrix": ".traceability-matrix.md",
20828
21476
  html: ".html",
20829
21477
  "cucumber-html": ".cucumber.html",
20830
21478
  junit: ".junit.xml",
@@ -20985,6 +21633,9 @@ var ReportGenerator = class {
20985
21633
  searchable: options.html?.searchable ?? true,
20986
21634
  startCollapsed: options.html?.startCollapsed ?? false,
20987
21635
  embedScreenshots: options.html?.embedScreenshots ?? true,
21636
+ // Under "copy" asset mode local html files become hashed assets with
21637
+ // an iframe src instead of being inlined into the report.
21638
+ embedHtmlFiles: options.html?.embedHtmlFiles ?? (options.assetMode ?? "none") !== "copy",
20988
21639
  syntaxHighlighting: options.html?.syntaxHighlighting ?? true,
20989
21640
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
20990
21641
  markdownEnabled: options.html?.markdownEnabled ?? true,
@@ -21160,6 +21811,7 @@ var ReportGenerator = class {
21160
21811
  searchable: this.options.html.searchable,
21161
21812
  startCollapsed: this.options.html.startCollapsed,
21162
21813
  embedScreenshots: this.options.html.embedScreenshots,
21814
+ embedHtmlFiles: this.options.html.embedHtmlFiles,
21163
21815
  syntaxHighlighting: this.options.html.syntaxHighlighting,
21164
21816
  mermaidEnabled: this.options.html.mermaidEnabled,
21165
21817
  markdownEnabled: this.options.html.markdownEnabled,
@@ -21247,6 +21899,10 @@ var ReportGenerator = class {
21247
21899
  const formatter = new ReleaseManifestFormatter();
21248
21900
  return formatter.format(run);
21249
21901
  }
21902
+ case "traceability-matrix": {
21903
+ const formatter = new TraceabilityMatrixFormatter();
21904
+ return formatter.format(run);
21905
+ }
21250
21906
  case "story-report-json": {
21251
21907
  const formatter = new StoryReportJsonFormatter({
21252
21908
  pretty: this.options.storyReportJson.pretty
@@ -21922,7 +22578,7 @@ function bundleExplorerAssets(reportPath, assetsDir, baseUrl = "/stories/assets"
21922
22578
  const visit = (entries) => {
21923
22579
  for (const entry of entries ?? []) {
21924
22580
  const e = entry;
21925
- if (e.kind === "screenshot" || e.kind === "video") {
22581
+ if (e.kind === "screenshot" || e.kind === "video" || e.kind === "html") {
21926
22582
  if (typeof e.path === "string" && !isRemote(e.path) && fs13.existsSync(e.path)) {
21927
22583
  e.path = bundle(e.path);
21928
22584
  }
@@ -22044,6 +22700,7 @@ var EXIT_GENERATION = 3;
22044
22700
  var EXIT_USAGE = 4;
22045
22701
  var EXIT_COMPARE_GATE = 5;
22046
22702
  var EXIT_REVIEW_GATE = 5;
22703
+ var EXIT_AGENT_GATE = 5;
22047
22704
  var EXIT_RELEASE_GATE = 6;
22048
22705
  var HELP_TEXT = `
22049
22706
  executable-stories \u2014 Generate reports from test results JSON.
@@ -22055,6 +22712,9 @@ USAGE
22055
22712
  executable-stories gate-release <dev-run.json> <rc-run.json> [options]
22056
22713
  executable-stories review <file> --changed-files <path> [options]
22057
22714
  executable-stories list <file> [options]
22715
+ executable-stories check <file> [--baseline <path|auto>] [--check-format text|json] [--no-fail]
22716
+ executable-stories goal <file> [--require-tags <csv>] [--require-tickets <csv>] [--require-scenarios <csv>] [--baseline <path|auto>] [--no-regressions] [--goal-format text|json]
22717
+ executable-stories triage <file> [--baseline <path|auto>] [--triage-format text|json]
22058
22718
  executable-stories validate <file>
22059
22719
  executable-stories validate --stdin
22060
22720
  executable-stories init-astro [directory]
@@ -22074,6 +22734,9 @@ SUBCOMMANDS
22074
22734
  gate-release Verify a release candidate against the dev test baseline (RC gate)
22075
22735
  review Generate an Evidence Review of AI-authored changes (correlate a run to the diff)
22076
22736
  list List scenarios from a test run (text table or JSON)
22737
+ check Backpressure summary: compress passing, expand failing (GWT + error + covers); non-zero exit on failures
22738
+ goal Behavioral definition-of-done for agent loops: required scenarios pass, no regressions, no weakened scenarios (exit 0 = met, 5 = not)
22739
+ triage Discovery worklist for agent loops: failing scenarios, regressions first, each with the code it covers
22077
22740
  validate Validate a JSON file against the schema (no output generated)
22078
22741
  init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
22079
22742
  new Scaffold a docs page from a template (adr, runbook, decision-log, incident)
@@ -22084,7 +22747,7 @@ SUBCOMMANDS
22084
22747
  deploy Record deployments, show environment status, detect drift
22085
22748
 
22086
22749
  OPTIONS
22087
- --format <formats> Comma-separated formats: html, markdown, release-manifest, junit, cucumber-json, cucumber-messages, cucumber-html, astro, confluence, story-report-json, scenario-index-json, behavior-manifest-json, or custom names from config (default: html)
22750
+ --format <formats> Comma-separated formats: html, markdown, release-manifest, traceability-matrix, junit, cucumber-json, cucumber-messages, cucumber-html, astro, confluence, story-report-json, scenario-index-json, behavior-manifest-json, or custom names from config (default: html)
22088
22751
  astro Themed Markdown (for Astro docs sites with matching CSS)
22089
22752
  confluence Atlassian Document Format (ADF) JSON for Confluence / Jira
22090
22753
  behavior-manifest-json Agent-readable behavior manifest and debugger warnings
@@ -22096,6 +22759,7 @@ OPTIONS
22096
22759
  cucumber-messages Raw NDJSON (Cucumber Messages)
22097
22760
  story-report-json StoryReport v1 JSON (consumed by executable-stories-react and other UI renderers)
22098
22761
  scenario-index-json Storybook-like scenario index for agents and explorers
22762
+ traceability-matrix Requirement-first matrix (ticket -> scenarios -> covered code -> status)
22099
22763
  --config <path> Path to executable-stories.config.js (default: ./executable-stories.config.js)
22100
22764
  --input-type <type> Input type: raw, canonical, or ndjson (default: raw)
22101
22765
  --output-dir <dir> Output directory (default: reports)
@@ -22122,6 +22786,15 @@ OPTIONS
22122
22786
  --stdin Read JSON from stdin instead of file
22123
22787
  --list-format <format> list output format: text (default), json, csv, markdown-table
22124
22788
  --json-summary Deprecated alias for --list-format json
22789
+ --check-format <format> check output format: text (default) or json
22790
+ --no-fail (check) Report only \u2014 always exit 0 even when scenarios failed
22791
+ --require-tags <csv> (goal) Every scenario carrying any of these tags must pass
22792
+ --require-tickets <csv> (goal) Every scenario carrying any of these tickets must pass
22793
+ --require-scenarios <csv> (goal) These scenarios (by id or exact title) must pass
22794
+ --no-regressions (goal) Not met if any scenario regressed vs --baseline
22795
+ --no-ratchet (goal) Disable the removed/weakened-scenario guard (on by default with --baseline)
22796
+ --goal-format <format> goal output format: text (default) or json
22797
+ --triage-format <format> triage output format: text (default) or json
22125
22798
  --baseline <path|auto> Compare baseline file, or auto-pick a prior run for compare
22126
22799
  --baseline-dir <dir> Directory to scan when --baseline auto is used
22127
22800
  --pr-summary Print a PR-friendly markdown summary after compare
@@ -22146,6 +22819,31 @@ LIST
22146
22819
  list supports --include-tags, --exclude-tags for filtering
22147
22820
  list supports --input-type and --stdin
22148
22821
 
22822
+ CHECK
22823
+ check is the inner-loop "backpressure" view for coding agents: run it after tests.
22824
+ Passing scenarios collapse to a single count line; each failing scenario expands
22825
+ to its Given/When/Then steps, the step that broke, the error, and the product
22826
+ code it covers \u2014 so the agent gets an actionable signal, not a wall of green.
22827
+ check exits 5 when any scenario failed (so the agent loop pushes back); pass
22828
+ --no-fail to report only. --baseline <path|auto> adds "N regressed / N fixed"
22829
+ since the prior run. --check-format json emits the structured report.
22830
+
22831
+ GOAL
22832
+ goal is the behavioral stopping condition for an agent loop (the /goal pattern).
22833
+ It is "met" when the required scenarios pass, nothing regressed (with
22834
+ --no-regressions), and no scenario was removed, disabled, or had steps deleted
22835
+ versus --baseline (the ratchet, on by default when a baseline is given). Declare
22836
+ the target with --require-tags / --require-tickets / --require-scenarios; with
22837
+ none given, the goal is "every scenario passes". Exit 0 means met, 5 means not
22838
+ yet, so a loop can run until the verdict flips. --goal-format json for machines.
22839
+
22840
+ TRIAGE
22841
+ triage is the discovery-phase worklist for a loop. It lists failing scenarios,
22842
+ regressions first (with --baseline), each with the product code it covers, the
22843
+ error, and its tickets, so the loop can route each fix to a sub-agent. Failures
22844
+ with no covers are flagged. --triage-format json emits the work queue. triage
22845
+ always exits 0 \u2014 it reports work, it does not gate.
22846
+
22149
22847
  COMPARE
22150
22848
  compare supports --format html,markdown
22151
22849
  compare uses the same --input-type for both baseline and current files
@@ -22218,9 +22916,16 @@ EXIT CODES
22218
22916
  2 Canonical validation failure
22219
22917
  3 Formatter/generation failure
22220
22918
  4 Bad arguments / usage error
22221
- 5 Compare gate failed
22919
+ 5 Compare / review / check gate failed
22222
22920
  6 Release gate failed
22223
22921
  `.trim();
22922
+ function parseTextJsonFormat(flag, value) {
22923
+ if (value !== "text" && value !== "json") {
22924
+ console.error(`Error: ${flag} must be "text" or "json", got "${value}".`);
22925
+ process.exit(EXIT_USAGE);
22926
+ }
22927
+ return value;
22928
+ }
22224
22929
  async function parseCliArgs(argv) {
22225
22930
  const args = argv.slice(2);
22226
22931
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
@@ -22228,9 +22933,9 @@ async function parseCliArgs(argv) {
22228
22933
  process.exit(EXIT_SUCCESS);
22229
22934
  }
22230
22935
  const subcommand = args[0];
22231
- if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "gate-release" && subcommand !== "deploy" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "build-docs" && subcommand !== "new" && subcommand !== "check-links" && subcommand !== "import-openapi" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
22936
+ if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "gate-release" && subcommand !== "deploy" && subcommand !== "review" && subcommand !== "list" && subcommand !== "check" && subcommand !== "goal" && subcommand !== "triage" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "build-docs" && subcommand !== "new" && subcommand !== "check-links" && subcommand !== "import-openapi" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
22232
22937
  console.error(
22233
- `Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "gate-release", "deploy", "review", "list", "validate", "init-astro", "build-docs", "new", "check-links", "import-openapi", "publish-confluence", or "publish-jira".`
22938
+ `Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "gate-release", "deploy", "review", "list", "check", "goal", "triage", "validate", "init-astro", "build-docs", "new", "check-links", "import-openapi", "publish-confluence", or "publish-jira".`
22234
22939
  );
22235
22940
  process.exit(EXIT_USAGE);
22236
22941
  }
@@ -22318,6 +23023,15 @@ async function parseCliArgs(argv) {
22318
23023
  stdin: { type: "boolean", default: false },
22319
23024
  "json-summary": { type: "boolean", default: false },
22320
23025
  "list-format": { type: "string", default: "text" },
23026
+ "check-format": { type: "string", default: "text" },
23027
+ "no-fail": { type: "boolean", default: false },
23028
+ "require-tags": { type: "string" },
23029
+ "require-tickets": { type: "string" },
23030
+ "require-scenarios": { type: "string" },
23031
+ "no-regressions": { type: "boolean", default: false },
23032
+ "no-ratchet": { type: "boolean", default: false },
23033
+ "goal-format": { type: "string", default: "text" },
23034
+ "triage-format": { type: "string", default: "text" },
22321
23035
  "emit-canonical": { type: "string" },
22322
23036
  "slack-webhook": { type: "string" },
22323
23037
  "teams-webhook": { type: "string" },
@@ -22388,7 +23102,7 @@ async function parseCliArgs(argv) {
22388
23102
  }
22389
23103
  const pluginConfig = await loadConfig(values["config"]);
22390
23104
  const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
22391
- const builtInFormats = /* @__PURE__ */ new Set(["astro", "behavior-manifest-json", "confluence", "html", "markdown", "release-manifest", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "scenario-index-json", "story-report-json"]);
23105
+ const builtInFormats = /* @__PURE__ */ new Set(["astro", "behavior-manifest-json", "confluence", "html", "markdown", "release-manifest", "traceability-matrix", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "scenario-index-json", "story-report-json"]);
22392
23106
  const formatStr = values.format;
22393
23107
  const allRequestedFormats = formatStr.split(",").map((f) => f.trim());
22394
23108
  const builtInRequested = allRequestedFormats.filter((f) => builtInFormats.has(f));
@@ -22396,7 +23110,7 @@ async function parseCliArgs(argv) {
22396
23110
  const unknownFormats = allRequestedFormats.filter((f) => !builtInFormats.has(f) && !customFormatterNames.has(f));
22397
23111
  if (unknownFormats.length > 0) {
22398
23112
  const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
22399
- console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, behavior-manifest-json, confluence, html, markdown, release-manifest, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
23113
+ console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, behavior-manifest-json, confluence, html, markdown, release-manifest, traceability-matrix, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
22400
23114
  process.exit(EXIT_USAGE);
22401
23115
  }
22402
23116
  const formats = builtInRequested;
@@ -22484,10 +23198,14 @@ async function parseCliArgs(argv) {
22484
23198
  console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
22485
23199
  process.exit(EXIT_USAGE);
22486
23200
  }
23201
+ const checkFormat = parseTextJsonFormat("--check-format", values["check-format"]);
23202
+ const goalFormat = parseTextJsonFormat("--goal-format", values["goal-format"]);
23203
+ const triageFormat = parseTextJsonFormat("--triage-format", values["triage-format"]);
22487
23204
  const cliArgs = {
22488
23205
  subcommand,
22489
23206
  inputFile,
22490
23207
  baselineFile,
23208
+ baselineArg: baselineValue,
22491
23209
  currentFile,
22492
23210
  baselineMode,
22493
23211
  baselineDir: values["baseline-dir"],
@@ -22514,6 +23232,15 @@ async function parseCliArgs(argv) {
22514
23232
  htmlThemePicker: values["html-theme-picker"],
22515
23233
  jsonSummary: values["json-summary"],
22516
23234
  listFormat: values["list-format"],
23235
+ checkFormat,
23236
+ noFail: values["no-fail"],
23237
+ requireTags: parseGlobs(values["require-tags"]),
23238
+ requireTickets: parseGlobs(values["require-tickets"]),
23239
+ requireScenarios: parseGlobs(values["require-scenarios"]),
23240
+ noRegressions: values["no-regressions"],
23241
+ noRatchet: values["no-ratchet"],
23242
+ goalFormat,
23243
+ triageFormat,
22517
23244
  emitCanonical: values["emit-canonical"],
22518
23245
  slackWebhook,
22519
23246
  teamsWebhook,
@@ -22730,6 +23457,24 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
22730
23457
  }
22731
23458
  return picked.file;
22732
23459
  }
23460
+ function resolveBaselineRun(args, currentRun) {
23461
+ if (!args.baselineArg) return void 0;
23462
+ let baselineFile;
23463
+ if (args.baselineArg === "auto") {
23464
+ if (!args.inputFile) {
23465
+ console.error("Error: --baseline auto requires a current input file (not --stdin).");
23466
+ process.exit(EXIT_USAGE);
23467
+ }
23468
+ baselineFile = resolveBaselineAuto(args.inputFile, currentRun, args);
23469
+ } else {
23470
+ baselineFile = args.baselineArg;
23471
+ }
23472
+ return applySelection(normalizeRunFromText(readFileInput(baselineFile), args).run, args);
23473
+ }
23474
+ function resolveBaselineStatusMap(args, currentRun) {
23475
+ const baselineRun = resolveBaselineRun(args, currentRun);
23476
+ return baselineRun ? new Map(baselineRun.testCases.map((tc) => [tc.id, tc.status])) : void 0;
23477
+ }
22733
23478
  async function main() {
22734
23479
  const { args, pluginConfig, customRequested } = await parseCliArgs(process.argv);
22735
23480
  const startMs = Date.now();
@@ -22840,6 +23585,51 @@ async function main() {
22840
23585
  console.log(output);
22841
23586
  process.exit(EXIT_SUCCESS);
22842
23587
  }
23588
+ if (args.subcommand === "check") {
23589
+ const text3 = await readInput(args);
23590
+ const run = applySelection(normalizeRunFromText(text3, args).run, args);
23591
+ const baseline = resolveBaselineStatusMap(args, run);
23592
+ const report = buildCheck(
23593
+ { testCases: run.testCases, baseline, format: args.checkFormat },
23594
+ {}
23595
+ );
23596
+ console.log(renderCheck(report, args.checkFormat));
23597
+ if (report.summary.failed > 0 && !args.noFail) {
23598
+ process.exit(EXIT_AGENT_GATE);
23599
+ }
23600
+ process.exit(EXIT_SUCCESS);
23601
+ }
23602
+ if (args.subcommand === "goal") {
23603
+ const text3 = await readInput(args);
23604
+ const run = applySelection(normalizeRunFromText(text3, args).run, args);
23605
+ const baseline = resolveBaselineRun(args, run);
23606
+ const report = buildGoal(
23607
+ {
23608
+ run,
23609
+ baseline,
23610
+ requireTags: args.requireTags,
23611
+ requireTickets: args.requireTickets,
23612
+ requireScenarios: args.requireScenarios,
23613
+ enforceNoRegressions: args.noRegressions,
23614
+ enforceRatchet: !args.noRatchet,
23615
+ format: args.goalFormat
23616
+ },
23617
+ {}
23618
+ );
23619
+ console.log(renderGoal(report, args.goalFormat));
23620
+ process.exit(report.met ? EXIT_SUCCESS : EXIT_AGENT_GATE);
23621
+ }
23622
+ if (args.subcommand === "triage") {
23623
+ const text3 = await readInput(args);
23624
+ const run = applySelection(normalizeRunFromText(text3, args).run, args);
23625
+ const baseline = resolveBaselineStatusMap(args, run);
23626
+ const report = buildTriage(
23627
+ { testCases: run.testCases, baseline, format: args.triageFormat },
23628
+ {}
23629
+ );
23630
+ console.log(renderTriage(report, args.triageFormat));
23631
+ process.exit(EXIT_SUCCESS);
23632
+ }
22843
23633
  if (args.subcommand === "watch") {
22844
23634
  if (!args.inputFile) {
22845
23635
  console.error("Error: watch requires an input file (the raw-run JSON the framework writes).");
@@ -23958,6 +24748,15 @@ function createDefaultCliArgs() {
23958
24748
  htmlThemePicker: false,
23959
24749
  jsonSummary: false,
23960
24750
  listFormat: "text",
24751
+ checkFormat: "text",
24752
+ noFail: false,
24753
+ requireTags: [],
24754
+ requireTickets: [],
24755
+ requireScenarios: [],
24756
+ noRegressions: false,
24757
+ noRatchet: false,
24758
+ goalFormat: "text",
24759
+ triageFormat: "text",
23961
24760
  notify: "never",
23962
24761
  maxFailedTests: 5,
23963
24762
  maxHistoryRuns: 10,