executable-stories-formatters 0.8.0 → 0.10.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
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
- import * as fs8 from "fs";
6
- import * as path9 from "path";
5
+ import * as fs9 from "fs";
6
+ import * as path10 from "path";
7
7
 
8
8
  // src/validation/schema-validator.ts
9
9
  import Ajv from "ajv/dist/2020.js";
@@ -414,7 +414,7 @@ var raw_run_schema_default = {
414
414
  },
415
415
  RawAttachment: {
416
416
  type: "object",
417
- description: "A test attachment (screenshot, log, artifact). In the MVP schema, only path-based attachments are supported (no inline base64).",
417
+ description: "A test attachment (screenshot, log, artifact). Either path-based or inline (body); the ACL decides embed-vs-link.",
418
418
  properties: {
419
419
  name: {
420
420
  type: "string",
@@ -427,9 +427,40 @@ var raw_run_schema_default = {
427
427
  path: {
428
428
  type: "string",
429
429
  description: "File path (relative to projectRoot or absolute)."
430
+ },
431
+ body: {
432
+ type: "string",
433
+ description: "Inline content (e.g., base64-encoded image or UTF-8 text)."
434
+ },
435
+ encoding: {
436
+ type: "string",
437
+ enum: ["BASE64", "IDENTITY"],
438
+ description: "Content encoding for an inline body."
439
+ },
440
+ charset: {
441
+ type: "string",
442
+ description: "Character set for IDENTITY text bodies (default utf-8)."
443
+ },
444
+ fileName: {
445
+ type: "string",
446
+ description: "Actual artifact filename (distinct from the logical name)."
447
+ },
448
+ byteLength: {
449
+ type: "integer",
450
+ minimum: 0,
451
+ description: "Size in bytes, used for embed-vs-link decisions."
452
+ },
453
+ stepIndex: {
454
+ type: "integer",
455
+ minimum: 0,
456
+ description: "Step index this attachment belongs to (undefined = test-case level)."
457
+ },
458
+ stepId: {
459
+ type: "string",
460
+ description: "Stable step ID this attachment belongs to (preferred over stepIndex)."
430
461
  }
431
462
  },
432
- required: ["name", "mediaType", "path"],
463
+ required: ["name", "mediaType"],
433
464
  additionalProperties: false
434
465
  },
435
466
  RawStepEvent: {
@@ -475,6 +506,22 @@ var raw_run_schema_default = {
475
506
  buildNumber: {
476
507
  type: "string",
477
508
  description: "CI build number or run ID."
509
+ },
510
+ provider: {
511
+ type: "string",
512
+ description: "Typed provider key (e.g., 'github', 'gitlab', 'circleci')."
513
+ },
514
+ branch: {
515
+ type: "string",
516
+ description: "Git branch name."
517
+ },
518
+ commitSha: {
519
+ type: "string",
520
+ description: "Git commit SHA."
521
+ },
522
+ prNumber: {
523
+ type: "string",
524
+ description: "Pull/merge request number."
478
525
  }
479
526
  },
480
527
  required: ["name"],
@@ -492,17 +539,17 @@ function validateRawRun(data) {
492
539
  return { valid: true, errors: [] };
493
540
  }
494
541
  const errors = (validate.errors ?? []).map((err) => {
495
- const path10 = err.instancePath || "/";
542
+ const path11 = err.instancePath || "/";
496
543
  const message = err.message ?? "unknown error";
497
544
  if (err.keyword === "additionalProperties") {
498
545
  const extra = err.params.additionalProperty;
499
- return `${path10}: ${message} \u2014 '${extra}'`;
546
+ return `${path11}: ${message} \u2014 '${extra}'`;
500
547
  }
501
548
  if (err.keyword === "enum") {
502
549
  const allowed = err.params.allowedValues;
503
- return `${path10}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
550
+ return `${path11}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
504
551
  }
505
- return `${path10}: ${message}`;
552
+ return `${path11}: ${message}`;
506
553
  });
507
554
  return { valid: false, errors };
508
555
  }
@@ -845,7 +892,8 @@ function canonicalizeTestCase(raw, options, projectRoot) {
845
892
  projectName: raw.projectName,
846
893
  retry: raw.retry ?? 0,
847
894
  retries: raw.retries ?? 0,
848
- tags
895
+ tags,
896
+ ...raw.evidence ? { evidence: raw.evidence } : {}
849
897
  };
850
898
  }
851
899
  function normalizeTags(story) {
@@ -976,7 +1024,7 @@ ${result.errors.join("\n")}`);
976
1024
 
977
1025
  // src/index.ts
978
1026
  import "fs";
979
- import * as path7 from "path";
1027
+ import * as path8 from "path";
980
1028
  import * as fsPromises from "fs/promises";
981
1029
 
982
1030
  // src/converters/acl/lines.ts
@@ -1521,6 +1569,9 @@ function buildScenario(tc, featureId) {
1521
1569
  if (tickets && tickets.length > 0) {
1522
1570
  scenario.tickets = tickets.map((t) => t.url ? { id: t.id, url: t.url } : { id: t.id });
1523
1571
  }
1572
+ if (tc.story.covers && tc.story.covers.length > 0) {
1573
+ scenario.covers = [...tc.story.covers];
1574
+ }
1524
1575
  return scenario;
1525
1576
  }
1526
1577
  function deriveFeatureTitle(group, relSourceFile) {
@@ -1644,6 +1695,181 @@ var StoryReportJsonFormatter = class {
1644
1695
  }
1645
1696
  };
1646
1697
 
1698
+ // src/formatters/scenario-index-json.ts
1699
+ var ScenarioIndexJsonFormatter = class {
1700
+ options;
1701
+ constructor(options = {}) {
1702
+ this.options = {
1703
+ pretty: options.pretty ?? true,
1704
+ filters: options.filters
1705
+ };
1706
+ }
1707
+ toIndex(run) {
1708
+ return toScenarioIndex(toStoryReport(run), this.options.filters);
1709
+ }
1710
+ format(run) {
1711
+ const index = this.toIndex(run);
1712
+ return this.options.pretty ? JSON.stringify(index, null, 2) : JSON.stringify(index);
1713
+ }
1714
+ };
1715
+ function toScenarioIndex(report, filters = {}) {
1716
+ const scenarios = report.features.flatMap(
1717
+ (feature) => feature.scenarios.map((scenario) => toScenarioIndexItem(feature, scenario))
1718
+ ).filter((scenario) => matchesFilters(scenario, filters));
1719
+ return {
1720
+ schemaVersion: "1.0",
1721
+ runId: report.runId,
1722
+ generatedAtMs: report.finishedAtMs,
1723
+ summary: summarize(scenarios),
1724
+ scenarios
1725
+ };
1726
+ }
1727
+ function toScenarioIndexItem(feature, scenario) {
1728
+ return {
1729
+ id: scenario.id,
1730
+ title: scenario.title,
1731
+ status: scenario.status,
1732
+ feature: feature.title,
1733
+ sourceFile: feature.sourceFile,
1734
+ sourceLine: scenario.sourceLine,
1735
+ tags: scenario.tags,
1736
+ tickets: scenario.tickets ?? [],
1737
+ covers: scenario.covers ?? [],
1738
+ durationMs: scenario.durationMs,
1739
+ steps: scenario.steps.map((step) => ({
1740
+ id: step.id,
1741
+ index: step.index,
1742
+ keyword: step.keyword,
1743
+ text: step.text,
1744
+ status: step.status,
1745
+ durationMs: step.durationMs,
1746
+ errorMessage: step.errorMessage,
1747
+ docKinds: step.docEntries.map((entry) => entry.kind)
1748
+ })),
1749
+ docKinds: scenario.docEntries.map((entry) => entry.kind),
1750
+ error: scenario.errorMessage ? { message: scenario.errorMessage, stack: scenario.errorStack } : void 0
1751
+ };
1752
+ }
1753
+ function matchesFilters(scenario, filters) {
1754
+ if (filters.statuses?.length && !filters.statuses.includes(scenario.status)) {
1755
+ return false;
1756
+ }
1757
+ if (filters.tags?.length && !filters.tags.some((tag) => scenario.tags.includes(tag))) {
1758
+ return false;
1759
+ }
1760
+ if (filters.sourceFiles?.length && !filters.sourceFiles.some((sourceFile) => scenario.sourceFile.includes(sourceFile))) {
1761
+ return false;
1762
+ }
1763
+ return true;
1764
+ }
1765
+ function summarize(scenarios) {
1766
+ return {
1767
+ total: scenarios.length,
1768
+ passed: scenarios.filter((scenario) => scenario.status === "passed").length,
1769
+ failed: scenarios.filter((scenario) => scenario.status === "failed").length,
1770
+ skipped: scenarios.filter((scenario) => scenario.status === "skipped").length,
1771
+ pending: scenarios.filter((scenario) => scenario.status === "pending").length,
1772
+ durationMs: scenarios.reduce((total, scenario) => total + scenario.durationMs, 0)
1773
+ };
1774
+ }
1775
+
1776
+ // src/formatters/behavior-manifest-json.ts
1777
+ var BehaviorManifestJsonFormatter = class {
1778
+ pretty;
1779
+ constructor(options = {}) {
1780
+ this.pretty = options.pretty ?? true;
1781
+ }
1782
+ toManifest(run) {
1783
+ return toBehaviorManifest(toStoryReport(run));
1784
+ }
1785
+ format(run) {
1786
+ const manifest = this.toManifest(run);
1787
+ return this.pretty ? JSON.stringify(manifest, null, 2) : JSON.stringify(manifest);
1788
+ }
1789
+ };
1790
+ function toBehaviorManifest(report) {
1791
+ const index = toScenarioIndex(report);
1792
+ const bySource = /* @__PURE__ */ new Map();
1793
+ const byTag = /* @__PURE__ */ new Map();
1794
+ const docKinds = /* @__PURE__ */ new Set();
1795
+ const debuggerIssues = [];
1796
+ for (const scenario of index.scenarios) {
1797
+ const source = bySource.get(scenario.sourceFile) ?? {
1798
+ path: scenario.sourceFile,
1799
+ scenarioCount: 0,
1800
+ failed: 0,
1801
+ tags: []
1802
+ };
1803
+ source.scenarioCount += 1;
1804
+ if (scenario.status === "failed") source.failed += 1;
1805
+ source.tags = [.../* @__PURE__ */ new Set([...source.tags, ...scenario.tags])].sort();
1806
+ bySource.set(scenario.sourceFile, source);
1807
+ for (const tag of scenario.tags) {
1808
+ const tagEntry = byTag.get(tag) ?? { name: tag, scenarioCount: 0 };
1809
+ tagEntry.scenarioCount += 1;
1810
+ byTag.set(tag, tagEntry);
1811
+ }
1812
+ for (const kind of scenario.docKinds) docKinds.add(kind);
1813
+ for (const step of scenario.steps) {
1814
+ for (const kind of step.docKinds) docKinds.add(kind);
1815
+ }
1816
+ if (!scenarioHasDocs(scenario)) {
1817
+ debuggerIssues.push({
1818
+ severity: "warning",
1819
+ code: "missing-docs",
1820
+ scenarioId: scenario.id,
1821
+ title: scenario.title,
1822
+ message: "Scenario has no doc entries."
1823
+ });
1824
+ }
1825
+ if (scenario.tags.length === 0) {
1826
+ debuggerIssues.push({
1827
+ severity: "warning",
1828
+ code: "missing-tags",
1829
+ scenarioId: scenario.id,
1830
+ title: scenario.title,
1831
+ message: "Scenario has no tags."
1832
+ });
1833
+ }
1834
+ if (scenario.covers.length === 0) {
1835
+ debuggerIssues.push({
1836
+ severity: "warning",
1837
+ code: "missing-covers",
1838
+ scenarioId: scenario.id,
1839
+ title: scenario.title,
1840
+ message: "Scenario declares no covers (product-code paths), so code\u2192scenario lookup cannot find it."
1841
+ });
1842
+ }
1843
+ if (scenario.sourceLine === void 0) {
1844
+ debuggerIssues.push({
1845
+ severity: "warning",
1846
+ code: "missing-source-line",
1847
+ scenarioId: scenario.id,
1848
+ title: scenario.title,
1849
+ message: "Scenario has no source line."
1850
+ });
1851
+ }
1852
+ }
1853
+ const scenariosWithDocs = index.scenarios.filter(scenarioHasDocs).length;
1854
+ return {
1855
+ schemaVersion: "1.0",
1856
+ runId: report.runId,
1857
+ generatedAtMs: report.finishedAtMs,
1858
+ summary: index.summary,
1859
+ sourceFiles: [...bySource.values()].sort((a, b) => a.path.localeCompare(b.path)),
1860
+ tags: [...byTag.values()].sort((a, b) => a.name.localeCompare(b.name)),
1861
+ docCoverage: {
1862
+ scenariosWithDocs,
1863
+ scenariosWithoutDocs: index.scenarios.length - scenariosWithDocs,
1864
+ docKinds: [...docKinds].sort()
1865
+ },
1866
+ debugger: debuggerIssues
1867
+ };
1868
+ }
1869
+ function scenarioHasDocs(scenario) {
1870
+ return scenario.docKinds.length > 0 || scenario.steps.some((step) => step.docKinds.length > 0);
1871
+ }
1872
+
1647
1873
  // src/formatters/html/renderers/index.ts
1648
1874
  import * as fs2 from "fs";
1649
1875
  import * as path3 from "path";
@@ -14461,7 +14687,7 @@ function renderDocEntry(entry, deps) {
14461
14687
  // src/formatters/html/renderers/steps.ts
14462
14688
  var CONTINUATION_KEYWORDS = ["And", "But", "*"];
14463
14689
  function renderStep(step, stepResult, index, deps) {
14464
- const statusIcon2 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14690
+ const statusIcon4 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14465
14691
  const statusClass = stepResult ? `status-${stepResult.status}` : "";
14466
14692
  const duration = stepResult && stepResult.durationMs > 0 ? `${stepResult.durationMs}ms` : "";
14467
14693
  const keywordTrimmed = step.keyword.trim();
@@ -14470,7 +14696,7 @@ function renderStep(step, stepResult, index, deps) {
14470
14696
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
14471
14697
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
14472
14698
  return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
14473
- <span class="step-status ${statusClass}">${statusIcon2}</span>
14699
+ <span class="step-status ${statusClass}">${statusIcon4}</span>
14474
14700
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
14475
14701
  <span class="step-text">${textHtml}</span>
14476
14702
  <span class="step-duration">${duration}</span>
@@ -14515,16 +14741,16 @@ function highlightStepParams(text2, deps) {
14515
14741
  var MIN_METRIC_SAMPLES = 5;
14516
14742
 
14517
14743
  // src/formatters/html/renderers/scenario.ts
14518
- function renderTicket(ticket, template, escapeHtml3) {
14744
+ function renderTicket(ticket, template, escapeHtml4) {
14519
14745
  const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
14520
14746
  if (url) {
14521
- return `<a class="tag ticket-tag" href="${escapeHtml3(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml3(ticket.id)}</a>`;
14747
+ return `<a class="tag ticket-tag" href="${escapeHtml4(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml4(ticket.id)}</a>`;
14522
14748
  }
14523
- return `<span class="tag ticket-tag">${escapeHtml3(ticket.id)}</span>`;
14749
+ return `<span class="tag ticket-tag">${escapeHtml4(ticket.id)}</span>`;
14524
14750
  }
