executable-stories-formatters 0.12.0 → 0.14.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/index.js CHANGED
@@ -526,14 +526,14 @@ var CucumberJsonFormatter = class {
526
526
  duration: 0
527
527
  };
528
528
  }
529
- const statusMap = {
529
+ const statusMap2 = {
530
530
  passed: "passed",
531
531
  failed: "failed",
532
532
  skipped: "skipped",
533
533
  pending: "pending"
534
534
  };
535
535
  const stepResult = {
536
- status: statusMap[result.status] ?? "undefined",
536
+ status: statusMap2[result.status] ?? "undefined",
537
537
  // Duration in nanoseconds (Cucumber uses nanoseconds)
538
538
  duration: result.durationMs * 1e6
539
539
  };
@@ -15124,7 +15124,9 @@ var MarkdownFormatter = class {
15124
15124
  ticketUrlTemplate: options.ticketUrlTemplate,
15125
15125
  traceUrlTemplate: options.traceUrlTemplate,
15126
15126
  includeSourceLinks: options.includeSourceLinks ?? true,
15127
- customRenderers: options.customRenderers
15127
+ customRenderers: options.customRenderers,
15128
+ scenarioAnchor: options.scenarioAnchor,
15129
+ scenarioBadge: options.scenarioBadge
15128
15130
  };
15129
15131
  }
15130
15132
  /**
@@ -15311,6 +15313,11 @@ var MarkdownFormatter = class {
15311
15313
  * Render a single scenario.
15312
15314
  */
