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.cjs CHANGED
@@ -55,12 +55,16 @@ __export(src_exports, {
55
55
  STORY_REPORT_SCHEMA_VERSION: () => STORY_REPORT_SCHEMA_VERSION,
56
56
  ScenarioIndexJsonFormatter: () => ScenarioIndexJsonFormatter,
57
57
  StoryReportJsonFormatter: () => StoryReportJsonFormatter,
58
+ TraceabilityMatrixFormatter: () => TraceabilityMatrixFormatter,
58
59
  adaptJestRun: () => adaptJestRun,
59
60
  adaptPlaywrightRun: () => adaptPlaywrightRun,
60
61
  adaptVitestRun: () => adaptVitestRun,
61
62
  assertValidRun: () => assertValidRun,
63
+ buildCheck: () => buildCheck,
64
+ buildGoal: () => buildGoal,
62
65
  buildHtmlDocEntry: () => buildHtmlDocEntry,
63
66
  buildReview: () => buildReview,
67
+ buildTriage: () => buildTriage,
64
68
  bundleAssets: () => bundleAssets,
65
69
  calculateFlakiness: () => calculateFlakiness,
66
70
  calculateStability: () => calculateStability,
@@ -110,6 +114,9 @@ __export(src_exports, {
110
114
  readPackageVersion: () => readPackageVersion,
111
115
  recordDeployment: () => recordDeployment,
112
116
  regenerateArtifacts: () => regenerateArtifacts,
117
+ renderCheck: () => renderCheck,
118
+ renderGoal: () => renderGoal,
119
+ renderTriage: () => renderTriage,
113
120
  resolveAttachment: () => resolveAttachment,
114
121
  resolveAttachments: () => resolveAttachments,
115
122
  resolveTheme: () => resolveTheme,
@@ -131,6 +138,7 @@ __export(src_exports, {
131
138
  toReleaseManifest: () => toReleaseManifest,
132
139
  toScenarioIndex: () => toScenarioIndex,
133
140
  toStoryReport: () => toStoryReport,
141
+ toTraceabilityMatrix: () => toTraceabilityMatrix,
134
142
  tryGetActiveOtelContext: () => tryGetActiveOtelContext,
135
143
  updateHistory: () => updateHistory,
136
144
  validateCanonicalRun: () => validateCanonicalRun
@@ -663,14 +671,14 @@ var CucumberJsonFormatter = class {
663
671
  duration: 0
664
672
  };
665
673
  }
666
- const statusMap = {
674
+ const statusMap2 = {
667
675
  passed: "passed",
668
676
  failed: "failed",
669
677
  skipped: "skipped",
670
678
  pending: "pending"
671
679
  };
672
680
  const stepResult = {
673
- status: statusMap[result.status] ?? "undefined",
681
+ status: statusMap2[result.status] ?? "undefined",
674
682
  // Duration in nanoseconds (Cucumber uses nanoseconds)
675
683
  duration: result.durationMs * 1e6
676
684
  };
@@ -15261,7 +15269,9 @@ var MarkdownFormatter = class {
15261
15269
  ticketUrlTemplate: options.ticketUrlTemplate,
15262
15270
  traceUrlTemplate: options.traceUrlTemplate,
15263
15271
  includeSourceLinks: options.includeSourceLinks ?? true,
15264
- customRenderers: options.customRenderers
15272
+ customRenderers: options.customRenderers,
15273
+ scenarioAnchor: options.scenarioAnchor,
15274
+ scenarioBadge: options.scenarioBadge
15265
15275
  };
15266
15276
  }
15267
15277
  /**
@@ -15448,6 +15458,11 @@ var MarkdownFormatter = class {
15448
15458
  * Render a single scenario.
15449
15459
  */
15450
15460
  renderScenario(lines, tc) {
15461
+ const anchorId = this.options.scenarioAnchor?.(tc);
15462
+ if (anchorId) {
15463
+ lines.push(`<a id="${anchorId}"></a>`);
15464
+ lines.push("");
15465
+ }
15451
15466
  if (this.options.customRenderers?.renderScenarioHeader) {
15452
15467
  const custom = this.options.customRenderers.renderScenarioHeader(tc);
15453
15468
  if (custom !== null) {
@@ -15463,6 +15478,10 @@ var MarkdownFormatter = class {
15463
15478
  icon = this.getStatusIcon(tc.status) + " ";
15464
15479
  }
15465
15480
  lines.push(`${headingPrefix} ${icon}${tc.story.scenario}`);
15481
+ const badge = this.options.scenarioBadge?.(tc);
15482
+ if (badge) {
15483
+ lines.push(badge);
15484
+ }
15466
15485
  if (this.options.includeSourceLinks && this.options.permalinkBaseUrl && tc.sourceFile !== "unknown") {
15467
15486
  const permalink = this.buildPermalink(tc);
15468
15487
  lines.push(`Source: [${tc.sourceFile}](${permalink})`);
@@ -15854,6 +15873,132 @@ function escapePipe(value) {
15854
15873
  return value.replace(/\|/g, "\\|");
15855
15874
  }
15856
15875
 
15876
+ // src/formatters/traceability-matrix.ts
15877
+ var TraceabilityMatrixFormatter = class {
15878
+ format(run) {
15879
+ const matrix = toTraceabilityMatrix(run);
15880
+ const lines = [];
15881
+ lines.push("# Traceability Matrix");
15882
+ lines.push("");
15883
+ lines.push(`Generated: ${matrix.generatedAt}`);
15884
+ lines.push(`Run: ${matrix.run.startedAt} to ${matrix.run.finishedAt}`);
15885
+ if (matrix.run.branch) lines.push(`Branch: ${matrix.run.branch}`);
15886
+ if (matrix.run.gitSha) lines.push(`Commit: ${matrix.run.gitSha}`);
15887
+ lines.push("");
15888
+ lines.push("| Requirements | Verified | Failing | Scenarios | Untraced |");
15889
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15890
+ lines.push(
15891
+ `| ${matrix.summary.requirements} | ${matrix.summary.requirementsVerified} | ${matrix.summary.requirementsFailing} | ${matrix.summary.scenarios} | ${matrix.summary.untracedScenarios} |`
15892
+ );
15893
+ lines.push("");
15894
+ for (const req of matrix.requirements) {
15895
+ const heading2 = req.url ? `[${req.ticket}](${req.url})` : req.ticket;
15896
+ lines.push(`## ${heading2}`);
15897
+ lines.push("");
15898
+ lines.push(`Status: ${renderRequirementStatus(req.status)}`);
15899
+ if (req.covers.length > 0) {
15900
+ lines.push(`Covers: ${req.covers.map((path12) => `\`${path12}\``).join(", ")}`);
15901
+ }
15902
+ lines.push("");
15903
+ lines.push("| Status | Scenario | Source | Covers |");
15904
+ lines.push("| --- | --- | --- | --- |");
15905
+ for (const scenario of req.scenarios) {
15906
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15907
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path12) => `\`${path12}\``).join(", ") : "";
15908
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
15909
+ }
15910
+ lines.push("");
15911
+ }
15912
+ if (matrix.untraced.length > 0) {
15913
+ lines.push("## Untraced scenarios");
15914
+ lines.push("");
15915
+ lines.push("Behavior with no requirement link. Add a `ticket` to each so it appears against a requirement.");
15916
+ lines.push("");
15917
+ lines.push("| Status | Scenario | Source |");
15918
+ lines.push("| --- | --- | --- |");
15919
+ for (const scenario of matrix.untraced) {
15920
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15921
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` |`);
15922
+ }
15923
+ lines.push("");
15924
+ }
15925
+ return lines.join("\n").trimEnd();
15926
+ }
15927
+ };
15928
+ function toTraceabilityMatrix(run) {
15929
+ const sorted = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id));
15930
+ const byTicket = /* @__PURE__ */ new Map();
15931
+ const untraced = [];
15932
+ for (const tc of sorted) {
15933
+ const tickets = tc.story.tickets ?? [];
15934
+ if (tickets.length === 0) {
15935
+ untraced.push({
15936
+ id: tc.id,
15937
+ title: tc.story.scenario,
15938
+ status: tc.status,
15939
+ sourceFile: tc.sourceFile,
15940
+ sourceLine: tc.sourceLine
15941
+ });
15942
+ continue;
15943
+ }
15944
+ for (const ticket of tickets) {
15945
+ const entry = byTicket.get(ticket.id) ?? { url: ticket.url, cases: [] };
15946
+ if (!entry.url && ticket.url) entry.url = ticket.url;
15947
+ entry.cases.push(tc);
15948
+ byTicket.set(ticket.id, entry);
15949
+ }
15950
+ }
15951
+ const requirements = [...byTicket.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ticket, entry]) => {
15952
+ const scenarios = entry.cases.map((tc) => ({
15953
+ id: tc.id,
15954
+ title: tc.story.scenario,
15955
+ status: tc.status,
15956
+ sourceFile: tc.sourceFile,
15957
+ sourceLine: tc.sourceLine,
15958
+ covers: tc.story.covers ?? []
15959
+ }));
15960
+ const covers = [...new Set(scenarios.flatMap((s) => s.covers))].sort();
15961
+ return { ticket, url: entry.url, status: requirementStatus(entry.cases), scenarios, covers };
15962
+ });
15963
+ return {
15964
+ schemaVersion: "1.0",
15965
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15966
+ run: {
15967
+ startedAt: new Date(run.startedAtMs).toISOString(),
15968
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15969
+ gitSha: run.gitSha,
15970
+ branch: run.ci?.branch
15971
+ },
15972
+ summary: {
15973
+ requirements: requirements.length,
15974
+ requirementsVerified: requirements.filter((r) => r.status === "verified").length,
15975
+ requirementsFailing: requirements.filter((r) => r.status === "failing").length,
15976
+ scenarios: run.testCases.length,
15977
+ untracedScenarios: untraced.length
15978
+ },
15979
+ requirements,
15980
+ untraced
15981
+ };
15982
+ }
15983
+ function requirementStatus(cases) {
15984
+ if (cases.some((tc) => tc.status === "failed")) return "failing";
15985
+ if (cases.some((tc) => tc.status === "passed")) return "verified";
15986
+ return "incomplete";
15987
+ }
15988
+ function renderRequirementStatus(status) {
15989
+ switch (status) {
15990
+ case "verified":
15991
+ return "verified (all scenarios passed)";
15992
+ case "failing":
15993
+ return "failing (a scenario failed)";
15994
+ default:
15995
+ return "incomplete (no scenario passed yet)";
15996
+ }
15997
+ }
15998
+ function escapePipe2(value) {
15999
+ return value.replace(/\|/g, "\\|");
16000
+ }
16001
+
15857
16002
  // src/formatters/cucumber-messages/synthesize-feature.ts
15858
16003
  function extractFeatureName(testCases, uri) {
15859
16004
  for (const tc of testCases) {
@@ -18153,12 +18298,16 @@ function groupBy7(items, keyFn) {
18153
18298
  var fs5 = __toESM(require("fs"), 1);
18154
18299
  var path6 = __toESM(require("path"), 1);
18155
18300
  var SKIP_PREFIXES = ["http://", "https://", "data:", "#"];
18156
- function isLocalPath(src) {
18301
+ function isRemoteRef(src) {
18157
18302
  const trimmed = src.trim();
18158
- if (SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
18159
- return false;
18160
- }
18161
- return !path6.posix.isAbsolute(trimmed) && !path6.win32.isAbsolute(trimmed);
18303
+ return SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
18304
+ }
18305
+ function isAbsoluteRef(src) {
18306
+ const trimmed = src.trim();
18307
+ return path6.posix.isAbsolute(trimmed) || path6.win32.isAbsolute(trimmed);
18308
+ }
18309
+ function isRelativeLocalPath(src) {
18310
+ return !isRemoteRef(src) && !isAbsoluteRef(src);
18162
18311
  }
18163
18312
  function stripCodeContent(markdown) {
18164
18313
  let result = markdown.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$/gm, "");
@@ -18174,21 +18323,21 @@ function scanMarkdownAssets(markdown) {
18174
18323
  let match;
18175
18324
  while ((match = mdImageRe.exec(stripped)) !== null) {
18176
18325
  const src = match[1].trim();
18177
- if (isLocalPath(src)) {
18326
+ if (!isRemoteRef(src)) {
18178
18327
  found.add(src);
18179
18328
  }
18180
18329
  }
18181
18330
  const htmlSrcRe = /<(?:img|source|video)[^>]+\bsrc=["']([^"']+)["'][^>]*>/gi;
18182
18331
  while ((match = htmlSrcRe.exec(stripped)) !== null) {
18183
18332
  const src = match[1].trim();
18184
- if (isLocalPath(src)) {
18333
+ if (!isRemoteRef(src)) {
18185
18334
  found.add(src);
18186
18335
  }
18187
18336
  }
18188
18337
  const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
18189
18338
  while ((match = posterRe.exec(stripped)) !== null) {
18190
18339
  const src = match[1].trim();
18191
- if (isLocalPath(src)) {
18340
+ if (!isRemoteRef(src)) {
18192
18341
  found.add(src);
18193
18342
  }
18194
18343
  }
@@ -18214,48 +18363,21 @@ function isCode(segment) {
18214
18363
  const trimmed = segment.trimStart();
18215
18364
  return trimmed.startsWith("`") || trimmed.startsWith("~") || trimmed.startsWith("<pre") || trimmed.startsWith("<code");
18216
18365
  }
18366
+ function resolveRewrite(trimmed, assetsBaseUrl, pathMap) {
18367
+ if (isRemoteRef(trimmed)) return null;
18368
+ if (pathMap) {
18369
+ const mapped = pathMap.get(trimmed);
18370
+ return mapped === void 0 ? null : `${assetsBaseUrl}/${mapped}`;
18371
+ }
18372
+ if (!isRelativeLocalPath(trimmed)) return null;
18373
+ return `${assetsBaseUrl}/${trimmed}`;
18374
+ }
18217
18375
  function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
18218
- let result = prose;
18219
- result = result.replace(
18220
- /(!\[[^\]]*\]\()([^)"'\s]+)((?:\s+["'][^"']*["'])?\s*\))/g,
18221
- (full, pre, src, post) => {
18222
- const trimmed = src.trim();
18223
- if (!isLocalPath(trimmed)) return full;
18224
- if (pathMap) {
18225
- const mapped = pathMap.get(trimmed);
18226
- if (mapped === void 0) return full;
18227
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18228
- }
18229
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18230
- }
18231
- );
18232
- result = result.replace(
18233
- /(<(?:img|source|video)[^>]+\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
18234
- (full, pre, src, post) => {
18235
- const trimmed = src.trim();
18236
- if (!isLocalPath(trimmed)) return full;
18237
- if (pathMap) {
18238
- const mapped = pathMap.get(trimmed);
18239
- if (mapped === void 0) return full;
18240
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18241
- }
18242
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18243
- }
18244
- );
18245
- result = result.replace(
18246
- /(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
18247
- (full, pre, src, post) => {
18248
- const trimmed = src.trim();
18249
- if (!isLocalPath(trimmed)) return full;
18250
- if (pathMap) {
18251
- const mapped = pathMap.get(trimmed);
18252
- if (mapped === void 0) return full;
18253
- return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18254
- }
18255
- return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18256
- }
18257
- );
18258
- return result;
18376
+ const rewrite = (full, pre, src, post) => {
18377
+ const target = resolveRewrite(src.trim(), assetsBaseUrl, pathMap);
18378
+ return target === null ? full : `${pre}${target}${post}`;
18379
+ };
18380
+ return prose.replace(/(!\[[^\]]*\]\()([^)"'\s]+)((?:\s+["'][^"']*["'])?\s*\))/g, rewrite).replace(/(<(?:img|source|video)[^>]+\bsrc=["'])([^"']+)(["'][^>]*>)/gi, rewrite).replace(/(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi, rewrite);
18259
18381
  }
18260
18382
  function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
18261
18383
  return splitByCode(markdown).map((seg) => isCode(seg) ? seg : rewriteProseSegment(seg, assetsBaseUrl, pathMap)).join("");
@@ -18272,8 +18394,9 @@ function copyMarkdownAssets(options) {
18272
18394
  const pathMap = /* @__PURE__ */ new Map();
18273
18395
  const missing = [];
18274
18396
  for (const ref of refs) {
18275
- const absPath = path6.resolve(markdownDir, ref);
18397
+ const absPath = isAbsoluteRef(ref) ? ref : path6.resolve(markdownDir, ref);
18276
18398
  if (!fs5.existsSync(absPath)) {
18399
+ if (isAbsoluteRef(ref)) continue;
18277
18400
  if (!allowMissing) {
18278
18401
  throw new Error(`Asset not found: ${absPath}`);
18279
18402
  }
@@ -20602,6 +20725,280 @@ function collectDocKinds(testCase) {
20602
20725
  return [...kinds].sort();
20603
20726
  }
20604
20727
 
20728
+ // src/scenario-failure.ts
20729
+ function failingScenarioMessage(tc) {
20730
+ const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
20731
+ return failingStep?.errorMessage ?? tc.errorMessage;
20732
+ }
20733
+
20734
+ // src/check.ts
20735
+ var ICON_PASS = "\u2713";
20736
+ var ICON_FAIL = "\u2717";
20737
+ var ICON_SKIP = "\u2298";
20738
+ var ICON_PENDING = "\u23F3";
20739
+ var ICON_WARN = "\u26A0";
20740
+ function buildCheck(args, _deps = {}) {
20741
+ const { testCases, baseline } = args;
20742
+ const summary = {
20743
+ total: testCases.length,
20744
+ passed: testCases.filter((tc) => tc.status === "passed").length,
20745
+ failed: testCases.filter((tc) => tc.status === "failed").length,
20746
+ skipped: testCases.filter((tc) => tc.status === "skipped").length,
20747
+ pending: testCases.filter((tc) => tc.status === "pending").length
20748
+ };
20749
+ let regressed = 0;
20750
+ let fixed = 0;
20751
+ if (baseline) {
20752
+ for (const tc of testCases) {
20753
+ const before = baseline.get(tc.id);
20754
+ if (before === "passed" && tc.status === "failed") regressed += 1;
20755
+ if (before === "failed" && tc.status === "passed") fixed += 1;
20756
+ }
20757
+ }
20758
+ const failures = testCases.filter((tc) => tc.status === "failed").map((tc) => toFailure(tc, baseline)).sort((a, b) => {
20759
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20760
+ return a.location.localeCompare(b.location);
20761
+ });
20762
+ return {
20763
+ summary,
20764
+ failures,
20765
+ regressed,
20766
+ fixed,
20767
+ comparedToBaseline: baseline !== void 0
20768
+ };
20769
+ }
20770
+ function toFailure(tc, baseline) {
20771
+ const failedIndexes = new Set(
20772
+ tc.stepResults.filter((s) => s.status === "failed").map((s) => s.index)
20773
+ );
20774
+ const steps = tc.story.steps.map((step, index) => ({
20775
+ keyword: step.keyword,
20776
+ text: step.text,
20777
+ failed: failedIndexes.has(index)
20778
+ }));
20779
+ return {
20780
+ id: tc.id,
20781
+ scenario: tc.story.scenario,
20782
+ location: `${tc.sourceFile}:${tc.sourceLine}`,
20783
+ steps,
20784
+ errorMessage: failingScenarioMessage(tc),
20785
+ covers: tc.story.covers ?? [],
20786
+ tickets: (tc.story.tickets ?? []).map((t) => t.id),
20787
+ regressed: baseline?.get(tc.id) === "passed"
20788
+ };
20789
+ }
20790
+ function renderCheck(report, format) {
20791
+ return format === "json" ? JSON.stringify(report, null, 2) : renderCheckText(report);
20792
+ }
20793
+ function renderCheckText(report) {
20794
+ const { summary, failures } = report;
20795
+ const headlineParts = [`${ICON_PASS} ${summary.passed} passed`];
20796
+ if (summary.failed > 0) headlineParts.push(`${ICON_FAIL} ${summary.failed} failed`);
20797
+ if (summary.skipped > 0) headlineParts.push(`${ICON_SKIP} ${summary.skipped} skipped`);
20798
+ if (summary.pending > 0) headlineParts.push(`${ICON_PENDING} ${summary.pending} pending`);
20799
+ const headline = `${headlineParts.join(" ")} (${summary.total} scenarios)`;
20800
+ if (failures.length === 0) {
20801
+ const lines2 = [headline];
20802
+ if (report.comparedToBaseline && report.fixed > 0) {
20803
+ lines2.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20804
+ }
20805
+ lines2.push("All scenarios green.");
20806
+ return lines2.join("\n");
20807
+ }
20808
+ const lines = [headline, ""];
20809
+ for (const f of failures) {
20810
+ lines.push(`${ICON_FAIL} ${f.scenario}${f.regressed ? " (regressed)" : ""}`);
20811
+ lines.push(` ${f.location}`);
20812
+ for (const step of f.steps) {
20813
+ const marker = step.failed ? ` ${ICON_FAIL} ` : " ";
20814
+ lines.push(`${marker}${step.keyword} ${step.text}`);
20815
+ }
20816
+ if (f.errorMessage) {
20817
+ const firstLine = f.errorMessage.split("\n")[0];
20818
+ lines.push(` \u2192 ${firstLine}`);
20819
+ }
20820
+ if (f.covers.length > 0) {
20821
+ lines.push(` covers: ${f.covers.join(", ")}`);
20822
+ }
20823
+ if (f.tickets.length > 0) {
20824
+ lines.push(` ticket: ${f.tickets.join(", ")}`);
20825
+ }
20826
+ lines.push("");
20827
+ }
20828
+ if (report.comparedToBaseline) {
20829
+ if (report.regressed > 0) {
20830
+ lines.push(`${ICON_WARN} ${report.regressed} regressed since baseline (was passing).`);
20831
+ }
20832
+ if (report.fixed > 0) {
20833
+ lines.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20834
+ }
20835
+ if (report.regressed === 0 && report.fixed === 0) {
20836
+ lines.push("No status changes vs. baseline.");
20837
+ }
20838
+ }
20839
+ return lines.join("\n").trimEnd();
20840
+ }
20841
+
20842
+ // src/goal.ts
20843
+ var ACTIVE = ["passed", "failed"];
20844
+ function buildGoal(args, _deps = {}) {
20845
+ const { run, baseline } = args;
20846
+ const cases = run.testCases;
20847
+ const selectors = [
20848
+ ...args.requireTags.map((tag) => ({ label: `tag:${tag}`, match: (tc) => tc.tags.includes(tag) })),
20849
+ ...args.requireTickets.map((id) => ({ label: `ticket:${id}`, match: (tc) => (tc.story.tickets ?? []).some((t) => t.id === id) })),
20850
+ ...args.requireScenarios.map((sel) => ({ label: `scenario:${sel}`, match: (tc) => tc.id === sel || tc.story.scenario === sel }))
20851
+ ];
20852
+ const requirements = selectors.length === 0 ? [evaluate("all scenarios", cases)] : selectors.map((s) => evaluate(s.label, cases.filter(s.match)));
20853
+ const regressions = [];
20854
+ if (baseline && args.enforceNoRegressions) {
20855
+ const before = statusMap(baseline);
20856
+ for (const tc of cases) {
20857
+ if (before.get(tc.id) === "passed" && tc.status === "failed") {
20858
+ regressions.push({ id: tc.id, title: tc.story.scenario });
20859
+ }
20860
+ }
20861
+ }
20862
+ const violations = [];
20863
+ if (baseline && args.enforceRatchet) {
20864
+ const current = new Map(cases.map((tc) => [tc.id, tc]));
20865
+ for (const base of baseline.testCases) {
20866
+ const now = current.get(base.id);
20867
+ if (!now) {
20868
+ violations.push({ id: base.id, title: base.story.scenario, kind: "removed", detail: "scenario no longer present" });
20869
+ continue;
20870
+ }
20871
+ if (ACTIVE.includes(base.status) && (now.status === "skipped" || now.status === "pending")) {
20872
+ violations.push({ id: base.id, title: base.story.scenario, kind: "disabled", detail: `${base.status} -> ${now.status}` });
20873
+ }
20874
+ const baseSteps = base.story.steps.length;
20875
+ const nowSteps = now.story.steps.length;
20876
+ if (nowSteps < baseSteps) {
20877
+ violations.push({ id: base.id, title: base.story.scenario, kind: "weakened", detail: `${baseSteps} steps -> ${nowSteps} steps` });
20878
+ }
20879
+ }
20880
+ }
20881
+ const met = requirements.every((r) => r.met) && regressions.length === 0 && violations.length === 0;
20882
+ return {
20883
+ met,
20884
+ requirements,
20885
+ regressions,
20886
+ regressionsEnforced: Boolean(baseline && args.enforceNoRegressions),
20887
+ ratchet: { enforced: Boolean(baseline && args.enforceRatchet), violations }
20888
+ };
20889
+ }
20890
+ function evaluate(selector, matched) {
20891
+ const passed = matched.filter((tc) => tc.status === "passed").length;
20892
+ const failing = matched.filter((tc) => tc.status !== "passed").map((tc) => tc.story.scenario);
20893
+ return {
20894
+ selector,
20895
+ matched: matched.length,
20896
+ passed,
20897
+ failing,
20898
+ met: matched.length > 0 && failing.length === 0
20899
+ };
20900
+ }
20901
+ function statusMap(run) {
20902
+ return new Map(run.testCases.map((tc) => [tc.id, tc.status]));
20903
+ }
20904
+ function renderGoal(report, format) {
20905
+ if (format === "json") return JSON.stringify(report, null, 2);
20906
+ const lines = [`GOAL: ${report.met ? "met" : "not met"}`];
20907
+ for (const req of report.requirements) {
20908
+ if (req.matched === 0) {
20909
+ lines.push(` ${req.selector}: no matching scenario (no proof)`);
20910
+ continue;
20911
+ }
20912
+ const tail = req.failing.length > 0 ? ` (${req.failing.length} failing)` : "";
20913
+ lines.push(` ${req.selector}: ${req.passed}/${req.matched} scenarios pass${tail}`);
20914
+ }
20915
+ if (report.regressionsEnforced) {
20916
+ if (report.regressions.length === 0) {
20917
+ lines.push(" regressions: 0");
20918
+ } else {
20919
+ lines.push(` regressions: ${report.regressions.length} (${report.regressions.map((r) => r.title).join(", ")})`);
20920
+ }
20921
+ }
20922
+ if (report.ratchet.enforced) {
20923
+ if (report.ratchet.violations.length === 0) {
20924
+ lines.push(" ratchet: clean (0 scenarios removed/weakened)");
20925
+ } else {
20926
+ lines.push(` ratchet: ${report.ratchet.violations.length} removed/weakened`);
20927
+ for (const v of report.ratchet.violations) {
20928
+ lines.push(` ${v.kind}: ${v.title} (${v.detail})`);
20929
+ }
20930
+ }
20931
+ }
20932
+ return lines.join("\n");
20933
+ }
20934
+
20935
+ // src/triage.ts
20936
+ function buildTriage(args, _deps = {}) {
20937
+ const { testCases, baseline } = args;
20938
+ const failing = testCases.filter((tc) => tc.status === "failed");
20939
+ const ranked = failing.map((tc) => {
20940
+ const regressed = baseline?.get(tc.id) === "passed";
20941
+ return {
20942
+ tc,
20943
+ regressed,
20944
+ covers: tc.story.covers ?? []
20945
+ };
20946
+ }).sort((a, b) => {
20947
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20948
+ const la = `${a.tc.sourceFile}:${a.tc.sourceLine}`;
20949
+ const lb = `${b.tc.sourceFile}:${b.tc.sourceLine}`;
20950
+ return la.localeCompare(lb);
20951
+ });
20952
+ const items = ranked.map((entry, index) => ({
20953
+ rank: index + 1,
20954
+ id: entry.tc.id,
20955
+ scenario: entry.tc.story.scenario,
20956
+ status: entry.tc.status,
20957
+ location: `${entry.tc.sourceFile}:${entry.tc.sourceLine}`,
20958
+ covers: entry.covers,
20959
+ tickets: (entry.tc.story.tickets ?? []).map((t) => t.id),
20960
+ errorMessage: failingScenarioMessage(entry.tc),
20961
+ regressed: entry.regressed,
20962
+ reason: entry.regressed ? "regression" : "failing"
20963
+ }));
20964
+ return {
20965
+ total: testCases.length,
20966
+ failing: failing.length,
20967
+ regressions: items.filter((i) => i.regressed).length,
20968
+ needsCovers: items.filter((i) => i.covers.length === 0).length,
20969
+ items
20970
+ };
20971
+ }
20972
+ function renderTriage(report, format) {
20973
+ if (format === "json") return JSON.stringify(report, null, 2);
20974
+ if (report.items.length === 0) {
20975
+ return "Nothing to triage. No failing scenarios.";
20976
+ }
20977
+ const header = report.regressions > 0 ? `${report.items.length} items to triage (${report.regressions} regression${report.regressions === 1 ? "" : "s"})` : `${report.items.length} items to triage`;
20978
+ const lines = [header, ""];
20979
+ for (const item of report.items) {
20980
+ const tag = item.regressed ? "[regression] " : "";
20981
+ lines.push(`${item.rank}. ${tag}${item.scenario}`);
20982
+ lines.push(` ${item.location}`);
20983
+ if (item.errorMessage) {
20984
+ lines.push(` \u2192 ${item.errorMessage.split("\n")[0]}`);
20985
+ }
20986
+ if (item.covers.length > 0) {
20987
+ lines.push(` fix: ${item.covers.join(", ")}`);
20988
+ } else {
20989
+ lines.push(" fix: (no covers declared \u2014 add `covers` to route this to code)");
20990
+ }
20991
+ if (item.tickets.length > 0) {
20992
+ lines.push(` ticket: ${item.tickets.join(", ")}`);
20993
+ }
20994
+ lines.push("");
20995
+ }
20996
+ if (report.needsCovers > 0) {
20997
+ lines.push(`${report.needsCovers} failing scenario(s) have no covers and can't be routed to code automatically.`);
20998
+ }
20999
+ return lines.join("\n").trimEnd();
21000
+ }
21001
+
20605
21002
  // src/review/conventions.ts
20606
21003
  var CHANGE_TAG_PREFIX = "change:";
20607
21004
  var AUDIENCE_TAG_PREFIX = "audience:";
@@ -21403,6 +21800,7 @@ var FORMAT_EXTENSIONS = {
21403
21800
  "behavior-manifest-json": ".behavior-manifest.json",
21404
21801
  markdown: ".md",
21405
21802
  "release-manifest": ".release-manifest.md",
21803
+ "traceability-matrix": ".traceability-matrix.md",
21406
21804
  html: ".html",
21407
21805
  "cucumber-html": ".cucumber.html",
21408
21806
  junit: ".junit.xml",
@@ -21625,7 +22023,9 @@ var ReportGenerator = class {
21625
22023
  permalinkBaseUrl: options.astro?.markdown?.permalinkBaseUrl,
21626
22024
  ticketUrlTemplate: options.astro?.markdown?.ticketUrlTemplate,
21627
22025
  traceUrlTemplate: options.astro?.markdown?.traceUrlTemplate,
21628
- customRenderers: options.astro?.markdown?.customRenderers
22026
+ customRenderers: options.astro?.markdown?.customRenderers,
22027
+ scenarioAnchor: options.astro?.markdown?.scenarioAnchor,
22028
+ scenarioBadge: options.astro?.markdown?.scenarioBadge
21629
22029
  }
21630
22030
  },
21631
22031
  assetMode: options.assetMode ?? "none",
@@ -21829,6 +22229,10 @@ var ReportGenerator = class {
21829
22229
  const formatter = new ReleaseManifestFormatter();
21830
22230
  return formatter.format(run);
21831
22231
  }
22232
+ case "traceability-matrix": {
22233
+ const formatter = new TraceabilityMatrixFormatter();
22234
+ return formatter.format(run);
22235
+ }
21832
22236
  case "story-report-json": {
21833
22237
  const formatter = new StoryReportJsonFormatter({
21834
22238
  pretty: this.options.storyReportJson.pretty
@@ -21909,12 +22313,16 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21909
22313
  STORY_REPORT_SCHEMA_VERSION,
21910
22314
  ScenarioIndexJsonFormatter,
21911
22315
  StoryReportJsonFormatter,
22316
+ TraceabilityMatrixFormatter,
21912
22317
  adaptJestRun,
21913
22318
  adaptPlaywrightRun,
21914
22319
  adaptVitestRun,
21915
22320
  assertValidRun,
22321
+ buildCheck,
22322
+ buildGoal,
21916
22323
  buildHtmlDocEntry,
21917
22324
  buildReview,
22325
+ buildTriage,
21918
22326
  bundleAssets,
21919
22327
  calculateFlakiness,
21920
22328
  calculateStability,
@@ -21964,6 +22372,9 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21964
22372
  readPackageVersion,
21965
22373
  recordDeployment,
21966
22374
  regenerateArtifacts,
22375
+ renderCheck,
22376
+ renderGoal,
22377
+ renderTriage,
21967
22378
  resolveAttachment,
21968
22379
  resolveAttachments,
21969
22380
  resolveTheme,
@@ -21985,6 +22396,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21985
22396
  toReleaseManifest,
21986
22397
  toScenarioIndex,
21987
22398
  toStoryReport,
22399
+ toTraceabilityMatrix,
21988
22400
  tryGetActiveOtelContext,
21989
22401
  updateHistory,
21990
22402
  validateCanonicalRun