14525
14751
  function renderScenario(args, deps) {
14526
14752
  const { tc } = args;
14527
- const statusIcon2 = deps.getStatusIcon(tc.status);
14753
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14528
14754
  const statusClass = `status-${tc.status}`;
14529
14755
  const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
14530
14756
  const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
@@ -14594,7 +14820,7 @@ function renderScenario(args, deps) {
14594
14820
  <div class="scenario-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14595
14821
  <div class="scenario-info">
14596
14822
  <div class="scenario-title">
14597
- <span class="status-icon ${statusClass}">${statusIcon2}</span>
14823
+ <span class="status-icon ${statusClass}">${statusIcon4}</span>
14598
14824
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
14599
14825
  </div>
14600
14826
  <div class="scenario-meta">${tags}${tickets}${outcomeBadge}${sourceLink}${traceBadge}${metricBadges}</div>
@@ -14720,7 +14946,7 @@ function flattenTree(roots) {
14720
14946
  }
14721
14947
  return result;
14722
14948
  }
14723
- function buildTooltip(span, escapeHtml3) {
14949
+ function buildTooltip(span, escapeHtml4) {
14724
14950
  const parts = [];
14725
14951
  parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
14726
14952
  if (span.statusMessage) {
@@ -14738,7 +14964,7 @@ function buildTooltip(span, escapeHtml3) {
14738
14964
  if (text2.length > TOOLTIP_MAX_LENGTH) {
14739
14965
  text2 = text2.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
14740
14966
  }
14741
- return escapeHtml3(text2);
14967
+ return escapeHtml4(text2);
14742
14968
  }
14743
14969
  function renderTraceView(args, deps) {
14744
14970
  if (!args.spans || args.spans.length === 0) return "";
@@ -14961,11 +15187,11 @@ function renderToc(args, deps) {
14961
15187
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14962
15188
  const featureSlug = `feature-${slugify(file)}`;
14963
15189
  const scenarios = testCases.map((tc) => {
14964
- const statusIcon2 = deps.getStatusIcon(tc.status);
15190
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14965
15191
  const statusClass = `status-${tc.status}`;
14966
15192
  const failedClass = tc.status === "failed" ? " toc-failed" : "";
14967
15193
  return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14968
- <span class="toc-status ${statusClass}">${statusIcon2}</span>
15194
+ <span class="toc-status ${statusClass}">${statusIcon4}</span>
14969
15195
  ${deps.escapeHtml(tc.story.scenario)}
14970
15196
  </a>`;
14971
15197
  }).join("\n");
@@ -16478,8 +16704,8 @@ function extractDocAttachments(step) {
16478
16704
  }
16479
16705
  return attachments;
16480
16706
  }
16481
- function guessMediaType(path10) {
16482
- const lower = path10.toLowerCase();
16707
+ function guessMediaType(path11) {
16708
+ const lower = path11.toLowerCase();
16483
16709
  if (lower.endsWith(".png")) return "image/png";
16484
16710
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
16485
16711
  if (lower.endsWith(".gif")) return "image/gif";
@@ -16620,11 +16846,11 @@ var CucumberHtmlFormatter = class {
16620
16846
  for (const envelope of envelopes) {
16621
16847
  const accepted = htmlStream.write(envelope);
16622
16848
  if (!accepted) {
16623
- await new Promise((resolve8) => htmlStream.once("drain", resolve8));
16849
+ await new Promise((resolve9) => htmlStream.once("drain", resolve9));
16624
16850
  }
16625
16851
  }
16626
- await new Promise((resolve8, reject) => {
16627
- collector.on("finish", resolve8);
16852
+ await new Promise((resolve9, reject) => {
16853
+ collector.on("finish", resolve9);
16628
16854
  collector.on("error", reject);
16629
16855
  htmlStream.end();
16630
16856
  });
@@ -18235,6 +18461,68 @@ function copyMarkdownAssets(options) {
18235
18461
  };
18236
18462
  }
18237
18463
 
18464
+ // src/watch.ts
18465
+ import * as fs6 from "fs";
18466
+ import * as path7 from "path";
18467
+ function toRun(data, inputType, synthesize) {
18468
+ if (inputType === "canonical") return data;
18469
+ let raw = data;
18470
+ if (synthesize) raw = synthesizeStories(raw);
18471
+ return canonicalizeRun(raw);
18472
+ }
18473
+ async function regenerateArtifacts(options, deps = {}) {
18474
+ const read = deps.readFile ?? ((filePath) => fs6.readFileSync(filePath, "utf8"));
18475
+ const data = JSON.parse(read(path7.resolve(options.input)));
18476
+ const run = toRun(data, options.inputType ?? "raw", options.synthesize !== false);
18477
+ const generator = new ReportGenerator({
18478
+ formats: options.formats,
18479
+ outputDir: options.outputDir,
18480
+ outputName: options.outputName
18481
+ });
18482
+ const result = await generator.generate(run);
18483
+ return [...result.values()].flat();
18484
+ }
18485
+ function startWatch(options, deps = {}) {
18486
+ const log = deps.log ?? ((message) => console.log(message));
18487
+ const regenerate = deps.regenerate ?? ((input) => regenerateArtifacts({ ...options, input }, deps));
18488
+ const watchFn = deps.watch ?? ((filePath, listener) => fs6.watch(filePath, listener));
18489
+ const debounceMs = options.debounceMs ?? 150;
18490
+ let timer;
18491
+ let running = false;
18492
+ let pending = false;
18493
+ const run = async () => {
18494
+ if (running) {
18495
+ pending = true;
18496
+ return;
18497
+ }
18498
+ running = true;
18499
+ try {
18500
+ const files = await regenerate(options.input);
18501
+ log(`Regenerated ${files.length} artifact file(s) from ${options.input}`);
18502
+ } catch (error) {
18503
+ log(`Watch regeneration failed: ${error.message}`);
18504
+ } finally {
18505
+ running = false;
18506
+ if (pending) {
18507
+ pending = false;
18508
+ trigger();
18509
+ }
18510
+ }
18511
+ };
18512
+ const trigger = () => {
18513
+ if (timer) clearTimeout(timer);
18514
+ timer = setTimeout(() => void run(), debounceMs);
18515
+ };
18516
+ trigger();
18517
+ const watcher = watchFn(path7.resolve(options.input), trigger);
18518
+ return {
18519
+ close: () => {
18520
+ if (timer) clearTimeout(timer);
18521
+ watcher.close();
18522
+ }
18523
+ };
18524
+ }
18525
+
18238
18526
  // src/publishers/confluence.ts
18239
18527
  function parseAdf(adf) {
18240
18528
  let parsed;
@@ -19403,12 +19691,22 @@ function listScenarios(args, _deps) {
19403
19691
  const { testCases, format } = args;
19404
19692
  if (format === "json") {
19405
19693
  const items = testCases.map((tc) => ({
19694
+ id: tc.id,
19406
19695
  scenario: tc.story.scenario,
19407
19696
  status: tc.status,
19408
19697
  sourceFile: tc.sourceFile,
19409
19698
  sourceLine: tc.sourceLine,
19699
+ suitePath: tc.story.suitePath ?? tc.titlePath.slice(0, -1),
19410
19700
  tags: tc.tags,
19411
- id: tc.id
19701
+ tickets: tc.story.tickets ?? [],
19702
+ covers: tc.story.covers ?? [],
19703
+ durationMs: tc.durationMs,
19704
+ error: tc.errorMessage ? {
19705
+ message: tc.errorMessage,
19706
+ stack: tc.errorStack
19707
+ } : void 0,
19708
+ steps: tc.story.steps.map((step, index) => toScenarioStep(step, index, tc)),
19709
+ docKinds: collectDocKinds(tc)
19412
19710
  }));
19413
19711
  return JSON.stringify(items, null, 2);
19414
19712
  }
@@ -19481,10 +19779,730 @@ function listScenarios(args, _deps) {
19481
19779
  ];
19482
19780
  return lines.join("\n");
19483
19781
  }
19782
+ function toScenarioStep(step, index, testCase) {
19783
+ const result = testCase.stepResults.find(
19784
+ (candidate) => candidate.index === index || candidate.stepId === step.id
19785
+ );
19786
+ return {
19787
+ id: step.id,
19788
+ index,
19789
+ keyword: step.keyword,
19790
+ text: step.text,
19791
+ status: result?.status ?? testCase.status,
19792
+ durationMs: result?.durationMs ?? step.durationMs ?? 0,
19793
+ errorMessage: result?.errorMessage,
19794
+ mode: step.mode,
19795
+ docKinds: (step.docs ?? []).map((doc) => doc.kind)
19796
+ };
19797
+ }
19798
+ function collectDocKinds(testCase) {
19799
+ const kinds = /* @__PURE__ */ new Set();
19800
+ for (const doc of testCase.story.docs ?? []) {
19801
+ kinds.add(doc.kind);
19802
+ }
19803
+ for (const step of testCase.story.steps) {
19804
+ for (const doc of step.docs ?? []) {
19805
+ kinds.add(doc.kind);
19806
+ }
19807
+ }
19808
+ return [...kinds].sort();
19809
+ }
19810
+
19811
+ // src/review/conventions.ts
19812
+ var CHANGE_TAG_PREFIX = "change:";
19813
+ var AUDIENCE_TAG_PREFIX = "audience:";
19814
+ var VALID_CHANGE_TYPES = /* @__PURE__ */ new Set([
19815
+ "feature",
19816
+ "bugfix",
19817
+ "refactor",
19818
+ "perf",
19819
+ "deps"
19820
+ ]);
19821
+ var STAKEHOLDER_FILE = /(?:\.e2e\.)|(?:^|\/)e2e\/|(?:\.spec\.)/i;
19822
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
19823
+ "ts",
19824
+ "tsx",
19825
+ "js",
19826
+ "jsx",
19827
+ "mjs",
19828
+ "cjs",
19829
+ "py",
19830
+ "go",
19831
+ "rs",
19832
+ "kt",
19833
+ "kts",
19834
+ "java",
19835
+ "cs",
19836
+ "rb"
19837
+ ]);
19838
+ var TEST_INFIX = /\.(?:story\.)?(?:int\.|e2e\.|unit\.)?(?:test|spec|cy)\.[a-z]+$/i;
19839
+ function deriveAudience(sourceFile, tags) {
19840
+ const override = tags.map((t) => t.toLowerCase()).find((t) => t.startsWith(AUDIENCE_TAG_PREFIX));
19841
+ if (override) {
19842
+ const value = override.slice(AUDIENCE_TAG_PREFIX.length);
19843
+ if (value === "stakeholder" || value === "engineer") return value;
19844
+ }
19845
+ return STAKEHOLDER_FILE.test(sourceFile) ? "stakeholder" : "engineer";
19846
+ }
19847
+ function deriveChangeType(tags) {
19848
+ for (const tag of tags) {
19849
+ const lower = tag.toLowerCase();
19850
+ if (lower.startsWith(CHANGE_TAG_PREFIX)) {
19851
+ const value = lower.slice(CHANGE_TAG_PREFIX.length);
19852
+ if (VALID_CHANGE_TYPES.has(value)) return value;
19853
+ }
19854
+ }
19855
+ return "unknown";
19856
+ }
19857
+ function extensionOf(path11) {
19858
+ const base = path11.split("/").pop() ?? path11;
19859
+ const dot = base.lastIndexOf(".");
19860
+ return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
19861
+ }
19862
+ function isTestFile(path11) {
19863
+ return TEST_INFIX.test(path11);
19864
+ }
19865
+ function isReviewableSource(path11) {
19866
+ if (isTestFile(path11)) return false;
19867
+ if (path11.endsWith(".d.ts")) return false;
19868
+ return CODE_EXTENSIONS.has(extensionOf(path11));
19869
+ }
19870
+ function testBaseKey(testFile) {
19871
+ return testFile.replace(TEST_INFIX, "");
19872
+ }
19873
+ function sourceBaseKey(sourceFile) {
19874
+ const dot = sourceFile.lastIndexOf(".");
19875
+ const slash = sourceFile.lastIndexOf("/");
19876
+ return dot > slash ? sourceFile.slice(0, dot) : sourceFile;
19877
+ }
19878
+
19879
+ // src/review/build-review.ts
19880
+ var STRENGTH_RANK = {
19881
+ none: 0,
19882
+ weak: 1,
19883
+ moderate: 2,
19884
+ strong: 3
19885
+ };
19886
+ var INTENT_SECTION_TITLE = /\b(why|intent|approach|rationale|reasoning)\b/i;
19887
+ function findDoc(docs, predicate) {
19888
+ if (!docs) return void 0;
19889
+ for (const doc of docs) {
19890
+ if (predicate(doc)) return doc;
19891
+ const nested = findDoc(doc.children, predicate);
19892
+ if (nested) return nested;
19893
+ }
19894
+ return void 0;
19895
+ }
19896
+ function anyDoc(docs, predicate) {
19897
+ return findDoc(docs, predicate) !== void 0;
19898
+ }
19899
+ function extractIntent(testCase) {
19900
+ const docs = testCase.story.docs;
19901
+ const section = findDoc(
19902
+ docs,
19903
+ (d) => d.kind === "section" && INTENT_SECTION_TITLE.test(d.title)
19904
+ );
19905
+ if (section && section.kind === "section") return section.markdown;
19906
+ const note = findDoc(docs, (d) => d.kind === "note");
19907
+ if (note && note.kind === "note") return note.text;
19908
+ return void 0;
19909
+ }
19910
+ function hasScreenshot(testCase) {
19911
+ if (testCase.attachments.some((a) => a.mediaType.startsWith("image/"))) {
19912
+ return true;
19913
+ }
19914
+ if (anyDoc(testCase.story.docs, (d) => d.kind === "screenshot")) return true;
19915
+ return testCase.story.steps.some(
19916
+ (step) => anyDoc(step.docs, (d) => d.kind === "screenshot")
19917
+ );
19918
+ }
19919
+ function hasOtelTrace(testCase) {
19920
+ return (testCase.story.otelSpans?.length ?? 0) > 0;
19921
+ }
19922
+ function gradeEvidence(testCase, audience) {
19923
+ if (testCase.status !== "passed") {
19924
+ return {
19925
+ strength: "none",
19926
+ reasons: [`test is ${testCase.status} \u2014 the proof does not hold`]
19927
+ };
19928
+ }
19929
+ const ev = testCase.evidence;
19930
+ const screenshot = hasScreenshot(testCase);
19931
+ const otel = hasOtelTrace(testCase);
19932
+ const isIntegration = /\.int\.test\./i.test(testCase.sourceFile);
19933
+ const mutation = ev?.mutationScorePct;
19934
+ const changedCov = ev?.changedLineCoveragePct;
19935
+ const strong2 = [];
19936
+ if (ev?.failingFirstVerified) {
19937
+ strong2.push("failing-first verified (red on base ref, green on head)");
19938
+ }
19939
+ if (typeof mutation === "number" && mutation >= 80) {
19940
+ strong2.push(`mutation score ${mutation}% (\u226580%)`);
19941
+ }
19942
+ if (screenshot && otel) {
19943
+ strong2.push("backed by screenshot + OTEL trace");
19944
+ } else if (audience === "stakeholder" && (screenshot || otel)) {
19945
+ strong2.push(`stakeholder proof: ${screenshot ? "screenshot" : "OTEL trace"}`);
19946
+ }
19947
+ if (strong2.length > 0) return { strength: "strong", reasons: strong2 };
19948
+ const moderate = [];
19949
+ if (screenshot) moderate.push("screenshot attached");
19950
+ if (otel) moderate.push("OTEL trace attached");
19951
+ if (typeof mutation === "number" && mutation >= 50) {
19952
+ moderate.push(`mutation score ${mutation}%`);
19953
+ }
19954
+ if (typeof changedCov === "number" && changedCov >= 80) {
19955
+ moderate.push(`changed-line coverage ${changedCov}%`);
19956
+ }
19957
+ if (isIntegration) moderate.push("integration-level test");
19958
+ if (moderate.length > 0) return { strength: "moderate", reasons: moderate };
19959
+ return {
19960
+ strength: "weak",
19961
+ reasons: [
19962
+ "passing test only \u2014 no corroborating evidence (add e2e proof, mutation score, or failing-first)"
19963
+ ]
19964
+ };
19965
+ }
19966
+ function toClaim(testCase, changedSourcePaths) {
19967
+ const audience = deriveAudience(testCase.sourceFile, testCase.tags);
19968
+ const changeType = deriveChangeType(testCase.tags);
19969
+ const { strength, reasons } = gradeEvidence(testCase, audience);
19970
+ const key = testBaseKey(testCase.sourceFile);
19971
+ const coversFiles = changedSourcePaths.filter(
19972
+ (path11) => sourceBaseKey(path11) === key
19973
+ );
19974
+ return {
19975
+ id: testCase.id,
19976
+ scenario: testCase.story.scenario,
19977
+ sourceFile: testCase.sourceFile,
19978
+ sourceLine: testCase.sourceLine,
19979
+ status: testCase.status,
19980
+ audience,
19981
+ changeType,
19982
+ strength,
19983
+ strengthReasons: reasons,
19984
+ intent: extractIntent(testCase),
19985
+ coversFiles,
19986
+ testCase
19987
+ };
19988
+ }
19989
+ function bandFor(claims) {
19990
+ if (claims.length === 0) return "uncovered";
19991
+ const maxRank = Math.max(...claims.map((c) => STRENGTH_RANK[c.strength]));
19992
+ return maxRank >= STRENGTH_RANK.moderate ? "covered" : "weak";
19993
+ }
19994
+ var AUDIENCE_ORDER = {
19995
+ stakeholder: 0,
19996
+ engineer: 1
19997
+ };
19998
+ function buildReview(run, context = { changedFiles: [] }) {
19999
+ const changedSource = context.changedFiles.filter(
20000
+ (f) => isReviewableSource(f.path)
20001
+ );
20002
+ const changedSourcePaths = changedSource.map((f) => f.path);
20003
+ const claims = run.testCases.map((tc) => toClaim(tc, changedSourcePaths));
20004
+ const changedFiles = changedSource.map((file) => {
20005
+ const covering = claims.filter((c) => c.coversFiles.includes(file.path));
20006
+ return {
20007
+ path: file.path,
20008
+ changeKind: file.changeKind,
20009
+ band: bandFor(covering),
20010
+ claims: covering.map((c) => ({
20011
+ id: c.id,
20012
+ scenario: c.scenario,
20013
+ strength: c.strength
20014
+ }))
20015
+ };
20016
+ });
20017
+ const sortedClaims = [...claims].sort((a, b) => {
20018
+ if (AUDIENCE_ORDER[a.audience] !== AUDIENCE_ORDER[b.audience]) {
20019
+ return AUDIENCE_ORDER[a.audience] - AUDIENCE_ORDER[b.audience];
20020
+ }
20021
+ if (STRENGTH_RANK[a.strength] !== STRENGTH_RANK[b.strength]) {
20022
+ return STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength];
20023
+ }
20024
+ if (a.sourceFile !== b.sourceFile) {
20025
+ return a.sourceFile.localeCompare(b.sourceFile);
20026
+ }
20027
+ return a.scenario.localeCompare(b.scenario);
20028
+ });
20029
+ const bandRank = { uncovered: 0, weak: 1, covered: 2 };
20030
+ const sortedFiles = [...changedFiles].sort((a, b) => {
20031
+ if (bandRank[a.band] !== bandRank[b.band]) {
20032
+ return bandRank[a.band] - bandRank[b.band];
20033
+ }
20034
+ return a.path.localeCompare(b.path);
20035
+ });
20036
+ const summary = buildSummary2(sortedClaims, sortedFiles);
20037
+ return {
20038
+ run,
20039
+ context,
20040
+ summary,
20041
+ claims: sortedClaims,
20042
+ changedFiles: sortedFiles
20043
+ };
20044
+ }
20045
+ function buildSummary2(claims, changedFiles) {
20046
+ const byAudience = {
20047
+ stakeholder: 0,
20048
+ engineer: 0
20049
+ };
20050
+ const byStrength = {
20051
+ none: 0,
20052
+ weak: 0,
20053
+ moderate: 0,
20054
+ strong: 0
20055
+ };
20056
+ for (const claim of claims) {
20057
+ byAudience[claim.audience] += 1;
20058
+ byStrength[claim.strength] += 1;
20059
+ }
20060
+ return {
20061
+ totalClaims: claims.length,
20062
+ byAudience,
20063
+ byStrength,
20064
+ changedSourceFiles: changedFiles.length,
20065
+ uncovered: changedFiles.filter((f) => f.band === "uncovered").length,
20066
+ weaklyCovered: changedFiles.filter((f) => f.band === "weak").length,
20067
+ covered: changedFiles.filter((f) => f.band === "covered").length
20068
+ };
20069
+ }
20070
+
20071
+ // src/formatters/review-markdown.ts
20072
+ var STRENGTH_BADGE = {
20073
+ strong: "\u{1F7E2} strong",
20074
+ moderate: "\u{1F7E1} moderate",
20075
+ weak: "\u{1F7E0} weak",
20076
+ none: "\u{1F534} none"
20077
+ };
20078
+ function statusIcon2(status) {
20079
+ switch (status) {
20080
+ case "passed":
20081
+ return "\u2705";
20082
+ case "failed":
20083
+ return "\u274C";
20084
+ case "skipped":
20085
+ return "\u2298";
20086
+ default:
20087
+ return "\u2022";
20088
+ }
20089
+ }
20090
+ function escapeCell2(value) {
20091
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
20092
+ }
20093
+ function intentSummary(intent) {
20094
+ const firstLine = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
20095
+ const trimmed = firstLine.trim();
20096
+ return trimmed.length > 200 ? `${trimmed.slice(0, 197)}\u2026` : trimmed;
20097
+ }
20098
+ function renderTicket2(ticket) {
20099
+ return ticket.url ? `[${ticket.id}](${ticket.url})` : `\`${ticket.id}\``;
20100
+ }
20101
+ function renderUncoveredBand(lines, files) {
20102
+ const uncovered = files.filter((f) => f.band === "uncovered");
20103
+ if (uncovered.length === 0) return;
20104
+ lines.push(`## \u{1F534} Changed code with no evidence (${uncovered.length})`);
20105
+ lines.push("");
20106
+ lines.push("Start here \u2014 these changed source files have no claim or test behind them.");
20107
+ lines.push("");
20108
+ for (const file of uncovered) {
20109
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_`);
20110
+ }
20111
+ lines.push("");
20112
+ }
20113
+ function renderWeakBand(lines, files) {
20114
+ const weak = files.filter((f) => f.band === "weak");
20115
+ if (weak.length === 0) return;
20116
+ lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
20117
+ lines.push("");
20118
+ for (const file of weak) {
20119
+ const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
20120
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
20121
+ }
20122
+ lines.push("");
20123
+ }
20124
+ function renderClaim(lines, claim) {
20125
+ lines.push(`### ${statusIcon2(claim.status)} ${claim.scenario}`);
20126
+ lines.push("");
20127
+ lines.push(`- File: \`${claim.sourceFile}:${claim.sourceLine}\``);
20128
+ if (claim.changeType !== "unknown") {
20129
+ lines.push(`- Change: \`${claim.changeType}\``);
20130
+ }
20131
+ const tickets = claim.testCase.story.tickets ?? [];
20132
+ if (tickets.length > 0) {
20133
+ lines.push(`- Tickets: ${tickets.map(renderTicket2).join(", ")}`);
20134
+ }
20135
+ lines.push(
20136
+ `- Evidence: ${STRENGTH_BADGE[claim.strength]} \u2014 ${claim.strengthReasons.join("; ")}`
20137
+ );
20138
+ if (claim.coversFiles.length > 0) {
20139
+ lines.push(
20140
+ `- Covers: ${claim.coversFiles.map((f) => `\`${f}\``).join(", ")}`
20141
+ );
20142
+ }
20143
+ if (claim.intent) {
20144
+ lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
20145
+ }
20146
+ lines.push("");
20147
+ }
20148
+ function renderAudienceSection(lines, title, claims) {
20149
+ if (claims.length === 0) return;
20150
+ lines.push(`## ${title} (${claims.length})`);
20151
+ lines.push("");
20152
+ for (const claim of claims) {
20153
+ renderClaim(lines, claim);
20154
+ }
20155
+ }
20156
+ var ReviewMarkdownFormatter = class {
20157
+ title;
20158
+ constructor(options = {}) {
20159
+ this.title = options.title ?? "Evidence Review";
20160
+ }
20161
+ format(review) {
20162
+ const lines = [];
20163
+ const { summary, context } = review;
20164
+ lines.push(`# ${this.title}`);
20165
+ lines.push("");
20166
+ if (context.baseRef || context.headRef) {
20167
+ lines.push(
20168
+ `Comparing \`${context.baseRef ?? "base"}\` \u2192 \`${context.headRef ?? "head"}\`.`
20169
+ );
20170
+ lines.push("");
20171
+ }
20172
+ lines.push("## Review priority");
20173
+ lines.push("");
20174
+ if (summary.changedSourceFiles === 0) {
20175
+ lines.push(
20176
+ "No changed source files supplied \u2014 showing claims and evidence only."
20177
+ );
20178
+ } else if (summary.uncovered > 0) {
20179
+ lines.push(
20180
+ `Review the ${summary.uncovered} unaccounted-for file(s) first: changed code with no evidence behind it.`
20181
+ );
20182
+ } else if (summary.weaklyCovered > 0) {
20183
+ lines.push(
20184
+ `No unaccounted-for changes. Review ${summary.weaklyCovered} weakly-covered file(s) next.`
20185
+ );
20186
+ } else {
20187
+ lines.push("Every changed source file is backed by at least moderate evidence.");
20188
+ }
20189
+ lines.push("");
20190
+ if (summary.changedSourceFiles > 0) {
20191
+ lines.push("| \u{1F534} Uncovered | \u{1F7E1} Weak | \u{1F7E2} Covered | Changed files |");
20192
+ lines.push("| ---: | ---: | ---: | ---: |");
20193
+ lines.push(
20194
+ `| ${summary.uncovered} | ${summary.weaklyCovered} | ${summary.covered} | ${summary.changedSourceFiles} |`
20195
+ );
20196
+ lines.push("");
20197
+ }
20198
+ lines.push("| Claims | Stakeholder | Engineer | Strong | Moderate | Weak | None |");
20199
+ lines.push("| ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
20200
+ lines.push(
20201
+ `| ${summary.totalClaims} | ${summary.byAudience.stakeholder} | ${summary.byAudience.engineer} | ${summary.byStrength.strong} | ${summary.byStrength.moderate} | ${summary.byStrength.weak} | ${summary.byStrength.none} |`
20202
+ );
20203
+ lines.push("");
20204
+ renderUncoveredBand(lines, review.changedFiles);
20205
+ renderWeakBand(lines, review.changedFiles);
20206
+ renderAudienceSection(
20207
+ lines,
20208
+ "Stakeholder behaviour",
20209
+ review.claims.filter((c) => c.audience === "stakeholder")
20210
+ );
20211
+ renderAudienceSection(
20212
+ lines,
20213
+ "Engineer changes",
20214
+ review.claims.filter((c) => c.audience === "engineer")
20215
+ );
20216
+ return lines.join("\n").trimEnd();
20217
+ }
20218
+ };
20219
+
20220
+ // src/formatters/review-html.ts
20221
+ function escapeHtml3(value) {
20222
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20223
+ }
20224
+ var STRENGTH_LABEL = {
20225
+ strong: "Strong",
20226
+ moderate: "Moderate",
20227
+ weak: "Weak",
20228
+ none: "None"
20229
+ };
20230
+ function statusIcon3(status) {
20231
+ switch (status) {
20232
+ case "passed":
20233
+ return "\u2705";
20234
+ case "failed":
20235
+ return "\u274C";
20236
+ case "skipped":
20237
+ return "\u2298";
20238
+ default:
20239
+ return "\u2022";
20240
+ }
20241
+ }
20242
+ function formatStep3(step) {
20243
+ return `<li><strong>${escapeHtml3(step.keyword)}</strong> ${escapeHtml3(step.text)}</li>`;
20244
+ }
20245
+ function inlineDoc(doc) {
20246
+ switch (doc.kind) {
20247
+ case "note":
20248
+ return escapeHtml3(doc.text);
20249
+ case "section":
20250
+ return `<strong>${escapeHtml3(doc.title)}</strong>: ${escapeHtml3(doc.markdown)}`;
20251
+ case "kv":
20252
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(String(doc.value))}`;
20253
+ case "code":
20254
+ return `${escapeHtml3(doc.label)}: <code>${escapeHtml3(doc.content)}</code>`;
20255
+ case "link":
20256
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(doc.url)}`;
20257
+ default:
20258
+ return escapeHtml3(doc.kind);
20259
+ }
20260
+ }
20261
+ function renderEvidenceArtifacts(testCase) {
20262
+ const parts = [];
20263
+ for (const att of testCase.attachments) {
20264
+ if (att.mediaType.startsWith("image/") && att.contentEncoding === "BASE64") {
20265
+ parts.push(
20266
+ `<img class="shot" alt="${escapeHtml3(att.name)}" src="data:${escapeHtml3(att.mediaType)};base64,${att.body}" />`
20267
+ );
20268
+ }
20269
+ }
20270
+ if ((testCase.story.otelSpans?.length ?? 0) > 0) {
20271
+ parts.push(
20272
+ `<p class="trace-note">\u{1F4E1} ${testCase.story.otelSpans.length} OTEL span(s) captured</p>`
20273
+ );
20274
+ }
20275
+ return parts.length > 0 ? `<div class="artifacts">${parts.join("")}</div>` : "";
20276
+ }
20277
+ function renderTicketPills(claim) {
20278
+ const tickets = claim.testCase.story.tickets ?? [];
20279
+ if (tickets.length === 0) return "";
20280
+ return `<div class="ticket-row">${tickets.map((ticket) => {
20281
+ const label = escapeHtml3(ticket.id);
20282
+ if (ticket.url) {
20283
+ return `<a class="ticket-pill" href="${escapeHtml3(ticket.url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
20284
+ }
20285
+ return `<span class="ticket-pill">${label}</span>`;
20286
+ }).join("")}</div>`;
20287
+ }
20288
+ function renderClaimCard(claim) {
20289
+ const ticketSearch = (claim.testCase.story.tickets ?? []).map((ticket) => ticket.id).join(" ");
20290
+ const search = escapeHtml3(
20291
+ `${claim.scenario} ${claim.sourceFile} ${claim.changeType} ${claim.audience} ${claim.strength} ${ticketSearch}`
20292
+ ).toLowerCase();
20293
+ const steps = claim.testCase.story.steps.length > 0 ? `<ul class="step-list">${claim.testCase.story.steps.map(formatStep3).join("")}</ul>` : "";
20294
+ const reasons = `<ul class="reasons">${claim.strengthReasons.map((r) => `<li>${escapeHtml3(r)}</li>`).join("")}</ul>`;
20295
+ const intent = claim.intent !== void 0 ? `<div class="intent"><span class="intent-label">Why</span> ${escapeHtml3(claim.intent)}</div>` : "";
20296
+ const covers = claim.coversFiles.length > 0 ? `<p class="covers">Covers ${claim.coversFiles.map((f) => `<code>${escapeHtml3(f)}</code>`).join(", ")}</p>` : "";
20297
+ const docs = (claim.testCase.story.docs ?? []).filter(
20298
+ (d) => d.kind === "section" || d.kind === "note"
20299
+ );
20300
+ const extraDocs = docs.length > 0 && claim.intent === void 0 ? `<div class="intent">${docs.map(inlineDoc).join("<br>")}</div>` : "";
20301
+ return `
20302
+ <article class="claim-card" data-audience="${claim.audience}" data-strength="${claim.strength}" data-search="${search}">
20303
+ <header class="claim-header">
20304
+ <div>
20305
+ <span class="strength-badge strength-${claim.strength}">${STRENGTH_LABEL[claim.strength]}</span>
20306
+ ${claim.changeType !== "unknown" ? `<span class="change-pill">${escapeHtml3(claim.changeType)}</span>` : ""}
20307
+ <h3>${statusIcon3(claim.status)} ${escapeHtml3(claim.scenario)}</h3>
20308
+ <p class="source">${escapeHtml3(`${claim.sourceFile}:${claim.sourceLine}`)}</p>
20309
+ ${renderTicketPills(claim)}
20310
+ </div>
20311
+ </header>
20312
+ ${intent}${extraDocs}
20313
+ <div class="evidence-block">
20314
+ <span class="evidence-label">Evidence</span>
20315
+ ${reasons}
20316
+ </div>
20317
+ ${covers}
20318
+ ${renderEvidenceArtifacts(claim.testCase)}
20319
+ ${steps}
20320
+ </article>`;
20321
+ }
20322
+ function renderChangedFileRow(file) {
20323
+ const claims = file.claims.length > 0 ? file.claims.map((c) => `${escapeHtml3(c.scenario)} <em>(${c.strength})</em>`).join(", ") : "\u2014";
20324
+ return `<tr data-band="${file.band}">
20325
+ <td><span class="band-dot band-${file.band}"></span></td>
20326
+ <td><code>${escapeHtml3(file.path)}</code></td>
20327
+ <td>${escapeHtml3(file.changeKind)}</td>
20328
+ <td>${claims}</td>
20329
+ </tr>`;
20330
+ }
20331
+ function renderAudienceSection2(title, claims) {
20332
+ if (claims.length === 0) return "";
20333
+ return `<section class="audience-section">
20334
+ <h2>${escapeHtml3(title)} <span class="count">${claims.length}</span></h2>
20335
+ <div class="claim-list">${claims.map(renderClaimCard).join("\n")}</div>
20336
+ </section>`;
20337
+ }
20338
+ var REVIEW_CSS = `
20339
+ * { box-sizing: border-box; }
20340
+ body { margin: 0; font-family: var(--font-sans, system-ui, sans-serif); background: var(--background); color: var(--foreground); }
20341
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 20px 80px; }
20342
+ h1, h2, h3, p { margin: 0; }
20343
+ .review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
20344
+ .subtle { color: var(--muted-foreground); margin-top: 6px; }
20345
+ .theme-toggle { background: var(--secondary); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; cursor: pointer; font-size: 1.1rem; color: var(--foreground); }
20346
+ .card, .claim-card, .summary-card, .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius, 16px); }
20347
+ .hero-card { padding: 24px; margin-bottom: 20px; }
20348
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
20349
+ .summary-card { padding: 14px 16px; }
20350
+ .summary-card strong { display: block; font-size: 1.8rem; }
20351
+ .priority-banner { padding: 18px 20px; margin-bottom: 20px; background: linear-gradient(135deg, color-mix(in srgb, var(--destructive) 10%, transparent), var(--card)); }
20352
+ .panel { padding: 18px; margin-bottom: 24px; }
20353
+ table { width: 100%; border-collapse: collapse; }
20354
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
20355
+ th { color: var(--muted-foreground); font-weight: 600; }
20356
+ .band-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
20357
+ .band-uncovered { background: var(--destructive); }
20358
+ .band-weak { background: var(--warning, #b58900); }
20359
+ .band-covered { background: var(--success, #2e7d32); }
20360
+ .toolbar { position: sticky; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 20px; }
20361
+ .toolbar input { flex: 1 1 240px; border: 1px solid var(--border); border-radius: 999px; padding: 10px 14px; font: inherit; background: var(--background); color: var(--foreground); }
20362
+ .toolbar button { border: 1px solid var(--border); background: var(--secondary); border-radius: 999px; padding: 10px 14px; font: inherit; cursor: pointer; color: var(--foreground); }
20363
+ .toolbar button.active { background: var(--foreground); color: var(--background); }
20364
+ .audience-section { margin-bottom: 28px; }
20365
+ .audience-section h2 { margin-bottom: 12px; }
20366
+ .count { color: var(--muted-foreground); font-weight: 400; }
20367
+ .claim-list { display: grid; gap: 14px; }
20368
+ .claim-card { padding: 18px; }
20369
+ .claim-header h3 { margin-top: 8px; }
20370
+ .source { color: var(--muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; margin-top: 4px; }
20371
+ .ticket-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
20372
+ .ticket-pill { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; color: var(--muted-foreground); background: var(--background); font-size: 0.78rem; text-decoration: none; }
20373
+ .ticket-pill:hover { color: var(--foreground); border-color: var(--muted-foreground); }
20374
+ .strength-badge, .change-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 0.8rem; margin-right: 6px; }
20375
+ .change-pill { background: var(--secondary); }
20376
+ .strength-strong { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); }
20377
+ .strength-moderate { background: color-mix(in srgb, var(--warning, #b58900) 20%, transparent); color: var(--warning, #b58900); }
20378
+ .strength-weak { background: color-mix(in srgb, #d2691e 20%, transparent); color: #b5530a; }
20379
+ .strength-none { background: color-mix(in srgb, var(--destructive) 16%, transparent); color: var(--destructive); }
20380
+ .intent { margin: 12px 0; padding: 10px 12px; border-left: 3px solid var(--border); background: color-mix(in srgb, var(--card) 60%, var(--background)); border-radius: 6px; }
20381
+ .intent-label { font-weight: 700; margin-right: 6px; }
20382
+ .evidence-block { margin-top: 10px; }
20383
+ .evidence-label { font-weight: 600; color: var(--muted-foreground); }
20384
+ .reasons { margin: 6px 0 0; padding-left: 18px; }
20385
+ .covers { color: var(--muted-foreground); margin-top: 8px; font-size: 0.9rem; }
20386
+ .artifacts { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
20387
+ .shot { max-width: 280px; max-height: 200px; border: 1px solid var(--border); border-radius: 8px; }
20388
+ .trace-note { color: var(--muted-foreground); }
20389
+ .step-list { margin: 12px 0 0; padding-left: 18px; color: var(--muted-foreground); }
20390
+ `;
20391
+ var JS_THEME_TOGGLE2 = `
20392
+ function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
20393
+ function getEffectiveTheme() { var s = localStorage.getItem('review-theme'); return (s === 'dark' || s === 'light') ? s : getSystemTheme(); }
20394
+ function toggleTheme() { var n = getEffectiveTheme() === 'dark' ? 'light' : 'dark'; localStorage.setItem('review-theme', n); applyTheme(n); }
20395
+ function applyTheme(t) {
20396
+ document.documentElement.setAttribute('data-theme', t);
20397
+ var b = document.querySelector('.theme-toggle');
20398
+ if (b) { b.textContent = t === 'dark' ? '\\u2600\\ufe0f' : '\\ud83c\\udf19'; }
20399
+ }
20400
+ `;
20401
+ var ReviewHtmlFormatter = class {
20402
+ title;
20403
+ theme;
20404
+ darkMode;
20405
+ constructor(options = {}) {
20406
+ this.title = options.title ?? "Evidence Review";
20407
+ this.theme = resolveTheme(options.theme ?? "default");
20408
+ this.darkMode = options.darkMode ?? true;
20409
+ }
20410
+ format(review) {
20411
+ const { summary, context } = review;
20412
+ const priority = summary.changedSourceFiles === 0 ? "No changed source files supplied \u2014 showing claims and evidence only." : summary.uncovered > 0 ? `${summary.uncovered} changed file(s) have no evidence. Review them first.` : summary.weaklyCovered > 0 ? `No unaccounted-for changes. ${summary.weaklyCovered} file(s) are weakly covered.` : "Every changed source file is backed by at least moderate evidence.";
20413
+ const changedFilesPanel = summary.changedSourceFiles > 0 ? `<section class="panel">
20414
+ <h2>Changed files</h2>
20415
+ <table>
20416
+ <thead><tr><th></th><th>File</th><th>Change</th><th>Evidence</th></tr></thead>
20417
+ <tbody>${review.changedFiles.map(renderChangedFileRow).join("")}</tbody>
20418
+ </table>
20419
+ </section>` : "";
20420
+ const themeToggleHtml = this.darkMode ? `<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>` : "";
20421
+ const themeInitJs = this.darkMode ? `${JS_THEME_TOGGLE2}
20422
+ applyTheme(getEffectiveTheme());` : "";
20423
+ const themeAttr = this.darkMode ? ' data-theme="light"' : "";
20424
+ const refsLine = context.baseRef || context.headRef ? `<p class="subtle">Comparing ${escapeHtml3(context.baseRef ?? "base")} \u2192 ${escapeHtml3(context.headRef ?? "head")}</p>` : "";
20425
+ return `<!doctype html>
20426
+ <html lang="en"${themeAttr}>
20427
+ <head>
20428
+ <meta charset="utf-8" />
20429
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20430
+ <title>${escapeHtml3(this.title)}</title>
20431
+ <style>
20432
+ ${this.theme.css}
20433
+ ${REVIEW_CSS}
20434
+ </style>
20435
+ </head>
20436
+ <body>
20437
+ <main>
20438
+ <div class="hero-card card">
20439
+ <div class="review-header">
20440
+ <h1>${escapeHtml3(this.title)}</h1>
20441
+ ${themeToggleHtml}
20442
+ </div>
20443
+ ${refsLine}
20444
+ </div>
20445
+ <section class="summary-grid">
20446
+ <div class="summary-card"><strong>${summary.uncovered}</strong><span>\u{1F534} Uncovered</span></div>
20447
+ <div class="summary-card"><strong>${summary.weaklyCovered}</strong><span>\u{1F7E1} Weak</span></div>
20448
+ <div class="summary-card"><strong>${summary.covered}</strong><span>\u{1F7E2} Covered</span></div>
20449
+ <div class="summary-card"><strong>${summary.totalClaims}</strong><span>Claims</span></div>
20450
+ <div class="summary-card"><strong>${summary.byStrength.strong}</strong><span>Strong</span></div>
20451
+ <div class="summary-card"><strong>${summary.byStrength.weak + summary.byStrength.none}</strong><span>Weak/None</span></div>
20452
+ </section>
20453
+ <section class="card priority-banner">
20454
+ <h2>Review priority</h2>
20455
+ <p class="subtle">${escapeHtml3(priority)}</p>
20456
+ </section>
20457
+ ${changedFilesPanel}
20458
+ <section class="toolbar">
20459
+ <input type="search" placeholder="Filter claims by scenario, file, change-type" aria-label="Filter claims" />
20460
+ <button type="button" class="active" data-filter="all">All</button>
20461
+ <button type="button" data-filter="stakeholder">Stakeholder</button>
20462
+ <button type="button" data-filter="engineer">Engineer</button>
20463
+ <button type="button" data-filter="weak">Weak/None</button>
20464
+ </section>
20465
+ ${renderAudienceSection2("Stakeholder behaviour", review.claims.filter((c) => c.audience === "stakeholder"))}
20466
+ ${renderAudienceSection2("Engineer changes", review.claims.filter((c) => c.audience === "engineer"))}
20467
+ </main>
20468
+ <script>
20469
+ ${themeInitJs}
20470
+ const input = document.querySelector('input[type="search"]');
20471
+ const buttons = Array.from(document.querySelectorAll('[data-filter]'));
20472
+ const cards = Array.from(document.querySelectorAll('.claim-card'));
20473
+ let activeFilter = 'all';
20474
+ function applyFilters() {
20475
+ const query = (input.value || '').trim().toLowerCase();
20476
+ cards.forEach((card) => {
20477
+ const audience = card.getAttribute('data-audience');
20478
+ const strength = card.getAttribute('data-strength');
20479
+ const haystack = card.getAttribute('data-search') || '';
20480
+ let matchesFilter = activeFilter === 'all'
20481
+ || audience === activeFilter
20482
+ || (activeFilter === 'weak' && (strength === 'weak' || strength === 'none'));
20483
+ const matchesSearch = !query || haystack.includes(query);
20484
+ card.style.display = matchesFilter && matchesSearch ? '' : 'none';
20485
+ });
20486
+ }
20487
+ input.addEventListener('input', applyFilters);
20488
+ buttons.forEach((button) => {
20489
+ button.addEventListener('click', () => {
20490
+ activeFilter = button.getAttribute('data-filter');
20491
+ buttons.forEach((b) => b.classList.toggle('active', b === button));
20492
+ applyFilters();
20493
+ });
20494
+ });
20495
+ applyFilters();
20496
+ </script>
20497
+ </body>
20498
+ </html>`;
20499
+ }
20500
+ };
19484
20501
 
19485
20502
  // src/index.ts
19486
20503
  var FORMAT_EXTENSIONS = {
19487
20504
  astro: ".md",
20505
+ "behavior-manifest-json": ".behavior-manifest.json",
19488
20506
  markdown: ".md",
19489
20507
  html: ".html",
19490
20508
  "cucumber-html": ".cucumber.html",
@@ -19492,6 +20510,7 @@ var FORMAT_EXTENSIONS = {
19492
20510
  "cucumber-json": ".cucumber.json",
19493
20511
  "cucumber-messages": ".ndjson",
19494
20512
  confluence: ".adf.json",
20513
+ "scenario-index-json": ".scenarios-index.json",
19495
20514
  "story-report-json": ".story-report.json"
19496
20515
  };
19497
20516
  var TEST_EXTENSIONS = [
@@ -19519,11 +20538,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
19519
20538
  const ext = FORMAT_EXTENSIONS[format];
19520
20539
  const effectiveName = outputName + (outputNameSuffix ?? "");
19521
20540
  if (mode === "aggregated") {
19522
- return toPosix(path7.join(baseOutputDir, `${effectiveName}${ext}`));
20541
+ return toPosix(path8.join(baseOutputDir, `${effectiveName}${ext}`));
19523
20542
  }
19524
20543
  const normalizedSource = toPosix(sourceFile);
19525
- const dirOfSource = path7.posix.dirname(normalizedSource);
19526
- let baseName = path7.posix.basename(normalizedSource);
20544
+ const dirOfSource = path8.posix.dirname(normalizedSource);
20545
+ let baseName = path8.posix.basename(normalizedSource);
19527
20546
  for (const testExt of TEST_EXTENSIONS) {
19528
20547
  if (baseName.endsWith(testExt)) {
19529
20548
  baseName = baseName.slice(0, -testExt.length);
@@ -19532,9 +20551,9 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
19532
20551
  }
19533
20552
  const fileName = `${baseName}.${effectiveName}${ext}`;
19534
20553
  if (colocatedStyle === "adjacent") {
19535
- return toPosix(path7.posix.join(dirOfSource, fileName));
20554
+ return toPosix(path8.posix.join(dirOfSource, fileName));
19536
20555
  }
19537
- return toPosix(path7.posix.join(baseOutputDir, dirOfSource, fileName));
20556
+ return toPosix(path8.posix.join(baseOutputDir, dirOfSource, fileName));
19538
20557
  }
19539
20558
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
19540
20559
  const groups = /* @__PURE__ */ new Map();
@@ -19620,6 +20639,12 @@ var ReportGenerator = class {
19620
20639
  storyReportJson: {
19621
20640
  pretty: options.storyReportJson?.pretty ?? true
19622
20641
  },
20642
+ scenarioIndexJson: {
20643
+ pretty: options.scenarioIndexJson?.pretty ?? true
20644
+ },
20645
+ behaviorManifestJson: {
20646
+ pretty: options.behaviorManifestJson?.pretty ?? true
20647
+ },
19623
20648
  cucumberMessages: {
19624
20649
  uriStrategy: options.cucumberMessages?.uriStrategy ?? "sourceFile",
19625
20650
  includeSynthetics: options.cucumberMessages?.includeSynthetics ?? true,
@@ -19735,8 +20760,8 @@ var ReportGenerator = class {
19735
20760
  if (astroPaths) {
19736
20761
  for (const mdPath of astroPaths) {
19737
20762
  const content = await fsPromises.readFile(mdPath, "utf8");
19738
- const mdDir = path7.dirname(mdPath);
19739
- const assetsDir = path7.resolve(this.options.astro.assetsDir);
20763
+ const mdDir = path8.dirname(mdPath);
20764
+ const assetsDir = path8.resolve(this.options.astro.assetsDir);
19740
20765
  const result = copyMarkdownAssets({
19741
20766
  markdown: content,
19742
20767
  markdownDir: mdDir,
@@ -19767,9 +20792,9 @@ var ReportGenerator = class {
19767
20792
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
19768
20793
  const ext = FORMAT_EXTENSIONS[format];
19769
20794
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
19770
- const outputPath = toPosix(path7.join(this.options.outputDir, `${effectiveName}${ext}`));
20795
+ const outputPath = toPosix(path8.join(this.options.outputDir, `${effectiveName}${ext}`));
19771
20796
  const content = await this.formatContent(run, format);
19772
- const dir = path7.dirname(outputPath);
20797
+ const dir = path8.dirname(outputPath);
19773
20798
  await fsPromises.mkdir(dir, { recursive: true });
19774
20799
  await this.deps.writeFile(outputPath, content);
19775
20800
  return [outputPath];
@@ -19781,7 +20806,7 @@ var ReportGenerator = class {
19781
20806
  testCases
19782
20807
  };
19783
20808
  const content = await this.formatContent(groupRun, format);
19784
- const dir = path7.dirname(outputPath);
20809
+ const dir = path8.dirname(outputPath);
19785
20810
  await fsPromises.mkdir(dir, { recursive: true });
19786
20811
  await this.deps.writeFile(outputPath, content);
19787
20812
  writtenPaths.push(outputPath);
@@ -19894,6 +20919,18 @@ var ReportGenerator = class {
19894
20919
  });
19895
20920
  return formatter.format(run);
19896
20921
  }
20922
+ case "scenario-index-json": {
20923
+ const formatter = new ScenarioIndexJsonFormatter({
20924
+ pretty: this.options.scenarioIndexJson.pretty
20925
+ });
20926
+ return formatter.format(run);
20927
+ }
20928
+ case "behavior-manifest-json": {
20929
+ const formatter = new BehaviorManifestJsonFormatter({
20930
+ pretty: this.options.behaviorManifestJson.pretty
20931
+ });
20932
+ return formatter.format(run);
20933
+ }
19897
20934
  default:
19898
20935
  throw new Error(`Unknown format: ${format}`);
19899
20936
  }
@@ -19907,7 +20944,7 @@ async function generateRunComparison(args) {
19907
20944
  await fsPromises.mkdir(outputDir, { recursive: true });
19908
20945
  for (const format of args.formats) {
19909
20946
  const ext = format === "html" ? ".html" : ".md";
19910
- const outputPath = toPosix(path7.join(outputDir, `${outputName}${ext}`));
20947
+ const outputPath = toPosix(path8.join(outputDir, `${outputName}${ext}`));
19911
20948
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
19912
20949
  await fsPromises.writeFile(outputPath, content, "utf8");
19913
20950
  files.push(outputPath);
@@ -19916,23 +20953,23 @@ async function generateRunComparison(args) {
19916
20953
  }
19917
20954
 
19918
20955
  // src/init-astro.ts
19919
- import * as fs7 from "fs";
19920
- import * as path8 from "path";
20956
+ import * as fs8 from "fs";
20957
+ import * as path9 from "path";
19921
20958
  import { fileURLToPath } from "url";
19922
- var __dirname = path8.dirname(fileURLToPath(import.meta.url));
20959
+ var __dirname = path9.dirname(fileURLToPath(import.meta.url));
19923
20960
  function initAstro(options = {}) {
19924
20961
  const targetDir = options.targetDir ?? "./story-docs";
19925
20962
  const force = options.force ?? false;
19926
- if (fs7.existsSync(targetDir)) {
19927
- const entries = fs7.readdirSync(targetDir);
20963
+ if (fs8.existsSync(targetDir)) {
20964
+ const entries = fs8.readdirSync(targetDir);
19928
20965
  if (entries.length > 0 && !force) {
19929
20966
  throw new Error(
19930
20967
  `Directory "${targetDir}" already exists and is not empty. Use --force to overwrite.`
19931
20968
  );
19932
20969
  }
19933
20970
  }
19934
- const templateDir = path8.resolve(__dirname, "..", "templates", "astro-starlight");
19935
- if (!fs7.existsSync(templateDir)) {
20971
+ const templateDir = path9.resolve(__dirname, "..", "templates", "astro-starlight");
20972
+ if (!fs8.existsSync(templateDir)) {
19936
20973
  throw new Error(
19937
20974
  `Template directory not found at ${templateDir}. Ensure the package is installed correctly.`
19938
20975
  );
@@ -19941,24 +20978,24 @@ function initAstro(options = {}) {
19941
20978
  return { targetDir };
19942
20979
  }
19943
20980
  function copyDirRecursive(src, dest) {
19944
- fs7.mkdirSync(dest, { recursive: true });
19945
- const entries = fs7.readdirSync(src, { withFileTypes: true });
20981
+ fs8.mkdirSync(dest, { recursive: true });
20982
+ const entries = fs8.readdirSync(src, { withFileTypes: true });
19946
20983
  for (const entry of entries) {
19947
- const srcPath = path8.join(src, entry.name);
19948
- const destPath = path8.join(dest, entry.name);
20984
+ const srcPath = path9.join(src, entry.name);
20985
+ const destPath = path9.join(dest, entry.name);
19949
20986
  if (entry.isDirectory()) {
19950
20987
  copyDirRecursive(srcPath, destPath);
19951
20988
  } else {
19952
- fs7.copyFileSync(srcPath, destPath);
20989
+ fs8.copyFileSync(srcPath, destPath);
19953
20990
  }
19954
20991
  }
19955
20992
  }
19956
20993
 
19957
20994
  // src/config.ts
19958
20995
  import { existsSync as existsSync7 } from "fs";
19959
- import { resolve as resolve6 } from "path";
20996
+ import { resolve as resolve7 } from "path";
19960
20997
  async function loadConfig(configPath) {
19961
- const resolved = configPath ? resolve6(configPath) : resolve6(process.cwd(), "executable-stories.config.js");
20998
+ const resolved = configPath ? resolve7(configPath) : resolve7(process.cwd(), "executable-stories.config.js");
19962
20999
  if (!existsSync7(resolved)) return {};
19963
21000
  const mod = await import(resolved);
19964
21001
  const config = mod.default;
@@ -19977,6 +21014,7 @@ var EXIT_CANONICAL_VALIDATION = 2;
19977
21014
  var EXIT_GENERATION = 3;
19978
21015
  var EXIT_USAGE = 4;
19979
21016
  var EXIT_COMPARE_GATE = 5;
21017
+ var EXIT_REVIEW_GATE = 5;
19980
21018
  var HELP_TEXT = `
19981
21019
  executable-stories \u2014 Generate reports from test results JSON.
19982
21020
 
@@ -19984,6 +21022,7 @@ USAGE
19984
21022
  executable-stories format <file> [options]
19985
21023
  executable-stories format --stdin [options]
19986
21024
  executable-stories compare <baseline-file> <current-file> [options]
21025
+ executable-stories review <file> --changed-files <path> [options]
19987
21026
  executable-stories list <file> [options]
19988
21027
  executable-stories validate <file>
19989
21028
  executable-stories validate --stdin
@@ -19993,7 +21032,9 @@ USAGE
19993
21032
 
19994
21033
  SUBCOMMANDS
19995
21034
  format Read raw test results and generate reports
21035
+ watch Regenerate reports whenever the raw-run file changes (live agent index)
19996
21036
  compare Compare two runs and generate a diff report
21037
+ review Generate an Evidence Review of AI-authored changes (correlate a run to the diff)
19997
21038
  list List scenarios from a test run (text table or JSON)
19998
21039
  validate Validate a JSON file against the schema (no output generated)
19999
21040
  init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
@@ -20001,9 +21042,10 @@ SUBCOMMANDS
20001
21042
  publish-jira Publish an ADF JSON file to a Jira issue (as comment or description)
20002
21043
 
20003
21044
  OPTIONS
20004
- --format <formats> Comma-separated formats: html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, astro, confluence, story-report-json, or custom names from config (default: html)
21045
+ --format <formats> Comma-separated formats: html, markdown, 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)
20005
21046
  astro Themed Markdown (for Astro docs sites with matching CSS)
20006
21047
  confluence Atlassian Document Format (ADF) JSON for Confluence / Jira
21048
+ behavior-manifest-json Agent-readable behavior manifest and debugger warnings
20007
21049
  html Custom HTML report (accessible, dark mode, mermaid)
20008
21050
  cucumber-html Official Cucumber HTML report
20009
21051
  markdown Markdown documentation
@@ -20011,6 +21053,7 @@ OPTIONS
20011
21053
  cucumber-json Cucumber JSON
20012
21054
  cucumber-messages Raw NDJSON (Cucumber Messages)
20013
21055
  story-report-json StoryReport v1 JSON (consumed by executable-stories-react and other UI renderers)
21056
+ scenario-index-json Storybook-like scenario index for agents and explorers
20014
21057
  --config <path> Path to executable-stories.config.js (default: ./executable-stories.config.js)
20015
21058
  --input-type <type> Input type: raw, canonical, or ndjson (default: raw)
20016
21059
  --output-dir <dir> Output directory (default: reports)
@@ -20044,6 +21087,11 @@ OPTIONS
20044
21087
  --fail-on-regression Exit non-zero when any regression is detected in compare
20045
21088
  --fail-on-added-failures Exit non-zero when newly added scenarios are failing
20046
21089
  --max-regressions <n> Exit non-zero when regressions exceed threshold
21090
+ --changed-files <path> (review) Changed files: JSON (ChangedFile[] or {changedFiles,baseRef,headRef}) or "git diff --name-status" text
21091
+ --base-ref <ref> (review) Base ref label shown in the report (informational)
21092
+ --head-ref <ref> (review) Head ref label shown in the report (informational)
21093
+ --fail-on <band> (review) Gate: "uncovered" or "weak" \u2014 exit non-zero when changed code lacks evidence (default: off)
21094
+ --min-evidence <strength> (review) Gate: "weak"|"moderate"|"strong" \u2014 exit non-zero when any claim is below this strength (default: off)
20047
21095
  --emit-canonical <path> Write canonical JSON to given path
20048
21096
  --help Show this help message
20049
21097
 
@@ -20116,9 +21164,9 @@ async function parseCliArgs(argv) {
20116
21164
  process.exit(EXIT_SUCCESS);
20117
21165
  }
20118
21166
  const subcommand = args[0];
20119
- if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
21167
+ if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
20120
21168
  console.error(
20121
- `Unknown subcommand: "${subcommand}". Use "format", "compare", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
21169
+ `Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "review", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
20122
21170
  );
20123
21171
  process.exit(EXIT_USAGE);
20124
21172
  }
@@ -20156,6 +21204,9 @@ async function parseCliArgs(argv) {
20156
21204
  console.log("");
20157
21205
  console.log("Generate story docs with:");
20158
21206
  console.log(` executable-stories format run.json --format astro --output-dir ${result.targetDir}/src/content/docs/stories --asset-mode copy`);
21207
+ console.log("");
21208
+ console.log("Generate the Storybook-like scenario explorer data with:");
21209
+ console.log(` executable-stories format run.json --format story-report-json --output-dir ${result.targetDir}/public/stories --output-name story-report`);
20159
21210
  process.exit(EXIT_SUCCESS);
20160
21211
  } catch (err) {
20161
21212
  console.error(`Error: ${err.message}`);
@@ -20212,6 +21263,11 @@ async function parseCliArgs(argv) {
20212
21263
  "fail-on-regression": { type: "boolean", default: false },
20213
21264
  "fail-on-added-failures": { type: "boolean", default: false },
20214
21265
  "max-regressions": { type: "string" },
21266
+ "changed-files": { type: "string" },
21267
+ "base-ref": { type: "string" },
21268
+ "head-ref": { type: "string" },
21269
+ "fail-on": { type: "string" },
21270
+ "min-evidence": { type: "string" },
20215
21271
  "config": { type: "string" },
20216
21272
  help: { type: "boolean", default: false }
20217
21273
  },
@@ -20252,7 +21308,7 @@ async function parseCliArgs(argv) {
20252
21308
  }
20253
21309
  const pluginConfig = await loadConfig(values["config"]);
20254
21310
  const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
20255
- const builtInFormats = /* @__PURE__ */ new Set(["astro", "confluence", "html", "markdown", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "story-report-json"]);
21311
+ const builtInFormats = /* @__PURE__ */ new Set(["astro", "behavior-manifest-json", "confluence", "html", "markdown", "junit", "cucumber-json", "cucumber-messages", "cucumber-html", "scenario-index-json", "story-report-json"]);
20256
21312
  const formatStr = values.format;
20257
21313
  const allRequestedFormats = formatStr.split(",").map((f) => f.trim());
20258
21314
  const builtInRequested = allRequestedFormats.filter((f) => builtInFormats.has(f));
@@ -20260,7 +21316,7 @@ async function parseCliArgs(argv) {
20260
21316
  const unknownFormats = allRequestedFormats.filter((f) => !builtInFormats.has(f) && !customFormatterNames.has(f));
20261
21317
  if (unknownFormats.length > 0) {
20262
21318
  const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
20263
- console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, confluence, html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, story-report-json${knownCustom}.`);
21319
+ console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, behavior-manifest-json, confluence, html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
20264
21320
  process.exit(EXIT_USAGE);
20265
21321
  }
20266
21322
  const formats = builtInRequested;
@@ -20337,6 +21393,17 @@ async function parseCliArgs(argv) {
20337
21393
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
20338
21394
  process.exit(EXIT_USAGE);
20339
21395
  }
21396
+ const failOnRaw = values["fail-on"];
21397
+ if (failOnRaw !== void 0 && failOnRaw !== "uncovered" && failOnRaw !== "weak") {
21398
+ console.error(`Error: --fail-on must be "uncovered" or "weak", got "${failOnRaw}".`);
21399
+ process.exit(EXIT_USAGE);
21400
+ }
21401
+ const minEvidenceRaw = values["min-evidence"];
21402
+ const validMinEvidence = /* @__PURE__ */ new Set(["weak", "moderate", "strong"]);
21403
+ if (minEvidenceRaw !== void 0 && !validMinEvidence.has(minEvidenceRaw)) {
21404
+ console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
21405
+ process.exit(EXIT_USAGE);
21406
+ }
20340
21407
  const cliArgs = {
20341
21408
  subcommand,
20342
21409
  inputFile,
@@ -20388,6 +21455,11 @@ async function parseCliArgs(argv) {
20388
21455
  failOnRegression: values["fail-on-regression"],
20389
21456
  failOnAddedFailures: values["fail-on-added-failures"],
20390
21457
  maxRegressions,
21458
+ changedFilesPath: values["changed-files"],
21459
+ baseRef: values["base-ref"],
21460
+ headRef: values["head-ref"],
21461
+ failOn: failOnRaw,
21462
+ minEvidence: minEvidenceRaw,
20391
21463
  config: values["config"]
20392
21464
  };
20393
21465
  return { args: cliArgs, pluginConfig, customRequested };
@@ -20396,27 +21468,27 @@ async function readInput(args) {
20396
21468
  if (args.stdin) {
20397
21469
  return readStdin();
20398
21470
  }
20399
- const filePath = path9.resolve(args.inputFile);
20400
- if (!fs8.existsSync(filePath)) {
21471
+ const filePath = path10.resolve(args.inputFile);
21472
+ if (!fs9.existsSync(filePath)) {
20401
21473
  console.error(`Error: File not found: ${filePath}`);
20402
21474
  process.exit(EXIT_USAGE);
20403
21475
  }
20404
- return fs8.readFileSync(filePath, "utf8");
21476
+ return fs9.readFileSync(filePath, "utf8");
20405
21477
  }
20406
21478
  function readFileInput(filePath) {
20407
- const resolved = path9.resolve(filePath);
20408
- if (!fs8.existsSync(resolved)) {
21479
+ const resolved = path10.resolve(filePath);
21480
+ if (!fs9.existsSync(resolved)) {
20409
21481
  console.error(`Error: File not found: ${resolved}`);
20410
21482
  process.exit(EXIT_USAGE);
20411
21483
  }
20412
- return fs8.readFileSync(resolved, "utf8");
21484
+ return fs9.readFileSync(resolved, "utf8");
20413
21485
  }
20414
21486
  function readStdin() {
20415
- return new Promise((resolve8, reject) => {
21487
+ return new Promise((resolve9, reject) => {
20416
21488
  const chunks = [];
20417
21489
  process.stdin.setEncoding("utf8");
20418
21490
  process.stdin.on("data", (chunk) => chunks.push(chunk));
20419
- process.stdin.on("end", () => resolve8(chunks.join("")));
21491
+ process.stdin.on("end", () => resolve9(chunks.join("")));
20420
21492
  process.stdin.on("error", reject);
20421
21493
  });
20422
21494
  }
@@ -20542,14 +21614,14 @@ function tryNormalizeRunFromText(text2, args) {
20542
21614
  }
20543
21615
  }
20544
21616
  function listBaselineCandidates(currentFile, args) {
20545
- const baselineDir = path9.resolve(args.baselineDir ?? path9.dirname(currentFile));
20546
- const currentResolved = path9.resolve(currentFile);
20547
- if (!fs8.existsSync(baselineDir)) {
21617
+ const baselineDir = path10.resolve(args.baselineDir ?? path10.dirname(currentFile));
21618
+ const currentResolved = path10.resolve(currentFile);
21619
+ if (!fs9.existsSync(baselineDir)) {
20548
21620
  console.error(`Error: baseline directory not found: ${baselineDir}`);
20549
21621
  process.exit(EXIT_USAGE);
20550
21622
  }
20551
- const entries = fs8.readdirSync(baselineDir, { withFileTypes: true });
20552
- return entries.filter((entry) => entry.isFile()).map((entry) => path9.join(baselineDir, entry.name)).filter((candidate) => path9.resolve(candidate) !== currentResolved).filter(
21623
+ const entries = fs9.readdirSync(baselineDir, { withFileTypes: true });
21624
+ return entries.filter((entry) => entry.isFile()).map((entry) => path10.join(baselineDir, entry.name)).filter((candidate) => path10.resolve(candidate) !== currentResolved).filter(
20553
21625
  (candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
20554
21626
  );
20555
21627
  }
@@ -20557,14 +21629,14 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
20557
21629
  const candidates = listBaselineCandidates(currentFile, args);
20558
21630
  const comparable = [];
20559
21631
  for (const candidate of candidates) {
20560
- const run = tryNormalizeRunFromText(fs8.readFileSync(candidate, "utf8"), args);
21632
+ const run = tryNormalizeRunFromText(fs9.readFileSync(candidate, "utf8"), args);
20561
21633
  if (run) {
20562
21634
  comparable.push({ file: candidate, run });
20563
21635
  }
20564
21636
  }
20565
21637
  if (comparable.length === 0) {
20566
21638
  console.error(
20567
- `Error: no compatible baseline files found in ${path9.resolve(args.baselineDir ?? path9.dirname(currentFile))}.`
21639
+ `Error: no compatible baseline files found in ${path10.resolve(args.baselineDir ?? path10.dirname(currentFile))}.`
20568
21640
  );
20569
21641
  process.exit(EXIT_USAGE);
20570
21642
  }
@@ -20601,6 +21673,30 @@ async function main() {
20601
21673
  process.exit(EXIT_GENERATION);
20602
21674
  }
20603
21675
  }
21676
+ if (args.subcommand === "review") {
21677
+ const text3 = await readInput(args);
21678
+ const run = applySelection(normalizeRunFromText(text3, args).run, args);
21679
+ const context = loadReviewContext(args);
21680
+ const review = buildReview(run, context);
21681
+ try {
21682
+ const files = writeReviewReport(review, args);
21683
+ for (const f of files) {
21684
+ console.log(f);
21685
+ }
21686
+ const gateFailures = evaluateReviewGate(review, args);
21687
+ if (gateFailures.length > 0) {
21688
+ for (const failure of gateFailures) {
21689
+ console.error(`Review gate failed: ${failure}`);
21690
+ }
21691
+ process.exit(EXIT_REVIEW_GATE);
21692
+ }
21693
+ process.exit(EXIT_SUCCESS);
21694
+ } catch (err) {
21695
+ const msg = err instanceof Error ? err.message : String(err);
21696
+ console.error(`Review failed: ${msg}`);
21697
+ process.exit(EXIT_GENERATION);
21698
+ }
21699
+ }
20604
21700
  if (args.subcommand === "list") {
20605
21701
  const text3 = await readInput(args);
20606
21702
  const run = applySelection(normalizeRunFromText(text3, args).run, args);
@@ -20617,6 +21713,24 @@ async function main() {
20617
21713
  console.log(output);
20618
21714
  process.exit(EXIT_SUCCESS);
20619
21715
  }
21716
+ if (args.subcommand === "watch") {
21717
+ if (!args.inputFile) {
21718
+ console.error("Error: watch requires an input file (the raw-run JSON the framework writes).");
21719
+ process.exit(EXIT_USAGE);
21720
+ }
21721
+ console.log(
21722
+ `Watching ${args.inputFile} \u2192 regenerating [${args.formats.join(", ")}] into ${args.outputDir}/ (Ctrl+C to stop)`
21723
+ );
21724
+ startWatch({
21725
+ input: args.inputFile,
21726
+ outputDir: args.outputDir,
21727
+ outputName: args.outputName,
21728
+ formats: args.formats,
21729
+ inputType: args.inputType === "canonical" ? "canonical" : "raw",
21730
+ synthesize: args.synthesizeStories
21731
+ });
21732
+ return;
21733
+ }
20620
21734
  const text2 = await readInput(args);
20621
21735
  if (args.inputType === "ndjson") {
20622
21736
  if (args.subcommand === "validate") {
@@ -20660,9 +21774,9 @@ async function main() {
20660
21774
  process.exit(EXIT_SCHEMA_VALIDATION);
20661
21775
  }
20662
21776
  if (args.emitCanonical) {
20663
- const outPath = path9.resolve(args.emitCanonical);
20664
- fs8.mkdirSync(path9.dirname(outPath), { recursive: true });
20665
- fs8.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
21777
+ const outPath = path10.resolve(args.emitCanonical);
21778
+ fs9.mkdirSync(path10.dirname(outPath), { recursive: true });
21779
+ fs9.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
20666
21780
  }
20667
21781
  try {
20668
21782
  const result = await generateReports(run, args);
@@ -20719,9 +21833,9 @@ ${msg}`);
20719
21833
  }
20720
21834
  const run = data;
20721
21835
  if (args.emitCanonical) {
20722
- const outPath = path9.resolve(args.emitCanonical);
20723
- fs8.mkdirSync(path9.dirname(outPath), { recursive: true });
20724
- fs8.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
21836
+ const outPath = path10.resolve(args.emitCanonical);
21837
+ fs9.mkdirSync(path10.dirname(outPath), { recursive: true });
21838
+ fs9.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
20725
21839
  }
20726
21840
  try {
20727
21841
  const result = await generateReports(run, args);
@@ -20777,9 +21891,9 @@ ${msg}`);
20777
21891
  process.exit(EXIT_CANONICAL_VALIDATION);
20778
21892
  }
20779
21893
  if (args.emitCanonical) {
20780
- const outPath = path9.resolve(args.emitCanonical);
20781
- fs8.mkdirSync(path9.dirname(outPath), { recursive: true });
20782
- fs8.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
21894
+ const outPath = path10.resolve(args.emitCanonical);
21895
+ fs9.mkdirSync(path10.dirname(outPath), { recursive: true });
21896
+ fs9.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
20783
21897
  }
20784
21898
  try {
20785
21899
  const result = await generateReports(canonical, args, droppedMissingStory);
@@ -20804,9 +21918,9 @@ function runCustomFormatters(run, customRequested, formatters, args) {
20804
21918
  const ext = formatter.fileExtension ?? formatName;
20805
21919
  const baseName = args.outputName ?? "report";
20806
21920
  const filename = args.outputNameTimestamp ? `${baseName}-${Math.floor(run.startedAtMs / 1e3)}.${ext}` : `${baseName}.${ext}`;
20807
- const filepath = path9.join(outputDir, filename);
20808
- fs8.mkdirSync(outputDir, { recursive: true });
20809
- fs8.writeFileSync(filepath, content, "utf8");
21921
+ const filepath = path10.join(outputDir, filename);
21922
+ fs9.mkdirSync(outputDir, { recursive: true });
21923
+ fs9.writeFileSync(filepath, content, "utf8");
20810
21924
  console.log(`Generated: ${filepath}`);
20811
21925
  } catch (err) {
20812
21926
  console.error(`Error running custom formatter "${formatName}": ${err instanceof Error ? err.message : String(err)}`);
@@ -20856,13 +21970,13 @@ async function dispatchNotifications(run, args) {
20856
21970
  }
20857
21971
  function runHistoryPipeline(run, args) {
20858
21972
  if (!args.historyFile) return;
20859
- const historyPath = path9.resolve(args.historyFile);
21973
+ const historyPath = path10.resolve(args.historyFile);
20860
21974
  const store = loadHistory(
20861
21975
  { filePath: historyPath },
20862
21976
  {
20863
21977
  readFile: (p) => {
20864
21978
  try {
20865
- return fs8.readFileSync(p, "utf8");
21979
+ return fs9.readFileSync(p, "utf8");
20866
21980
  } catch {
20867
21981
  return void 0;
20868
21982
  }
@@ -20875,11 +21989,11 @@ function runHistoryPipeline(run, args) {
20875
21989
  run,
20876
21990
  maxRuns: args.maxHistoryRuns
20877
21991
  });
20878
- const dir = path9.dirname(historyPath);
20879
- fs8.mkdirSync(dir, { recursive: true });
21992
+ const dir = path10.dirname(historyPath);
21993
+ fs9.mkdirSync(dir, { recursive: true });
20880
21994
  saveHistory(
20881
21995
  { filePath: historyPath, store: updated },
20882
- { writeFile: (p, content) => fs8.writeFileSync(p, content, "utf8") }
21996
+ { writeFile: (p, content) => fs9.writeFileSync(p, content, "utf8") }
20883
21997
  );
20884
21998
  let metricsCount = 0;
20885
21999
  for (const testId of Object.keys(updated.tests)) {
@@ -20959,6 +22073,102 @@ async function generateCompareReports(baseline, current, baselineFile, args) {
20959
22073
  prSummary: args.prSummary || args.prSummaryFile ? createPrCommentSummary(result.diff) : void 0
20960
22074
  };
20961
22075
  }
22076
+ var STRENGTH_RANK2 = {
22077
+ none: 0,
22078
+ weak: 1,
22079
+ moderate: 2,
22080
+ strong: 3
22081
+ };
22082
+ function mapStatus(status) {
22083
+ const letter = status.charAt(0).toUpperCase();
22084
+ if (letter === "A") return "added";
22085
+ if (letter === "D") return "deleted";
22086
+ if (letter === "R") return "renamed";
22087
+ if (letter === "C") return "added";
22088
+ return "modified";
22089
+ }
22090
+ function parseNameStatus(text2) {
22091
+ const files = [];
22092
+ for (const raw of text2.split("\n")) {
22093
+ const line = raw.trim();
22094
+ if (!line) continue;
22095
+ const cols = line.includes(" ") ? line.split(" ") : line.split(/\s+/);
22096
+ const status = cols[0];
22097
+ if (!status) continue;
22098
+ const filePath = /^[RC]/i.test(status) && cols.length >= 3 ? cols[cols.length - 1] : cols[1];
22099
+ if (!filePath) continue;
22100
+ files.push({ path: filePath, changeKind: mapStatus(status) });
22101
+ }
22102
+ return files;
22103
+ }
22104
+ var VALID_CHANGE_KINDS = /* @__PURE__ */ new Set(["added", "modified", "deleted", "renamed"]);
22105
+ function coerceChangedFile(value) {
22106
+ if (typeof value !== "object" || value === null) return void 0;
22107
+ const obj = value;
22108
+ if (typeof obj.path !== "string") return void 0;
22109
+ const kind = typeof obj.changeKind === "string" && VALID_CHANGE_KINDS.has(obj.changeKind) ? obj.changeKind : "modified";
22110
+ const changedLines = Array.isArray(obj.changedLines) ? obj.changedLines.filter((n) => typeof n === "number") : void 0;
22111
+ return changedLines ? { path: obj.path, changeKind: kind, changedLines } : { path: obj.path, changeKind: kind };
22112
+ }
22113
+ function loadReviewContext(args) {
22114
+ let changedFiles = [];
22115
+ let baseRef = args.baseRef;
22116
+ let headRef = args.headRef;
22117
+ if (args.changedFilesPath) {
22118
+ const text2 = readFileInput(args.changedFilesPath);
22119
+ const parsed = tryParseJson(text2);
22120
+ if (Array.isArray(parsed)) {
22121
+ changedFiles = parsed.map(coerceChangedFile).filter((f) => f !== void 0);
22122
+ } else if (parsed && typeof parsed === "object") {
22123
+ const obj = parsed;
22124
+ if (Array.isArray(obj.changedFiles)) {
22125
+ changedFiles = obj.changedFiles.map(coerceChangedFile).filter((f) => f !== void 0);
22126
+ }
22127
+ if (typeof obj.baseRef === "string") baseRef = baseRef ?? obj.baseRef;
22128
+ if (typeof obj.headRef === "string") headRef = headRef ?? obj.headRef;
22129
+ } else {
22130
+ changedFiles = parseNameStatus(text2);
22131
+ }
22132
+ }
22133
+ return { changedFiles, baseRef, headRef };
22134
+ }
22135
+ function writeReviewReport(review, args) {
22136
+ const title = args.htmlTitle && args.htmlTitle !== "Test Results" ? args.htmlTitle : void 0;
22137
+ const titleOpt = title ? { title } : {};
22138
+ const markdown = new ReviewMarkdownFormatter(titleOpt).format(review);
22139
+ const html = new ReviewHtmlFormatter({ ...titleOpt, theme: args.htmlTheme }).format(review);
22140
+ const outputDir = args.outputDir ?? "reports";
22141
+ const baseName = args.outputName ?? "evidence-review";
22142
+ const suffix = args.outputNameTimestamp ? `-${Math.floor(review.run.startedAtMs / 1e3)}` : "";
22143
+ fs9.mkdirSync(outputDir, { recursive: true });
22144
+ const mdPath = path10.join(outputDir, `${baseName}${suffix}.md`);
22145
+ const htmlPath = path10.join(outputDir, `${baseName}${suffix}.html`);
22146
+ fs9.writeFileSync(mdPath, markdown, "utf8");
22147
+ fs9.writeFileSync(htmlPath, html, "utf8");
22148
+ return [mdPath, htmlPath];
22149
+ }
22150
+ function evaluateReviewGate(review, args) {
22151
+ const failures = [];
22152
+ const { summary } = review;
22153
+ if (args.failOn === "uncovered" && summary.uncovered > 0) {
22154
+ failures.push(`${summary.uncovered} changed source file(s) have no evidence`);
22155
+ }
22156
+ if (args.failOn === "weak" && summary.uncovered + summary.weaklyCovered > 0) {
22157
+ failures.push(
22158
+ `${summary.uncovered + summary.weaklyCovered} changed source file(s) lack moderate+ evidence`
22159
+ );
22160
+ }
22161
+ if (args.minEvidence) {
22162
+ const threshold = STRENGTH_RANK2[args.minEvidence];
22163
+ const below = review.claims.filter((c) => STRENGTH_RANK2[c.strength] < threshold);
22164
+ if (below.length > 0) {
22165
+ failures.push(
22166
+ `${below.length} claim(s) below "${args.minEvidence}" evidence strength`
22167
+ );
22168
+ }
22169
+ }
22170
+ return failures;
22171
+ }
20962
22172
  function printResult(result, args, startMs, droppedMissingStory = 0) {
20963
22173
  const durationMs = Date.now() - startMs;
20964
22174
  if (args.jsonSummary) {
@@ -20980,9 +22190,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
20980
22190
  function printCompareResult(result, args, startMs) {
20981
22191
  const durationMs = Date.now() - startMs;
20982
22192
  if (result.prSummary && args.prSummaryFile) {
20983
- const outputPath = path9.resolve(args.prSummaryFile);
20984
- fs8.mkdirSync(path9.dirname(outputPath), { recursive: true });
20985
- fs8.writeFileSync(outputPath, result.prSummary, "utf8");
22193
+ const outputPath = path10.resolve(args.prSummaryFile);
22194
+ fs9.mkdirSync(path10.dirname(outputPath), { recursive: true });
22195
+ fs9.writeFileSync(outputPath, result.prSummary, "utf8");
20986
22196
  }
20987
22197
  if (args.jsonSummary) {
20988
22198
  console.log(
@@ -21073,7 +22283,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
21073
22283
  console.error("Error: missing ADF file argument. Run with --help for usage.");
21074
22284
  process.exit(EXIT_USAGE);
21075
22285
  }
21076
- if (!fs8.existsSync(inputFile)) {
22286
+ if (!fs9.existsSync(inputFile)) {
21077
22287
  console.error(`Error: file not found: ${inputFile}`);
21078
22288
  process.exit(EXIT_USAGE);
21079
22289
  }
@@ -21101,7 +22311,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
21101
22311
  console.error("Error: --title is required when creating a new page");
21102
22312
  process.exit(EXIT_USAGE);
21103
22313
  }
21104
- const adf = fs8.readFileSync(path9.resolve(inputFile), "utf8");
22314
+ const adf = fs9.readFileSync(path10.resolve(inputFile), "utf8");
21105
22315
  if (dryRun) {
21106
22316
  console.log(
21107
22317
  JSON.stringify(
@@ -21180,7 +22390,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
21180
22390
  console.error("Error: missing ADF file argument. Run with --help for usage.");
21181
22391
  process.exit(EXIT_USAGE);
21182
22392
  }
21183
- if (!fs8.existsSync(inputFile)) {
22393
+ if (!fs9.existsSync(inputFile)) {
21184
22394
  console.error(`Error: file not found: ${inputFile}`);
21185
22395
  process.exit(EXIT_USAGE);
21186
22396
  }
@@ -21207,7 +22417,7 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
21207
22417
  process.exit(EXIT_USAGE);
21208
22418
  }
21209
22419
  const mode = modeRaw;
21210
- const adf = fs8.readFileSync(path9.resolve(inputFile), "utf8");
22420
+ const adf = fs9.readFileSync(path10.resolve(inputFile), "utf8");
21211
22421
  if (dryRun) {
21212
22422
  console.log(
21213
22423
  JSON.stringify(