15313
15315
  renderScenario(lines, tc) {
15316
+ const anchorId = this.options.scenarioAnchor?.(tc);
15317
+ if (anchorId) {
15318
+ lines.push(`<a id="${anchorId}"></a>`);
15319
+ lines.push("");
15320
+ }
15314
15321
  if (this.options.customRenderers?.renderScenarioHeader) {
15315
15322
  const custom = this.options.customRenderers.renderScenarioHeader(tc);
15316
15323
  if (custom !== null) {
@@ -15326,6 +15333,10 @@ var MarkdownFormatter = class {
15326
15333
  icon = this.getStatusIcon(tc.status) + " ";
15327
15334
  }
15328
15335
  lines.push(`${headingPrefix} ${icon}${tc.story.scenario}`);
15336
+ const badge = this.options.scenarioBadge?.(tc);
15337
+ if (badge) {
15338
+ lines.push(badge);
15339
+ }
15329
15340
  if (this.options.includeSourceLinks && this.options.permalinkBaseUrl && tc.sourceFile !== "unknown") {
15330
15341
  const permalink = this.buildPermalink(tc);
15331
15342
  lines.push(`Source: [${tc.sourceFile}](${permalink})`);
@@ -15717,6 +15728,132 @@ function escapePipe(value) {
15717
15728
  return value.replace(/\|/g, "\\|");
15718
15729
  }
15719
15730
 
15731
+ // src/formatters/traceability-matrix.ts
15732
+ var TraceabilityMatrixFormatter = class {
15733
+ format(run) {
15734
+ const matrix = toTraceabilityMatrix(run);
15735
+ const lines = [];
15736
+ lines.push("# Traceability Matrix");
15737
+ lines.push("");
15738
+ lines.push(`Generated: ${matrix.generatedAt}`);
15739
+ lines.push(`Run: ${matrix.run.startedAt} to ${matrix.run.finishedAt}`);
15740
+ if (matrix.run.branch) lines.push(`Branch: ${matrix.run.branch}`);
15741
+ if (matrix.run.gitSha) lines.push(`Commit: ${matrix.run.gitSha}`);
15742
+ lines.push("");
15743
+ lines.push("| Requirements | Verified | Failing | Scenarios | Untraced |");
15744
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15745
+ lines.push(
15746
+ `| ${matrix.summary.requirements} | ${matrix.summary.requirementsVerified} | ${matrix.summary.requirementsFailing} | ${matrix.summary.scenarios} | ${matrix.summary.untracedScenarios} |`
15747
+ );
15748
+ lines.push("");
15749
+ for (const req of matrix.requirements) {
15750
+ const heading2 = req.url ? `[${req.ticket}](${req.url})` : req.ticket;
15751
+ lines.push(`## ${heading2}`);
15752
+ lines.push("");
15753
+ lines.push(`Status: ${renderRequirementStatus(req.status)}`);
15754
+ if (req.covers.length > 0) {
15755
+ lines.push(`Covers: ${req.covers.map((path12) => `\`${path12}\``).join(", ")}`);
15756
+ }
15757
+ lines.push("");
15758
+ lines.push("| Status | Scenario | Source | Covers |");
15759
+ lines.push("| --- | --- | --- | --- |");
15760
+ for (const scenario of req.scenarios) {
15761
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15762
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path12) => `\`${path12}\``).join(", ") : "";
15763
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
15764
+ }
15765
+ lines.push("");
15766
+ }
15767
+ if (matrix.untraced.length > 0) {
15768
+ lines.push("## Untraced scenarios");
15769
+ lines.push("");
15770
+ lines.push("Behavior with no requirement link. Add a `ticket` to each so it appears against a requirement.");
15771
+ lines.push("");
15772
+ lines.push("| Status | Scenario | Source |");
15773
+ lines.push("| --- | --- | --- |");
15774
+ for (const scenario of matrix.untraced) {
15775
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15776
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` |`);
15777
+ }
15778
+ lines.push("");
15779
+ }
15780
+ return lines.join("\n").trimEnd();
15781
+ }
15782
+ };
15783
+ function toTraceabilityMatrix(run) {
15784
+ const sorted = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id));
15785
+ const byTicket = /* @__PURE__ */ new Map();
15786
+ const untraced = [];
15787
+ for (const tc of sorted) {
15788
+ const tickets = tc.story.tickets ?? [];
15789
+ if (tickets.length === 0) {
15790
+ untraced.push({
15791
+ id: tc.id,
15792
+ title: tc.story.scenario,
15793
+ status: tc.status,
15794
+ sourceFile: tc.sourceFile,
15795
+ sourceLine: tc.sourceLine
15796
+ });
15797
+ continue;
15798
+ }
15799
+ for (const ticket of tickets) {
15800
+ const entry = byTicket.get(ticket.id) ?? { url: ticket.url, cases: [] };
15801
+ if (!entry.url && ticket.url) entry.url = ticket.url;
15802
+ entry.cases.push(tc);
15803
+ byTicket.set(ticket.id, entry);
15804
+ }
15805
+ }
15806
+ const requirements = [...byTicket.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ticket, entry]) => {
15807
+ const scenarios = entry.cases.map((tc) => ({
15808
+ id: tc.id,
15809
+ title: tc.story.scenario,
15810
+ status: tc.status,
15811
+ sourceFile: tc.sourceFile,
15812
+ sourceLine: tc.sourceLine,
15813
+ covers: tc.story.covers ?? []
15814
+ }));
15815
+ const covers = [...new Set(scenarios.flatMap((s) => s.covers))].sort();
15816
+ return { ticket, url: entry.url, status: requirementStatus(entry.cases), scenarios, covers };
15817
+ });
15818
+ return {
15819
+ schemaVersion: "1.0",
15820
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15821
+ run: {
15822
+ startedAt: new Date(run.startedAtMs).toISOString(),
15823
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15824
+ gitSha: run.gitSha,
15825
+ branch: run.ci?.branch
15826
+ },
15827
+ summary: {
15828
+ requirements: requirements.length,
15829
+ requirementsVerified: requirements.filter((r) => r.status === "verified").length,
15830
+ requirementsFailing: requirements.filter((r) => r.status === "failing").length,
15831
+ scenarios: run.testCases.length,
15832
+ untracedScenarios: untraced.length
15833
+ },
15834
+ requirements,
15835
+ untraced
15836
+ };
15837
+ }
15838
+ function requirementStatus(cases) {
15839
+ if (cases.some((tc) => tc.status === "failed")) return "failing";
15840
+ if (cases.some((tc) => tc.status === "passed")) return "verified";
15841
+ return "incomplete";
15842
+ }
15843
+ function renderRequirementStatus(status) {
15844
+ switch (status) {
15845
+ case "verified":
15846
+ return "verified (all scenarios passed)";
15847
+ case "failing":
15848
+ return "failing (a scenario failed)";
15849
+ default:
15850
+ return "incomplete (no scenario passed yet)";
15851
+ }
15852
+ }
15853
+ function escapePipe2(value) {
15854
+ return value.replace(/\|/g, "\\|");
15855
+ }
15856
+
15720
15857
  // src/formatters/cucumber-messages/synthesize-feature.ts
15721
15858
  function extractFeatureName(testCases, uri) {
15722
15859
  for (const tc of testCases) {
@@ -18016,12 +18153,16 @@ function groupBy7(items, keyFn) {
18016
18153
  import * as fs5 from "fs";
18017
18154
  import * as path6 from "path";
18018
18155
  var SKIP_PREFIXES = ["http://", "https://", "data:", "#"];
18019
- function isLocalPath(src) {
18156
+ function isRemoteRef(src) {
18020
18157
  const trimmed = src.trim();
18021
- if (SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
18022
- return false;
18023
- }
18024
- return !path6.posix.isAbsolute(trimmed) && !path6.win32.isAbsolute(trimmed);
18158
+ return SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
18159
+ }
18160
+ function isAbsoluteRef(src) {
18161
+ const trimmed = src.trim();
18162
+ return path6.posix.isAbsolute(trimmed) || path6.win32.isAbsolute(trimmed);
18163
+ }
18164
+ function isRelativeLocalPath(src) {
18165
+ return !isRemoteRef(src) && !isAbsoluteRef(src);
18025
18166
  }
18026
18167
  function stripCodeContent(markdown) {
18027
18168
  let result = markdown.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$/gm, "");
@@ -18037,21 +18178,21 @@ function scanMarkdownAssets(markdown) {
18037
18178
  let match;
18038
18179
  while ((match = mdImageRe.exec(stripped)) !== null) {
18039
18180
  const src = match[1].trim();
18040
- if (isLocalPath(src)) {
18181
+ if (!isRemoteRef(src)) {
18041
18182
  found.add(src);
18042
18183
  }
18043
18184
  }
18044
18185
  const htmlSrcRe = /<(?:img|source|video)[^>]+\bsrc=["']([^"']+)["'][^>]*>/gi;
18045
18186
  while ((match = htmlSrcRe.exec(stripped)) !== null) {
18046
18187
  const src = match[1].trim();
18047
- if (isLocalPath(src)) {
18188
+ if (!isRemoteRef(src)) {
18048
18189
  found.add(src);
18049
18190
  }
18050
18191
  }
18051
18192
  const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
18052
18193
  while ((match = posterRe.exec(stripped)) !== null) {
18053
18194
  const src = match[1].trim();
18054
- if (isLocalPath(src)) {
18195
+ if (!isRemoteRef(src)) {
18055
18196
  found.add(src);
18056
18197
  }
18057
18198
  }
@@ -18077,48 +18218,21 @@ function isCode(segment) {
18077
18218
  const trimmed = segment.trimStart();
18078
18219
  return trimmed.startsWith("`") || trimmed.startsWith("~") || trimmed.startsWith("<pre") || trimmed.startsWith("<code");
18079
18220
  }
18221
+ function resolveRewrite(trimmed, assetsBaseUrl, pathMap) {
18222
+ if (isRemoteRef(trimmed)) return null;
18223
+ if (pathMap) {
18224
+ const mapped = pathMap.get(trimmed);
18225
+ return mapped === void 0 ? null : `${assetsBaseUrl}/${mapped}`;
18226
+ }
18227
+ if (!isRelativeLocalPath(trimmed)) return null;
18228
+ return `${assetsBaseUrl}/${trimmed}`;
18229
+ }
18080
18230
  function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
18081
- let result = prose;
18082
- result = result.replace(
18083
- /(!\[[^\]]*\]\()([^)"'\s]+)((?:\s+["'][^"']*["'])?\s*\))/g,
18084
- (full, pre, src, post) => {
18085
- const trimmed = src.trim();
18086
- if (!isLocalPath(trimmed)) return full;
18087
- if (pathMap) {
18088
- const mapped = pathMap.get(trimmed);
18089
- if (mapped === void 0) return full;
18090
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18091
- }
18092
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18093
- }
18094
- );
18095
- result = result.replace(
18096
- /(<(?:img|source|video)[^>]+\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
18097
- (full, pre, src, post) => {
18098
- const trimmed = src.trim();
18099
- if (!isLocalPath(trimmed)) return full;
18100
- if (pathMap) {
18101
- const mapped = pathMap.get(trimmed);
18102
- if (mapped === void 0) return full;
18103
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18104
- }
18105
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18106
- }
18107
- );
18108
- result = result.replace(
18109
- /(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
18110
- (full, pre, src, post) => {
18111
- const trimmed = src.trim();
18112
- if (!isLocalPath(trimmed)) return full;
18113
- if (pathMap) {
18114
- const mapped = pathMap.get(trimmed);
18115
- if (mapped === void 0) return full;
18116
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18117
- }
18118
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18119
- }
18120
- );
18121
- return result;
18231
+ const rewrite = (full, pre, src, post) => {
18232
+ const target = resolveRewrite(src.trim(), assetsBaseUrl, pathMap);
18233
+ return target === null ? full : `${pre}${target}${post}`;
18234
+ };
18235
+ return prose.replace(/(!\[[^\]]*\]\()([^)"'\s]+)((?:\s+["'][^"']*["'])?\s*\))/g, rewrite).replace(/(<(?:img|source|video)[^>]+\bsrc=["'])([^"']+)(["'][^>]*>)/gi, rewrite).replace(/(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi, rewrite);
18122
18236
  }
18123
18237
  function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
18124
18238
  return splitByCode(markdown).map((seg) => isCode(seg) ? seg : rewriteProseSegment(seg, assetsBaseUrl, pathMap)).join("");
@@ -18135,8 +18249,9 @@ function copyMarkdownAssets(options) {
18135
18249
  const pathMap = /* @__PURE__ */ new Map();
18136
18250
  const missing = [];
18137
18251
  for (const ref of refs) {
18138
- const absPath = path6.resolve(markdownDir, ref);
18252
+ const absPath = isAbsoluteRef(ref) ? ref : path6.resolve(markdownDir, ref);
18139
18253
  if (!fs5.existsSync(absPath)) {
18254
+ if (isAbsoluteRef(ref)) continue;
18140
18255
  if (!allowMissing) {
18141
18256
  throw new Error(`Asset not found: ${absPath}`);
18142
18257
  }
@@ -20464,6 +20579,280 @@ function collectDocKinds(testCase) {
20464
20579
  return [...kinds].sort();
20465
20580
  }
20466
20581
 
20582
+ // src/scenario-failure.ts
20583
+ function failingScenarioMessage(tc) {
20584
+ const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
20585
+ return failingStep?.errorMessage ?? tc.errorMessage;
20586
+ }
20587
+
20588
+ // src/check.ts
20589
+ var ICON_PASS = "\u2713";
20590
+ var ICON_FAIL = "\u2717";
20591
+ var ICON_SKIP = "\u2298";
20592
+ var ICON_PENDING = "\u23F3";
20593
+ var ICON_WARN = "\u26A0";
20594
+ function buildCheck(args, _deps = {}) {
20595
+ const { testCases, baseline } = args;
20596
+ const summary = {
20597
+ total: testCases.length,
20598
+ passed: testCases.filter((tc) => tc.status === "passed").length,
20599
+ failed: testCases.filter((tc) => tc.status === "failed").length,
20600
+ skipped: testCases.filter((tc) => tc.status === "skipped").length,
20601
+ pending: testCases.filter((tc) => tc.status === "pending").length
20602
+ };
20603
+ let regressed = 0;
20604
+ let fixed = 0;
20605
+ if (baseline) {
20606
+ for (const tc of testCases) {
20607
+ const before = baseline.get(tc.id);
20608
+ if (before === "passed" && tc.status === "failed") regressed += 1;
20609
+ if (before === "failed" && tc.status === "passed") fixed += 1;
20610
+ }
20611
+ }
20612
+ const failures = testCases.filter((tc) => tc.status === "failed").map((tc) => toFailure(tc, baseline)).sort((a, b) => {
20613
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20614
+ return a.location.localeCompare(b.location);
20615
+ });
20616
+ return {
20617
+ summary,
20618
+ failures,
20619
+ regressed,
20620
+ fixed,
20621
+ comparedToBaseline: baseline !== void 0
20622
+ };
20623
+ }
20624
+ function toFailure(tc, baseline) {
20625
+ const failedIndexes = new Set(
20626
+ tc.stepResults.filter((s) => s.status === "failed").map((s) => s.index)
20627
+ );
20628
+ const steps = tc.story.steps.map((step, index) => ({
20629
+ keyword: step.keyword,
20630
+ text: step.text,
20631
+ failed: failedIndexes.has(index)
20632
+ }));
20633
+ return {
20634
+ id: tc.id,
20635
+ scenario: tc.story.scenario,
20636
+ location: `${tc.sourceFile}:${tc.sourceLine}`,
20637
+ steps,
20638
+ errorMessage: failingScenarioMessage(tc),
20639
+ covers: tc.story.covers ?? [],
20640
+ tickets: (tc.story.tickets ?? []).map((t) => t.id),
20641
+ regressed: baseline?.get(tc.id) === "passed"
20642
+ };
20643
+ }
20644
+ function renderCheck(report, format) {
20645
+ return format === "json" ? JSON.stringify(report, null, 2) : renderCheckText(report);
20646
+ }
20647
+ function renderCheckText(report) {
20648
+ const { summary, failures } = report;
20649
+ const headlineParts = [`${ICON_PASS} ${summary.passed} passed`];
20650
+ if (summary.failed > 0) headlineParts.push(`${ICON_FAIL} ${summary.failed} failed`);
20651
+ if (summary.skipped > 0) headlineParts.push(`${ICON_SKIP} ${summary.skipped} skipped`);
20652
+ if (summary.pending > 0) headlineParts.push(`${ICON_PENDING} ${summary.pending} pending`);
20653
+ const headline = `${headlineParts.join(" ")} (${summary.total} scenarios)`;
20654
+ if (failures.length === 0) {
20655
+ const lines2 = [headline];
20656
+ if (report.comparedToBaseline && report.fixed > 0) {
20657
+ lines2.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20658
+ }
20659
+ lines2.push("All scenarios green.");
20660
+ return lines2.join("\n");
20661
+ }
20662
+ const lines = [headline, ""];
20663
+ for (const f of failures) {
20664
+ lines.push(`${ICON_FAIL} ${f.scenario}${f.regressed ? " (regressed)" : ""}`);
20665
+ lines.push(` ${f.location}`);
20666
+ for (const step of f.steps) {
20667
+ const marker = step.failed ? ` ${ICON_FAIL} ` : " ";
20668
+ lines.push(`${marker}${step.keyword} ${step.text}`);
20669
+ }
20670
+ if (f.errorMessage) {
20671
+ const firstLine = f.errorMessage.split("\n")[0];
20672
+ lines.push(` \u2192 ${firstLine}`);
20673
+ }
20674
+ if (f.covers.length > 0) {
20675
+ lines.push(` covers: ${f.covers.join(", ")}`);
20676
+ }
20677
+ if (f.tickets.length > 0) {
20678
+ lines.push(` ticket: ${f.tickets.join(", ")}`);
20679
+ }
20680
+ lines.push("");
20681
+ }
20682
+ if (report.comparedToBaseline) {
20683
+ if (report.regressed > 0) {
20684
+ lines.push(`${ICON_WARN} ${report.regressed} regressed since baseline (was passing).`);
20685
+ }
20686
+ if (report.fixed > 0) {
20687
+ lines.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20688
+ }
20689
+ if (report.regressed === 0 && report.fixed === 0) {
20690
+ lines.push("No status changes vs. baseline.");
20691
+ }
20692
+ }
20693
+ return lines.join("\n").trimEnd();
20694
+ }
20695
+
20696
+ // src/goal.ts
20697
+ var ACTIVE = ["passed", "failed"];
20698
+ function buildGoal(args, _deps = {}) {
20699
+ const { run, baseline } = args;
20700
+ const cases = run.testCases;
20701
+ const selectors = [
20702
+ ...args.requireTags.map((tag) => ({ label: `tag:${tag}`, match: (tc) => tc.tags.includes(tag) })),
20703
+ ...args.requireTickets.map((id) => ({ label: `ticket:${id}`, match: (tc) => (tc.story.tickets ?? []).some((t) => t.id === id) })),
20704
+ ...args.requireScenarios.map((sel) => ({ label: `scenario:${sel}`, match: (tc) => tc.id === sel || tc.story.scenario === sel }))
20705
+ ];
20706
+ const requirements = selectors.length === 0 ? [evaluate("all scenarios", cases)] : selectors.map((s) => evaluate(s.label, cases.filter(s.match)));
20707
+ const regressions = [];
20708
+ if (baseline && args.enforceNoRegressions) {
20709
+ const before = statusMap(baseline);
20710
+ for (const tc of cases) {
20711
+ if (before.get(tc.id) === "passed" && tc.status === "failed") {
20712
+ regressions.push({ id: tc.id, title: tc.story.scenario });
20713
+ }
20714
+ }
20715
+ }
20716
+ const violations = [];
20717
+ if (baseline && args.enforceRatchet) {
20718
+ const current = new Map(cases.map((tc) => [tc.id, tc]));
20719
+ for (const base of baseline.testCases) {
20720
+ const now = current.get(base.id);
20721
+ if (!now) {
20722
+ violations.push({ id: base.id, title: base.story.scenario, kind: "removed", detail: "scenario no longer present" });
20723
+ continue;
20724
+ }
20725
+ if (ACTIVE.includes(base.status) && (now.status === "skipped" || now.status === "pending")) {
20726
+ violations.push({ id: base.id, title: base.story.scenario, kind: "disabled", detail: `${base.status} -> ${now.status}` });
20727
+ }
20728
+ const baseSteps = base.story.steps.length;
20729
+ const nowSteps = now.story.steps.length;
20730
+ if (nowSteps < baseSteps) {
20731
+ violations.push({ id: base.id, title: base.story.scenario, kind: "weakened", detail: `${baseSteps} steps -> ${nowSteps} steps` });
20732
+ }
20733
+ }
20734
+ }
20735
+ const met = requirements.every((r) => r.met) && regressions.length === 0 && violations.length === 0;
20736
+ return {
20737
+ met,
20738
+ requirements,
20739
+ regressions,
20740
+ regressionsEnforced: Boolean(baseline && args.enforceNoRegressions),
20741
+ ratchet: { enforced: Boolean(baseline && args.enforceRatchet), violations }
20742
+ };
20743
+ }
20744
+ function evaluate(selector, matched) {
20745
+ const passed = matched.filter((tc) => tc.status === "passed").length;
20746
+ const failing = matched.filter((tc) => tc.status !== "passed").map((tc) => tc.story.scenario);
20747
+ return {
20748
+ selector,
20749
+ matched: matched.length,
20750
+ passed,
20751
+ failing,
20752
+ met: matched.length > 0 && failing.length === 0
20753
+ };
20754
+ }
20755
+ function statusMap(run) {
20756
+ return new Map(run.testCases.map((tc) => [tc.id, tc.status]));
20757
+ }
20758
+ function renderGoal(report, format) {
20759
+ if (format === "json") return JSON.stringify(report, null, 2);
20760
+ const lines = [`GOAL: ${report.met ? "met" : "not met"}`];
20761
+ for (const req of report.requirements) {
20762
+ if (req.matched === 0) {
20763
+ lines.push(` ${req.selector}: no matching scenario (no proof)`);
20764
+ continue;
20765
+ }
20766
+ const tail = req.failing.length > 0 ? ` (${req.failing.length} failing)` : "";
20767
+ lines.push(` ${req.selector}: ${req.passed}/${req.matched} scenarios pass${tail}`);
20768
+ }
20769
+ if (report.regressionsEnforced) {
20770
+ if (report.regressions.length === 0) {
20771
+ lines.push(" regressions: 0");
20772
+ } else {
20773
+ lines.push(` regressions: ${report.regressions.length} (${report.regressions.map((r) => r.title).join(", ")})`);
20774
+ }
20775
+ }
20776
+ if (report.ratchet.enforced) {
20777
+ if (report.ratchet.violations.length === 0) {
20778
+ lines.push(" ratchet: clean (0 scenarios removed/weakened)");
20779
+ } else {
20780
+ lines.push(` ratchet: ${report.ratchet.violations.length} removed/weakened`);
20781
+ for (const v of report.ratchet.violations) {
20782
+ lines.push(` ${v.kind}: ${v.title} (${v.detail})`);
20783
+ }
20784
+ }
20785
+ }
20786
+ return lines.join("\n");
20787
+ }
20788
+
20789
+ // src/triage.ts
20790
+ function buildTriage(args, _deps = {}) {
20791
+ const { testCases, baseline } = args;
20792
+ const failing = testCases.filter((tc) => tc.status === "failed");
20793
+ const ranked = failing.map((tc) => {
20794
+ const regressed = baseline?.get(tc.id) === "passed";
20795
+ return {
20796
+ tc,
20797
+ regressed,
20798
+ covers: tc.story.covers ?? []
20799
+ };
20800
+ }).sort((a, b) => {
20801
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20802
+ const la = `${a.tc.sourceFile}:${a.tc.sourceLine}`;
20803
+ const lb = `${b.tc.sourceFile}:${b.tc.sourceLine}`;
20804
+ return la.localeCompare(lb);
20805
+ });
20806
+ const items = ranked.map((entry, index) => ({
20807
+ rank: index + 1,
20808
+ id: entry.tc.id,
20809
+ scenario: entry.tc.story.scenario,
20810
+ status: entry.tc.status,
20811
+ location: `${entry.tc.sourceFile}:${entry.tc.sourceLine}`,
20812
+ covers: entry.covers,
20813
+ tickets: (entry.tc.story.tickets ?? []).map((t) => t.id),
20814
+ errorMessage: failingScenarioMessage(entry.tc),
20815
+ regressed: entry.regressed,
20816
+ reason: entry.regressed ? "regression" : "failing"
20817
+ }));
20818
+ return {
20819
+ total: testCases.length,
20820
+ failing: failing.length,
20821
+ regressions: items.filter((i) => i.regressed).length,
20822
+ needsCovers: items.filter((i) => i.covers.length === 0).length,
20823
+ items
20824
+ };
20825
+ }
20826
+ function renderTriage(report, format) {
20827
+ if (format === "json") return JSON.stringify(report, null, 2);
20828
+ if (report.items.length === 0) {
20829
+ return "Nothing to triage. No failing scenarios.";
20830
+ }
20831
+ const header = report.regressions > 0 ? `${report.items.length} items to triage (${report.regressions} regression${report.regressions === 1 ? "" : "s"})` : `${report.items.length} items to triage`;
20832
+ const lines = [header, ""];
20833
+ for (const item of report.items) {
20834
+ const tag = item.regressed ? "[regression] " : "";
20835
+ lines.push(`${item.rank}. ${tag}${item.scenario}`);
20836
+ lines.push(` ${item.location}`);
20837
+ if (item.errorMessage) {
20838
+ lines.push(` \u2192 ${item.errorMessage.split("\n")[0]}`);
20839
+ }
20840
+ if (item.covers.length > 0) {
20841
+ lines.push(` fix: ${item.covers.join(", ")}`);
20842
+ } else {
20843
+ lines.push(" fix: (no covers declared \u2014 add `covers` to route this to code)");
20844
+ }
20845
+ if (item.tickets.length > 0) {
20846
+ lines.push(` ticket: ${item.tickets.join(", ")}`);
20847
+ }
20848
+ lines.push("");
20849
+ }
20850
+ if (report.needsCovers > 0) {
20851
+ lines.push(`${report.needsCovers} failing scenario(s) have no covers and can't be routed to code automatically.`);
20852
+ }
20853
+ return lines.join("\n").trimEnd();
20854
+ }
20855
+
20467
20856
  // src/review/conventions.ts
20468
20857
  var CHANGE_TAG_PREFIX = "change:";
20469
20858
  var AUDIENCE_TAG_PREFIX = "audience:";
@@ -21265,6 +21654,7 @@ var FORMAT_EXTENSIONS = {
21265
21654
  "behavior-manifest-json": ".behavior-manifest.json",
21266
21655
  markdown: ".md",
21267
21656
  "release-manifest": ".release-manifest.md",
21657
+ "traceability-matrix": ".traceability-matrix.md",
21268
21658
  html: ".html",
21269
21659
  "cucumber-html": ".cucumber.html",
21270
21660
  junit: ".junit.xml",
@@ -21487,7 +21877,9 @@ var ReportGenerator = class {
21487
21877
  permalinkBaseUrl: options.astro?.markdown?.permalinkBaseUrl,
21488
21878
  ticketUrlTemplate: options.astro?.markdown?.ticketUrlTemplate,
21489
21879
  traceUrlTemplate: options.astro?.markdown?.traceUrlTemplate,
21490
- customRenderers: options.astro?.markdown?.customRenderers
21880
+ customRenderers: options.astro?.markdown?.customRenderers,
21881
+ scenarioAnchor: options.astro?.markdown?.scenarioAnchor,
21882
+ scenarioBadge: options.astro?.markdown?.scenarioBadge
21491
21883
  }
21492
21884
  },
21493
21885
  assetMode: options.assetMode ?? "none",
@@ -21691,6 +22083,10 @@ var ReportGenerator = class {
21691
22083
  const formatter = new ReleaseManifestFormatter();
21692
22084
  return formatter.format(run);
21693
22085
  }
22086
+ case "traceability-matrix": {
22087
+ const formatter = new TraceabilityMatrixFormatter();
22088
+ return formatter.format(run);
22089
+ }
21694
22090
  case "story-report-json": {
21695
22091
  const formatter = new StoryReportJsonFormatter({
21696
22092
  pretty: this.options.storyReportJson.pretty
@@ -21770,12 +22166,16 @@ export {
21770
22166
  STORY_REPORT_SCHEMA_VERSION,
21771
22167
  ScenarioIndexJsonFormatter,
21772
22168
  StoryReportJsonFormatter,
22169
+ TraceabilityMatrixFormatter,
21773
22170
  adaptJestRun,
21774
22171
  adaptPlaywrightRun,
21775
22172
  adaptVitestRun,
21776
22173
  assertValidRun,
22174
+ buildCheck,
22175
+ buildGoal,
21777
22176
  buildHtmlDocEntry,
21778
22177
  buildReview,
22178
+ buildTriage,
21779
22179
  bundleAssets,
21780
22180
  calculateFlakiness,
21781
22181
  calculateStability,
@@ -21825,6 +22225,9 @@ export {
21825
22225
  readPackageVersion,
21826
22226
  recordDeployment,
21827
22227
  regenerateArtifacts,
22228
+ renderCheck,
22229
+ renderGoal,
22230
+ renderTriage,
21828
22231
  resolveAttachment,
21829
22232
  resolveAttachments,
21830
22233
  resolveTheme,
@@ -21846,6 +22249,7 @@ export {
21846
22249
  toReleaseManifest,
21847
22250
  toScenarioIndex,
21848
22251
  toStoryReport,
22252
+ toTraceabilityMatrix,
21849
22253
  tryGetActiveOtelContext,
21850
22254
  updateHistory,
21851
22255
  validateCanonicalRun