executable-stories-formatters 0.8.0 → 0.9.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
@@ -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"],
@@ -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) {
@@ -14461,7 +14509,7 @@ function renderDocEntry(entry, deps) {
14461
14509
  // src/formatters/html/renderers/steps.ts
14462
14510
  var CONTINUATION_KEYWORDS = ["And", "But", "*"];
14463
14511
  function renderStep(step, stepResult, index, deps) {
14464
- const statusIcon2 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14512
+ const statusIcon4 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14465
14513
  const statusClass = stepResult ? `status-${stepResult.status}` : "";
14466
14514
  const duration = stepResult && stepResult.durationMs > 0 ? `${stepResult.durationMs}ms` : "";
14467
14515
  const keywordTrimmed = step.keyword.trim();
@@ -14470,7 +14518,7 @@ function renderStep(step, stepResult, index, deps) {
14470
14518
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
14471
14519
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
14472
14520
  return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
14473
- <span class="step-status ${statusClass}">${statusIcon2}</span>
14521
+ <span class="step-status ${statusClass}">${statusIcon4}</span>
14474
14522
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
14475
14523
  <span class="step-text">${textHtml}</span>
14476
14524
  <span class="step-duration">${duration}</span>
@@ -14515,16 +14563,16 @@ function highlightStepParams(text2, deps) {
14515
14563
  var MIN_METRIC_SAMPLES = 5;
14516
14564
 
14517
14565
  // src/formatters/html/renderers/scenario.ts
14518
- function renderTicket(ticket, template, escapeHtml3) {
14566
+ function renderTicket(ticket, template, escapeHtml4) {
14519
14567
  const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
14520
14568
  if (url) {
14521
- return `<a class="tag ticket-tag" href="${escapeHtml3(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml3(ticket.id)}</a>`;
14569
+ return `<a class="tag ticket-tag" href="${escapeHtml4(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml4(ticket.id)}</a>`;
14522
14570
  }
14523
- return `<span class="tag ticket-tag">${escapeHtml3(ticket.id)}</span>`;
14571
+ return `<span class="tag ticket-tag">${escapeHtml4(ticket.id)}</span>`;
14524
14572
  }
14525
14573
  function renderScenario(args, deps) {
14526
14574
  const { tc } = args;
14527
- const statusIcon2 = deps.getStatusIcon(tc.status);
14575
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14528
14576
  const statusClass = `status-${tc.status}`;
14529
14577
  const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
14530
14578
  const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
@@ -14594,7 +14642,7 @@ function renderScenario(args, deps) {
14594
14642
  <div class="scenario-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14595
14643
  <div class="scenario-info">
14596
14644
  <div class="scenario-title">
14597
- <span class="status-icon ${statusClass}">${statusIcon2}</span>
14645
+ <span class="status-icon ${statusClass}">${statusIcon4}</span>
14598
14646
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
14599
14647
  </div>
14600
14648
  <div class="scenario-meta">${tags}${tickets}${outcomeBadge}${sourceLink}${traceBadge}${metricBadges}</div>
@@ -14720,7 +14768,7 @@ function flattenTree(roots) {
14720
14768
  }
14721
14769
  return result;
14722
14770
  }
14723
- function buildTooltip(span, escapeHtml3) {
14771
+ function buildTooltip(span, escapeHtml4) {
14724
14772
  const parts = [];
14725
14773
  parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
14726
14774
  if (span.statusMessage) {
@@ -14738,7 +14786,7 @@ function buildTooltip(span, escapeHtml3) {
14738
14786
  if (text2.length > TOOLTIP_MAX_LENGTH) {
14739
14787
  text2 = text2.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
14740
14788
  }
14741
- return escapeHtml3(text2);
14789
+ return escapeHtml4(text2);
14742
14790
  }
14743
14791
  function renderTraceView(args, deps) {
14744
14792
  if (!args.spans || args.spans.length === 0) return "";
@@ -14961,11 +15009,11 @@ function renderToc(args, deps) {
14961
15009
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14962
15010
  const featureSlug = `feature-${slugify(file)}`;
14963
15011
  const scenarios = testCases.map((tc) => {
14964
- const statusIcon2 = deps.getStatusIcon(tc.status);
15012
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14965
15013
  const statusClass = `status-${tc.status}`;
14966
15014
  const failedClass = tc.status === "failed" ? " toc-failed" : "";
14967
15015
  return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14968
- <span class="toc-status ${statusClass}">${statusIcon2}</span>
15016
+ <span class="toc-status ${statusClass}">${statusIcon4}</span>
14969
15017
  ${deps.escapeHtml(tc.story.scenario)}
14970
15018
  </a>`;
14971
15019
  }).join("\n");
@@ -19482,6 +19530,697 @@ function listScenarios(args, _deps) {
19482
19530
  return lines.join("\n");
19483
19531
  }
19484
19532
 
19533
+ // src/review/conventions.ts
19534
+ var CHANGE_TAG_PREFIX = "change:";
19535
+ var AUDIENCE_TAG_PREFIX = "audience:";
19536
+ var VALID_CHANGE_TYPES = /* @__PURE__ */ new Set([
19537
+ "feature",
19538
+ "bugfix",
19539
+ "refactor",
19540
+ "perf",
19541
+ "deps"
19542
+ ]);
19543
+ var STAKEHOLDER_FILE = /(?:\.e2e\.)|(?:^|\/)e2e\/|(?:\.spec\.)/i;
19544
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
19545
+ "ts",
19546
+ "tsx",
19547
+ "js",
19548
+ "jsx",
19549
+ "mjs",
19550
+ "cjs",
19551
+ "py",
19552
+ "go",
19553
+ "rs",
19554
+ "kt",
19555
+ "kts",
19556
+ "java",
19557
+ "cs",
19558
+ "rb"
19559
+ ]);
19560
+ var TEST_INFIX = /\.(?:story\.)?(?:int\.|e2e\.|unit\.)?(?:test|spec|cy)\.[a-z]+$/i;
19561
+ function deriveAudience(sourceFile, tags) {
19562
+ const override = tags.map((t) => t.toLowerCase()).find((t) => t.startsWith(AUDIENCE_TAG_PREFIX));
19563
+ if (override) {
19564
+ const value = override.slice(AUDIENCE_TAG_PREFIX.length);
19565
+ if (value === "stakeholder" || value === "engineer") return value;
19566
+ }
19567
+ return STAKEHOLDER_FILE.test(sourceFile) ? "stakeholder" : "engineer";
19568
+ }
19569
+ function deriveChangeType(tags) {
19570
+ for (const tag of tags) {
19571
+ const lower = tag.toLowerCase();
19572
+ if (lower.startsWith(CHANGE_TAG_PREFIX)) {
19573
+ const value = lower.slice(CHANGE_TAG_PREFIX.length);
19574
+ if (VALID_CHANGE_TYPES.has(value)) return value;
19575
+ }
19576
+ }
19577
+ return "unknown";
19578
+ }
19579
+ function extensionOf(path10) {
19580
+ const base = path10.split("/").pop() ?? path10;
19581
+ const dot = base.lastIndexOf(".");
19582
+ return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
19583
+ }
19584
+ function isTestFile(path10) {
19585
+ return TEST_INFIX.test(path10);
19586
+ }
19587
+ function isReviewableSource(path10) {
19588
+ if (isTestFile(path10)) return false;
19589
+ if (path10.endsWith(".d.ts")) return false;
19590
+ return CODE_EXTENSIONS.has(extensionOf(path10));
19591
+ }
19592
+ function testBaseKey(testFile) {
19593
+ return testFile.replace(TEST_INFIX, "");
19594
+ }
19595
+ function sourceBaseKey(sourceFile) {
19596
+ const dot = sourceFile.lastIndexOf(".");
19597
+ const slash = sourceFile.lastIndexOf("/");
19598
+ return dot > slash ? sourceFile.slice(0, dot) : sourceFile;
19599
+ }
19600
+
19601
+ // src/review/build-review.ts
19602
+ var STRENGTH_RANK = {
19603
+ none: 0,
19604
+ weak: 1,
19605
+ moderate: 2,
19606
+ strong: 3
19607
+ };
19608
+ var INTENT_SECTION_TITLE = /\b(why|intent|approach|rationale|reasoning)\b/i;
19609
+ function findDoc(docs, predicate) {
19610
+ if (!docs) return void 0;
19611
+ for (const doc of docs) {
19612
+ if (predicate(doc)) return doc;
19613
+ const nested = findDoc(doc.children, predicate);
19614
+ if (nested) return nested;
19615
+ }
19616
+ return void 0;
19617
+ }
19618
+ function anyDoc(docs, predicate) {
19619
+ return findDoc(docs, predicate) !== void 0;
19620
+ }
19621
+ function extractIntent(testCase) {
19622
+ const docs = testCase.story.docs;
19623
+ const section = findDoc(
19624
+ docs,
19625
+ (d) => d.kind === "section" && INTENT_SECTION_TITLE.test(d.title)
19626
+ );
19627
+ if (section && section.kind === "section") return section.markdown;
19628
+ const note = findDoc(docs, (d) => d.kind === "note");
19629
+ if (note && note.kind === "note") return note.text;
19630
+ return void 0;
19631
+ }
19632
+ function hasScreenshot(testCase) {
19633
+ if (testCase.attachments.some((a) => a.mediaType.startsWith("image/"))) {
19634
+ return true;
19635
+ }
19636
+ if (anyDoc(testCase.story.docs, (d) => d.kind === "screenshot")) return true;
19637
+ return testCase.story.steps.some(
19638
+ (step) => anyDoc(step.docs, (d) => d.kind === "screenshot")
19639
+ );
19640
+ }
19641
+ function hasOtelTrace(testCase) {
19642
+ return (testCase.story.otelSpans?.length ?? 0) > 0;
19643
+ }
19644
+ function gradeEvidence(testCase, audience) {
19645
+ if (testCase.status !== "passed") {
19646
+ return {
19647
+ strength: "none",
19648
+ reasons: [`test is ${testCase.status} \u2014 the proof does not hold`]
19649
+ };
19650
+ }
19651
+ const ev = testCase.evidence;
19652
+ const screenshot = hasScreenshot(testCase);
19653
+ const otel = hasOtelTrace(testCase);
19654
+ const isIntegration = /\.int\.test\./i.test(testCase.sourceFile);
19655
+ const mutation = ev?.mutationScorePct;
19656
+ const changedCov = ev?.changedLineCoveragePct;
19657
+ const strong2 = [];
19658
+ if (ev?.failingFirstVerified) {
19659
+ strong2.push("failing-first verified (red on base ref, green on head)");
19660
+ }
19661
+ if (typeof mutation === "number" && mutation >= 80) {
19662
+ strong2.push(`mutation score ${mutation}% (\u226580%)`);
19663
+ }
19664
+ if (screenshot && otel) {
19665
+ strong2.push("backed by screenshot + OTEL trace");
19666
+ } else if (audience === "stakeholder" && (screenshot || otel)) {
19667
+ strong2.push(`stakeholder proof: ${screenshot ? "screenshot" : "OTEL trace"}`);
19668
+ }
19669
+ if (strong2.length > 0) return { strength: "strong", reasons: strong2 };
19670
+ const moderate = [];
19671
+ if (screenshot) moderate.push("screenshot attached");
19672
+ if (otel) moderate.push("OTEL trace attached");
19673
+ if (typeof mutation === "number" && mutation >= 50) {
19674
+ moderate.push(`mutation score ${mutation}%`);
19675
+ }
19676
+ if (typeof changedCov === "number" && changedCov >= 80) {
19677
+ moderate.push(`changed-line coverage ${changedCov}%`);
19678
+ }
19679
+ if (isIntegration) moderate.push("integration-level test");
19680
+ if (moderate.length > 0) return { strength: "moderate", reasons: moderate };
19681
+ return {
19682
+ strength: "weak",
19683
+ reasons: [
19684
+ "passing test only \u2014 no corroborating evidence (add e2e proof, mutation score, or failing-first)"
19685
+ ]
19686
+ };
19687
+ }
19688
+ function toClaim(testCase, changedSourcePaths) {
19689
+ const audience = deriveAudience(testCase.sourceFile, testCase.tags);
19690
+ const changeType = deriveChangeType(testCase.tags);
19691
+ const { strength, reasons } = gradeEvidence(testCase, audience);
19692
+ const key = testBaseKey(testCase.sourceFile);
19693
+ const coversFiles = changedSourcePaths.filter(
19694
+ (path10) => sourceBaseKey(path10) === key
19695
+ );
19696
+ return {
19697
+ id: testCase.id,
19698
+ scenario: testCase.story.scenario,
19699
+ sourceFile: testCase.sourceFile,
19700
+ sourceLine: testCase.sourceLine,
19701
+ status: testCase.status,
19702
+ audience,
19703
+ changeType,
19704
+ strength,
19705
+ strengthReasons: reasons,
19706
+ intent: extractIntent(testCase),
19707
+ coversFiles,
19708
+ testCase
19709
+ };
19710
+ }
19711
+ function bandFor(claims) {
19712
+ if (claims.length === 0) return "uncovered";
19713
+ const maxRank = Math.max(...claims.map((c) => STRENGTH_RANK[c.strength]));
19714
+ return maxRank >= STRENGTH_RANK.moderate ? "covered" : "weak";
19715
+ }
19716
+ var AUDIENCE_ORDER = {
19717
+ stakeholder: 0,
19718
+ engineer: 1
19719
+ };
19720
+ function buildReview(run, context = { changedFiles: [] }) {
19721
+ const changedSource = context.changedFiles.filter(
19722
+ (f) => isReviewableSource(f.path)
19723
+ );
19724
+ const changedSourcePaths = changedSource.map((f) => f.path);
19725
+ const claims = run.testCases.map((tc) => toClaim(tc, changedSourcePaths));
19726
+ const changedFiles = changedSource.map((file) => {
19727
+ const covering = claims.filter((c) => c.coversFiles.includes(file.path));
19728
+ return {
19729
+ path: file.path,
19730
+ changeKind: file.changeKind,
19731
+ band: bandFor(covering),
19732
+ claims: covering.map((c) => ({
19733
+ id: c.id,
19734
+ scenario: c.scenario,
19735
+ strength: c.strength
19736
+ }))
19737
+ };
19738
+ });
19739
+ const sortedClaims = [...claims].sort((a, b) => {
19740
+ if (AUDIENCE_ORDER[a.audience] !== AUDIENCE_ORDER[b.audience]) {
19741
+ return AUDIENCE_ORDER[a.audience] - AUDIENCE_ORDER[b.audience];
19742
+ }
19743
+ if (STRENGTH_RANK[a.strength] !== STRENGTH_RANK[b.strength]) {
19744
+ return STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength];
19745
+ }
19746
+ if (a.sourceFile !== b.sourceFile) {
19747
+ return a.sourceFile.localeCompare(b.sourceFile);
19748
+ }
19749
+ return a.scenario.localeCompare(b.scenario);
19750
+ });
19751
+ const bandRank = { uncovered: 0, weak: 1, covered: 2 };
19752
+ const sortedFiles = [...changedFiles].sort((a, b) => {
19753
+ if (bandRank[a.band] !== bandRank[b.band]) {
19754
+ return bandRank[a.band] - bandRank[b.band];
19755
+ }
19756
+ return a.path.localeCompare(b.path);
19757
+ });
19758
+ const summary = buildSummary2(sortedClaims, sortedFiles);
19759
+ return {
19760
+ run,
19761
+ context,
19762
+ summary,
19763
+ claims: sortedClaims,
19764
+ changedFiles: sortedFiles
19765
+ };
19766
+ }
19767
+ function buildSummary2(claims, changedFiles) {
19768
+ const byAudience = {
19769
+ stakeholder: 0,
19770
+ engineer: 0
19771
+ };
19772
+ const byStrength = {
19773
+ none: 0,
19774
+ weak: 0,
19775
+ moderate: 0,
19776
+ strong: 0
19777
+ };
19778
+ for (const claim of claims) {
19779
+ byAudience[claim.audience] += 1;
19780
+ byStrength[claim.strength] += 1;
19781
+ }
19782
+ return {
19783
+ totalClaims: claims.length,
19784
+ byAudience,
19785
+ byStrength,
19786
+ changedSourceFiles: changedFiles.length,
19787
+ uncovered: changedFiles.filter((f) => f.band === "uncovered").length,
19788
+ weaklyCovered: changedFiles.filter((f) => f.band === "weak").length,
19789
+ covered: changedFiles.filter((f) => f.band === "covered").length
19790
+ };
19791
+ }
19792
+
19793
+ // src/formatters/review-markdown.ts
19794
+ var STRENGTH_BADGE = {
19795
+ strong: "\u{1F7E2} strong",
19796
+ moderate: "\u{1F7E1} moderate",
19797
+ weak: "\u{1F7E0} weak",
19798
+ none: "\u{1F534} none"
19799
+ };
19800
+ function statusIcon2(status) {
19801
+ switch (status) {
19802
+ case "passed":
19803
+ return "\u2705";
19804
+ case "failed":
19805
+ return "\u274C";
19806
+ case "skipped":
19807
+ return "\u2298";
19808
+ default:
19809
+ return "\u2022";
19810
+ }
19811
+ }
19812
+ function escapeCell2(value) {
19813
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
19814
+ }
19815
+ function intentSummary(intent) {
19816
+ const firstLine = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
19817
+ const trimmed = firstLine.trim();
19818
+ return trimmed.length > 200 ? `${trimmed.slice(0, 197)}\u2026` : trimmed;
19819
+ }
19820
+ function renderTicket2(ticket) {
19821
+ return ticket.url ? `[${ticket.id}](${ticket.url})` : `\`${ticket.id}\``;
19822
+ }
19823
+ function renderUncoveredBand(lines, files) {
19824
+ const uncovered = files.filter((f) => f.band === "uncovered");
19825
+ if (uncovered.length === 0) return;
19826
+ lines.push(`## \u{1F534} Changed code with no evidence (${uncovered.length})`);
19827
+ lines.push("");
19828
+ lines.push("Start here \u2014 these changed source files have no claim or test behind them.");
19829
+ lines.push("");
19830
+ for (const file of uncovered) {
19831
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_`);
19832
+ }
19833
+ lines.push("");
19834
+ }
19835
+ function renderWeakBand(lines, files) {
19836
+ const weak = files.filter((f) => f.band === "weak");
19837
+ if (weak.length === 0) return;
19838
+ lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
19839
+ lines.push("");
19840
+ for (const file of weak) {
19841
+ const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
19842
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
19843
+ }
19844
+ lines.push("");
19845
+ }
19846
+ function renderClaim(lines, claim) {
19847
+ lines.push(`### ${statusIcon2(claim.status)} ${claim.scenario}`);
19848
+ lines.push("");
19849
+ lines.push(`- File: \`${claim.sourceFile}:${claim.sourceLine}\``);
19850
+ if (claim.changeType !== "unknown") {
19851
+ lines.push(`- Change: \`${claim.changeType}\``);
19852
+ }
19853
+ const tickets = claim.testCase.story.tickets ?? [];
19854
+ if (tickets.length > 0) {
19855
+ lines.push(`- Tickets: ${tickets.map(renderTicket2).join(", ")}`);
19856
+ }
19857
+ lines.push(
19858
+ `- Evidence: ${STRENGTH_BADGE[claim.strength]} \u2014 ${claim.strengthReasons.join("; ")}`
19859
+ );
19860
+ if (claim.coversFiles.length > 0) {
19861
+ lines.push(
19862
+ `- Covers: ${claim.coversFiles.map((f) => `\`${f}\``).join(", ")}`
19863
+ );
19864
+ }
19865
+ if (claim.intent) {
19866
+ lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
19867
+ }
19868
+ lines.push("");
19869
+ }
19870
+ function renderAudienceSection(lines, title, claims) {
19871
+ if (claims.length === 0) return;
19872
+ lines.push(`## ${title} (${claims.length})`);
19873
+ lines.push("");
19874
+ for (const claim of claims) {
19875
+ renderClaim(lines, claim);
19876
+ }
19877
+ }
19878
+ var ReviewMarkdownFormatter = class {
19879
+ title;
19880
+ constructor(options = {}) {
19881
+ this.title = options.title ?? "Evidence Review";
19882
+ }
19883
+ format(review) {
19884
+ const lines = [];
19885
+ const { summary, context } = review;
19886
+ lines.push(`# ${this.title}`);
19887
+ lines.push("");
19888
+ if (context.baseRef || context.headRef) {
19889
+ lines.push(
19890
+ `Comparing \`${context.baseRef ?? "base"}\` \u2192 \`${context.headRef ?? "head"}\`.`
19891
+ );
19892
+ lines.push("");
19893
+ }
19894
+ lines.push("## Review priority");
19895
+ lines.push("");
19896
+ if (summary.changedSourceFiles === 0) {
19897
+ lines.push(
19898
+ "No changed source files supplied \u2014 showing claims and evidence only."
19899
+ );
19900
+ } else if (summary.uncovered > 0) {
19901
+ lines.push(
19902
+ `Review the ${summary.uncovered} unaccounted-for file(s) first: changed code with no evidence behind it.`
19903
+ );
19904
+ } else if (summary.weaklyCovered > 0) {
19905
+ lines.push(
19906
+ `No unaccounted-for changes. Review ${summary.weaklyCovered} weakly-covered file(s) next.`
19907
+ );
19908
+ } else {
19909
+ lines.push("Every changed source file is backed by at least moderate evidence.");
19910
+ }
19911
+ lines.push("");
19912
+ if (summary.changedSourceFiles > 0) {
19913
+ lines.push("| \u{1F534} Uncovered | \u{1F7E1} Weak | \u{1F7E2} Covered | Changed files |");
19914
+ lines.push("| ---: | ---: | ---: | ---: |");
19915
+ lines.push(
19916
+ `| ${summary.uncovered} | ${summary.weaklyCovered} | ${summary.covered} | ${summary.changedSourceFiles} |`
19917
+ );
19918
+ lines.push("");
19919
+ }
19920
+ lines.push("| Claims | Stakeholder | Engineer | Strong | Moderate | Weak | None |");
19921
+ lines.push("| ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
19922
+ lines.push(
19923
+ `| ${summary.totalClaims} | ${summary.byAudience.stakeholder} | ${summary.byAudience.engineer} | ${summary.byStrength.strong} | ${summary.byStrength.moderate} | ${summary.byStrength.weak} | ${summary.byStrength.none} |`
19924
+ );
19925
+ lines.push("");
19926
+ renderUncoveredBand(lines, review.changedFiles);
19927
+ renderWeakBand(lines, review.changedFiles);
19928
+ renderAudienceSection(
19929
+ lines,
19930
+ "Stakeholder behaviour",
19931
+ review.claims.filter((c) => c.audience === "stakeholder")
19932
+ );
19933
+ renderAudienceSection(
19934
+ lines,
19935
+ "Engineer changes",
19936
+ review.claims.filter((c) => c.audience === "engineer")
19937
+ );
19938
+ return lines.join("\n").trimEnd();
19939
+ }
19940
+ };
19941
+
19942
+ // src/formatters/review-html.ts
19943
+ function escapeHtml3(value) {
19944
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19945
+ }
19946
+ var STRENGTH_LABEL = {
19947
+ strong: "Strong",
19948
+ moderate: "Moderate",
19949
+ weak: "Weak",
19950
+ none: "None"
19951
+ };
19952
+ function statusIcon3(status) {
19953
+ switch (status) {
19954
+ case "passed":
19955
+ return "\u2705";
19956
+ case "failed":
19957
+ return "\u274C";
19958
+ case "skipped":
19959
+ return "\u2298";
19960
+ default:
19961
+ return "\u2022";
19962
+ }
19963
+ }
19964
+ function formatStep3(step) {
19965
+ return `<li><strong>${escapeHtml3(step.keyword)}</strong> ${escapeHtml3(step.text)}</li>`;
19966
+ }
19967
+ function inlineDoc(doc) {
19968
+ switch (doc.kind) {
19969
+ case "note":
19970
+ return escapeHtml3(doc.text);
19971
+ case "section":
19972
+ return `<strong>${escapeHtml3(doc.title)}</strong>: ${escapeHtml3(doc.markdown)}`;
19973
+ case "kv":
19974
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(String(doc.value))}`;
19975
+ case "code":
19976
+ return `${escapeHtml3(doc.label)}: <code>${escapeHtml3(doc.content)}</code>`;
19977
+ case "link":
19978
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(doc.url)}`;
19979
+ default:
19980
+ return escapeHtml3(doc.kind);
19981
+ }
19982
+ }
19983
+ function renderEvidenceArtifacts(testCase) {
19984
+ const parts = [];
19985
+ for (const att of testCase.attachments) {
19986
+ if (att.mediaType.startsWith("image/") && att.contentEncoding === "BASE64") {
19987
+ parts.push(
19988
+ `<img class="shot" alt="${escapeHtml3(att.name)}" src="data:${escapeHtml3(att.mediaType)};base64,${att.body}" />`
19989
+ );
19990
+ }
19991
+ }
19992
+ if ((testCase.story.otelSpans?.length ?? 0) > 0) {
19993
+ parts.push(
19994
+ `<p class="trace-note">\u{1F4E1} ${testCase.story.otelSpans.length} OTEL span(s) captured</p>`
19995
+ );
19996
+ }
19997
+ return parts.length > 0 ? `<div class="artifacts">${parts.join("")}</div>` : "";
19998
+ }
19999
+ function renderTicketPills(claim) {
20000
+ const tickets = claim.testCase.story.tickets ?? [];
20001
+ if (tickets.length === 0) return "";
20002
+ return `<div class="ticket-row">${tickets.map((ticket) => {
20003
+ const label = escapeHtml3(ticket.id);
20004
+ if (ticket.url) {
20005
+ return `<a class="ticket-pill" href="${escapeHtml3(ticket.url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
20006
+ }
20007
+ return `<span class="ticket-pill">${label}</span>`;
20008
+ }).join("")}</div>`;
20009
+ }
20010
+ function renderClaimCard(claim) {
20011
+ const ticketSearch = (claim.testCase.story.tickets ?? []).map((ticket) => ticket.id).join(" ");
20012
+ const search = escapeHtml3(
20013
+ `${claim.scenario} ${claim.sourceFile} ${claim.changeType} ${claim.audience} ${claim.strength} ${ticketSearch}`
20014
+ ).toLowerCase();
20015
+ const steps = claim.testCase.story.steps.length > 0 ? `<ul class="step-list">${claim.testCase.story.steps.map(formatStep3).join("")}</ul>` : "";
20016
+ const reasons = `<ul class="reasons">${claim.strengthReasons.map((r) => `<li>${escapeHtml3(r)}</li>`).join("")}</ul>`;
20017
+ const intent = claim.intent !== void 0 ? `<div class="intent"><span class="intent-label">Why</span> ${escapeHtml3(claim.intent)}</div>` : "";
20018
+ const covers = claim.coversFiles.length > 0 ? `<p class="covers">Covers ${claim.coversFiles.map((f) => `<code>${escapeHtml3(f)}</code>`).join(", ")}</p>` : "";
20019
+ const docs = (claim.testCase.story.docs ?? []).filter(
20020
+ (d) => d.kind === "section" || d.kind === "note"
20021
+ );
20022
+ const extraDocs = docs.length > 0 && claim.intent === void 0 ? `<div class="intent">${docs.map(inlineDoc).join("<br>")}</div>` : "";
20023
+ return `
20024
+ <article class="claim-card" data-audience="${claim.audience}" data-strength="${claim.strength}" data-search="${search}">
20025
+ <header class="claim-header">
20026
+ <div>
20027
+ <span class="strength-badge strength-${claim.strength}">${STRENGTH_LABEL[claim.strength]}</span>
20028
+ ${claim.changeType !== "unknown" ? `<span class="change-pill">${escapeHtml3(claim.changeType)}</span>` : ""}
20029
+ <h3>${statusIcon3(claim.status)} ${escapeHtml3(claim.scenario)}</h3>
20030
+ <p class="source">${escapeHtml3(`${claim.sourceFile}:${claim.sourceLine}`)}</p>
20031
+ ${renderTicketPills(claim)}
20032
+ </div>
20033
+ </header>
20034
+ ${intent}${extraDocs}
20035
+ <div class="evidence-block">
20036
+ <span class="evidence-label">Evidence</span>
20037
+ ${reasons}
20038
+ </div>
20039
+ ${covers}
20040
+ ${renderEvidenceArtifacts(claim.testCase)}
20041
+ ${steps}
20042
+ </article>`;
20043
+ }
20044
+ function renderChangedFileRow(file) {
20045
+ const claims = file.claims.length > 0 ? file.claims.map((c) => `${escapeHtml3(c.scenario)} <em>(${c.strength})</em>`).join(", ") : "\u2014";
20046
+ return `<tr data-band="${file.band}">
20047
+ <td><span class="band-dot band-${file.band}"></span></td>
20048
+ <td><code>${escapeHtml3(file.path)}</code></td>
20049
+ <td>${escapeHtml3(file.changeKind)}</td>
20050
+ <td>${claims}</td>
20051
+ </tr>`;
20052
+ }
20053
+ function renderAudienceSection2(title, claims) {
20054
+ if (claims.length === 0) return "";
20055
+ return `<section class="audience-section">
20056
+ <h2>${escapeHtml3(title)} <span class="count">${claims.length}</span></h2>
20057
+ <div class="claim-list">${claims.map(renderClaimCard).join("\n")}</div>
20058
+ </section>`;
20059
+ }
20060
+ var REVIEW_CSS = `
20061
+ * { box-sizing: border-box; }
20062
+ body { margin: 0; font-family: var(--font-sans, system-ui, sans-serif); background: var(--background); color: var(--foreground); }
20063
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 20px 80px; }
20064
+ h1, h2, h3, p { margin: 0; }
20065
+ .review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
20066
+ .subtle { color: var(--muted-foreground); margin-top: 6px; }
20067
+ .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); }
20068
+ .card, .claim-card, .summary-card, .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius, 16px); }
20069
+ .hero-card { padding: 24px; margin-bottom: 20px; }
20070
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
20071
+ .summary-card { padding: 14px 16px; }
20072
+ .summary-card strong { display: block; font-size: 1.8rem; }
20073
+ .priority-banner { padding: 18px 20px; margin-bottom: 20px; background: linear-gradient(135deg, color-mix(in srgb, var(--destructive) 10%, transparent), var(--card)); }
20074
+ .panel { padding: 18px; margin-bottom: 24px; }
20075
+ table { width: 100%; border-collapse: collapse; }
20076
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
20077
+ th { color: var(--muted-foreground); font-weight: 600; }
20078
+ .band-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
20079
+ .band-uncovered { background: var(--destructive); }
20080
+ .band-weak { background: var(--warning, #b58900); }
20081
+ .band-covered { background: var(--success, #2e7d32); }
20082
+ .toolbar { position: sticky; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 20px; }
20083
+ .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); }
20084
+ .toolbar button { border: 1px solid var(--border); background: var(--secondary); border-radius: 999px; padding: 10px 14px; font: inherit; cursor: pointer; color: var(--foreground); }
20085
+ .toolbar button.active { background: var(--foreground); color: var(--background); }
20086
+ .audience-section { margin-bottom: 28px; }
20087
+ .audience-section h2 { margin-bottom: 12px; }
20088
+ .count { color: var(--muted-foreground); font-weight: 400; }
20089
+ .claim-list { display: grid; gap: 14px; }
20090
+ .claim-card { padding: 18px; }
20091
+ .claim-header h3 { margin-top: 8px; }
20092
+ .source { color: var(--muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; margin-top: 4px; }
20093
+ .ticket-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
20094
+ .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; }
20095
+ .ticket-pill:hover { color: var(--foreground); border-color: var(--muted-foreground); }
20096
+ .strength-badge, .change-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 0.8rem; margin-right: 6px; }
20097
+ .change-pill { background: var(--secondary); }
20098
+ .strength-strong { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); }
20099
+ .strength-moderate { background: color-mix(in srgb, var(--warning, #b58900) 20%, transparent); color: var(--warning, #b58900); }
20100
+ .strength-weak { background: color-mix(in srgb, #d2691e 20%, transparent); color: #b5530a; }
20101
+ .strength-none { background: color-mix(in srgb, var(--destructive) 16%, transparent); color: var(--destructive); }
20102
+ .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; }
20103
+ .intent-label { font-weight: 700; margin-right: 6px; }
20104
+ .evidence-block { margin-top: 10px; }
20105
+ .evidence-label { font-weight: 600; color: var(--muted-foreground); }
20106
+ .reasons { margin: 6px 0 0; padding-left: 18px; }
20107
+ .covers { color: var(--muted-foreground); margin-top: 8px; font-size: 0.9rem; }
20108
+ .artifacts { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
20109
+ .shot { max-width: 280px; max-height: 200px; border: 1px solid var(--border); border-radius: 8px; }
20110
+ .trace-note { color: var(--muted-foreground); }
20111
+ .step-list { margin: 12px 0 0; padding-left: 18px; color: var(--muted-foreground); }
20112
+ `;
20113
+ var JS_THEME_TOGGLE2 = `
20114
+ function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
20115
+ function getEffectiveTheme() { var s = localStorage.getItem('review-theme'); return (s === 'dark' || s === 'light') ? s : getSystemTheme(); }
20116
+ function toggleTheme() { var n = getEffectiveTheme() === 'dark' ? 'light' : 'dark'; localStorage.setItem('review-theme', n); applyTheme(n); }
20117
+ function applyTheme(t) {
20118
+ document.documentElement.setAttribute('data-theme', t);
20119
+ var b = document.querySelector('.theme-toggle');
20120
+ if (b) { b.textContent = t === 'dark' ? '\\u2600\\ufe0f' : '\\ud83c\\udf19'; }
20121
+ }
20122
+ `;
20123
+ var ReviewHtmlFormatter = class {
20124
+ title;
20125
+ theme;
20126
+ darkMode;
20127
+ constructor(options = {}) {
20128
+ this.title = options.title ?? "Evidence Review";
20129
+ this.theme = resolveTheme(options.theme ?? "default");
20130
+ this.darkMode = options.darkMode ?? true;
20131
+ }
20132
+ format(review) {
20133
+ const { summary, context } = review;
20134
+ 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.";
20135
+ const changedFilesPanel = summary.changedSourceFiles > 0 ? `<section class="panel">
20136
+ <h2>Changed files</h2>
20137
+ <table>
20138
+ <thead><tr><th></th><th>File</th><th>Change</th><th>Evidence</th></tr></thead>
20139
+ <tbody>${review.changedFiles.map(renderChangedFileRow).join("")}</tbody>
20140
+ </table>
20141
+ </section>` : "";
20142
+ const themeToggleHtml = this.darkMode ? `<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>` : "";
20143
+ const themeInitJs = this.darkMode ? `${JS_THEME_TOGGLE2}
20144
+ applyTheme(getEffectiveTheme());` : "";
20145
+ const themeAttr = this.darkMode ? ' data-theme="light"' : "";
20146
+ const refsLine = context.baseRef || context.headRef ? `<p class="subtle">Comparing ${escapeHtml3(context.baseRef ?? "base")} \u2192 ${escapeHtml3(context.headRef ?? "head")}</p>` : "";
20147
+ return `<!doctype html>
20148
+ <html lang="en"${themeAttr}>
20149
+ <head>
20150
+ <meta charset="utf-8" />
20151
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20152
+ <title>${escapeHtml3(this.title)}</title>
20153
+ <style>
20154
+ ${this.theme.css}
20155
+ ${REVIEW_CSS}
20156
+ </style>
20157
+ </head>
20158
+ <body>
20159
+ <main>
20160
+ <div class="hero-card card">
20161
+ <div class="review-header">
20162
+ <h1>${escapeHtml3(this.title)}</h1>
20163
+ ${themeToggleHtml}
20164
+ </div>
20165
+ ${refsLine}
20166
+ </div>
20167
+ <section class="summary-grid">
20168
+ <div class="summary-card"><strong>${summary.uncovered}</strong><span>\u{1F534} Uncovered</span></div>
20169
+ <div class="summary-card"><strong>${summary.weaklyCovered}</strong><span>\u{1F7E1} Weak</span></div>
20170
+ <div class="summary-card"><strong>${summary.covered}</strong><span>\u{1F7E2} Covered</span></div>
20171
+ <div class="summary-card"><strong>${summary.totalClaims}</strong><span>Claims</span></div>
20172
+ <div class="summary-card"><strong>${summary.byStrength.strong}</strong><span>Strong</span></div>
20173
+ <div class="summary-card"><strong>${summary.byStrength.weak + summary.byStrength.none}</strong><span>Weak/None</span></div>
20174
+ </section>
20175
+ <section class="card priority-banner">
20176
+ <h2>Review priority</h2>
20177
+ <p class="subtle">${escapeHtml3(priority)}</p>
20178
+ </section>
20179
+ ${changedFilesPanel}
20180
+ <section class="toolbar">
20181
+ <input type="search" placeholder="Filter claims by scenario, file, change-type" aria-label="Filter claims" />
20182
+ <button type="button" class="active" data-filter="all">All</button>
20183
+ <button type="button" data-filter="stakeholder">Stakeholder</button>
20184
+ <button type="button" data-filter="engineer">Engineer</button>
20185
+ <button type="button" data-filter="weak">Weak/None</button>
20186
+ </section>
20187
+ ${renderAudienceSection2("Stakeholder behaviour", review.claims.filter((c) => c.audience === "stakeholder"))}
20188
+ ${renderAudienceSection2("Engineer changes", review.claims.filter((c) => c.audience === "engineer"))}
20189
+ </main>
20190
+ <script>
20191
+ ${themeInitJs}
20192
+ const input = document.querySelector('input[type="search"]');
20193
+ const buttons = Array.from(document.querySelectorAll('[data-filter]'));
20194
+ const cards = Array.from(document.querySelectorAll('.claim-card'));
20195
+ let activeFilter = 'all';
20196
+ function applyFilters() {
20197
+ const query = (input.value || '').trim().toLowerCase();
20198
+ cards.forEach((card) => {
20199
+ const audience = card.getAttribute('data-audience');
20200
+ const strength = card.getAttribute('data-strength');
20201
+ const haystack = card.getAttribute('data-search') || '';
20202
+ let matchesFilter = activeFilter === 'all'
20203
+ || audience === activeFilter
20204
+ || (activeFilter === 'weak' && (strength === 'weak' || strength === 'none'));
20205
+ const matchesSearch = !query || haystack.includes(query);
20206
+ card.style.display = matchesFilter && matchesSearch ? '' : 'none';
20207
+ });
20208
+ }
20209
+ input.addEventListener('input', applyFilters);
20210
+ buttons.forEach((button) => {
20211
+ button.addEventListener('click', () => {
20212
+ activeFilter = button.getAttribute('data-filter');
20213
+ buttons.forEach((b) => b.classList.toggle('active', b === button));
20214
+ applyFilters();
20215
+ });
20216
+ });
20217
+ applyFilters();
20218
+ </script>
20219
+ </body>
20220
+ </html>`;
20221
+ }
20222
+ };
20223
+
19485
20224
  // src/index.ts
19486
20225
  var FORMAT_EXTENSIONS = {
19487
20226
  astro: ".md",
@@ -19977,6 +20716,7 @@ var EXIT_CANONICAL_VALIDATION = 2;
19977
20716
  var EXIT_GENERATION = 3;
19978
20717
  var EXIT_USAGE = 4;
19979
20718
  var EXIT_COMPARE_GATE = 5;
20719
+ var EXIT_REVIEW_GATE = 5;
19980
20720
  var HELP_TEXT = `
19981
20721
  executable-stories \u2014 Generate reports from test results JSON.
19982
20722
 
@@ -19984,6 +20724,7 @@ USAGE
19984
20724
  executable-stories format <file> [options]
19985
20725
  executable-stories format --stdin [options]
19986
20726
  executable-stories compare <baseline-file> <current-file> [options]
20727
+ executable-stories review <file> --changed-files <path> [options]
19987
20728
  executable-stories list <file> [options]
19988
20729
  executable-stories validate <file>
19989
20730
  executable-stories validate --stdin
@@ -19994,6 +20735,7 @@ USAGE
19994
20735
  SUBCOMMANDS
19995
20736
  format Read raw test results and generate reports
19996
20737
  compare Compare two runs and generate a diff report
20738
+ review Generate an Evidence Review of AI-authored changes (correlate a run to the diff)
19997
20739
  list List scenarios from a test run (text table or JSON)
19998
20740
  validate Validate a JSON file against the schema (no output generated)
19999
20741
  init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
@@ -20044,6 +20786,11 @@ OPTIONS
20044
20786
  --fail-on-regression Exit non-zero when any regression is detected in compare
20045
20787
  --fail-on-added-failures Exit non-zero when newly added scenarios are failing
20046
20788
  --max-regressions <n> Exit non-zero when regressions exceed threshold
20789
+ --changed-files <path> (review) Changed files: JSON (ChangedFile[] or {changedFiles,baseRef,headRef}) or "git diff --name-status" text
20790
+ --base-ref <ref> (review) Base ref label shown in the report (informational)
20791
+ --head-ref <ref> (review) Head ref label shown in the report (informational)
20792
+ --fail-on <band> (review) Gate: "uncovered" or "weak" \u2014 exit non-zero when changed code lacks evidence (default: off)
20793
+ --min-evidence <strength> (review) Gate: "weak"|"moderate"|"strong" \u2014 exit non-zero when any claim is below this strength (default: off)
20047
20794
  --emit-canonical <path> Write canonical JSON to given path
20048
20795
  --help Show this help message
20049
20796
 
@@ -20116,9 +20863,9 @@ async function parseCliArgs(argv) {
20116
20863
  process.exit(EXIT_SUCCESS);
20117
20864
  }
20118
20865
  const subcommand = args[0];
20119
- if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
20866
+ if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
20120
20867
  console.error(
20121
- `Unknown subcommand: "${subcommand}". Use "format", "compare", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
20868
+ `Unknown subcommand: "${subcommand}". Use "format", "compare", "review", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
20122
20869
  );
20123
20870
  process.exit(EXIT_USAGE);
20124
20871
  }
@@ -20212,6 +20959,11 @@ async function parseCliArgs(argv) {
20212
20959
  "fail-on-regression": { type: "boolean", default: false },
20213
20960
  "fail-on-added-failures": { type: "boolean", default: false },
20214
20961
  "max-regressions": { type: "string" },
20962
+ "changed-files": { type: "string" },
20963
+ "base-ref": { type: "string" },
20964
+ "head-ref": { type: "string" },
20965
+ "fail-on": { type: "string" },
20966
+ "min-evidence": { type: "string" },
20215
20967
  "config": { type: "string" },
20216
20968
  help: { type: "boolean", default: false }
20217
20969
  },
@@ -20337,6 +21089,17 @@ async function parseCliArgs(argv) {
20337
21089
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
20338
21090
  process.exit(EXIT_USAGE);
20339
21091
  }
21092
+ const failOnRaw = values["fail-on"];
21093
+ if (failOnRaw !== void 0 && failOnRaw !== "uncovered" && failOnRaw !== "weak") {
21094
+ console.error(`Error: --fail-on must be "uncovered" or "weak", got "${failOnRaw}".`);
21095
+ process.exit(EXIT_USAGE);
21096
+ }
21097
+ const minEvidenceRaw = values["min-evidence"];
21098
+ const validMinEvidence = /* @__PURE__ */ new Set(["weak", "moderate", "strong"]);
21099
+ if (minEvidenceRaw !== void 0 && !validMinEvidence.has(minEvidenceRaw)) {
21100
+ console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
21101
+ process.exit(EXIT_USAGE);
21102
+ }
20340
21103
  const cliArgs = {
20341
21104
  subcommand,
20342
21105
  inputFile,
@@ -20388,6 +21151,11 @@ async function parseCliArgs(argv) {
20388
21151
  failOnRegression: values["fail-on-regression"],
20389
21152
  failOnAddedFailures: values["fail-on-added-failures"],
20390
21153
  maxRegressions,
21154
+ changedFilesPath: values["changed-files"],
21155
+ baseRef: values["base-ref"],
21156
+ headRef: values["head-ref"],
21157
+ failOn: failOnRaw,
21158
+ minEvidence: minEvidenceRaw,
20391
21159
  config: values["config"]
20392
21160
  };
20393
21161
  return { args: cliArgs, pluginConfig, customRequested };
@@ -20601,6 +21369,30 @@ async function main() {
20601
21369
  process.exit(EXIT_GENERATION);
20602
21370
  }
20603
21371
  }
21372
+ if (args.subcommand === "review") {
21373
+ const text3 = await readInput(args);
21374
+ const run = applySelection(normalizeRunFromText(text3, args).run, args);
21375
+ const context = loadReviewContext(args);
21376
+ const review = buildReview(run, context);
21377
+ try {
21378
+ const files = writeReviewReport(review, args);
21379
+ for (const f of files) {
21380
+ console.log(f);
21381
+ }
21382
+ const gateFailures = evaluateReviewGate(review, args);
21383
+ if (gateFailures.length > 0) {
21384
+ for (const failure of gateFailures) {
21385
+ console.error(`Review gate failed: ${failure}`);
21386
+ }
21387
+ process.exit(EXIT_REVIEW_GATE);
21388
+ }
21389
+ process.exit(EXIT_SUCCESS);
21390
+ } catch (err) {
21391
+ const msg = err instanceof Error ? err.message : String(err);
21392
+ console.error(`Review failed: ${msg}`);
21393
+ process.exit(EXIT_GENERATION);
21394
+ }
21395
+ }
20604
21396
  if (args.subcommand === "list") {
20605
21397
  const text3 = await readInput(args);
20606
21398
  const run = applySelection(normalizeRunFromText(text3, args).run, args);
@@ -20959,6 +21751,102 @@ async function generateCompareReports(baseline, current, baselineFile, args) {
20959
21751
  prSummary: args.prSummary || args.prSummaryFile ? createPrCommentSummary(result.diff) : void 0
20960
21752
  };
20961
21753
  }
21754
+ var STRENGTH_RANK2 = {
21755
+ none: 0,
21756
+ weak: 1,
21757
+ moderate: 2,
21758
+ strong: 3
21759
+ };
21760
+ function mapStatus(status) {
21761
+ const letter = status.charAt(0).toUpperCase();
21762
+ if (letter === "A") return "added";
21763
+ if (letter === "D") return "deleted";
21764
+ if (letter === "R") return "renamed";
21765
+ if (letter === "C") return "added";
21766
+ return "modified";
21767
+ }
21768
+ function parseNameStatus(text2) {
21769
+ const files = [];
21770
+ for (const raw of text2.split("\n")) {
21771
+ const line = raw.trim();
21772
+ if (!line) continue;
21773
+ const cols = line.includes(" ") ? line.split(" ") : line.split(/\s+/);
21774
+ const status = cols[0];
21775
+ if (!status) continue;
21776
+ const filePath = /^[RC]/i.test(status) && cols.length >= 3 ? cols[cols.length - 1] : cols[1];
21777
+ if (!filePath) continue;
21778
+ files.push({ path: filePath, changeKind: mapStatus(status) });
21779
+ }
21780
+ return files;
21781
+ }
21782
+ var VALID_CHANGE_KINDS = /* @__PURE__ */ new Set(["added", "modified", "deleted", "renamed"]);
21783
+ function coerceChangedFile(value) {
21784
+ if (typeof value !== "object" || value === null) return void 0;
21785
+ const obj = value;
21786
+ if (typeof obj.path !== "string") return void 0;
21787
+ const kind = typeof obj.changeKind === "string" && VALID_CHANGE_KINDS.has(obj.changeKind) ? obj.changeKind : "modified";
21788
+ const changedLines = Array.isArray(obj.changedLines) ? obj.changedLines.filter((n) => typeof n === "number") : void 0;
21789
+ return changedLines ? { path: obj.path, changeKind: kind, changedLines } : { path: obj.path, changeKind: kind };
21790
+ }
21791
+ function loadReviewContext(args) {
21792
+ let changedFiles = [];
21793
+ let baseRef = args.baseRef;
21794
+ let headRef = args.headRef;
21795
+ if (args.changedFilesPath) {
21796
+ const text2 = readFileInput(args.changedFilesPath);
21797
+ const parsed = tryParseJson(text2);
21798
+ if (Array.isArray(parsed)) {
21799
+ changedFiles = parsed.map(coerceChangedFile).filter((f) => f !== void 0);
21800
+ } else if (parsed && typeof parsed === "object") {
21801
+ const obj = parsed;
21802
+ if (Array.isArray(obj.changedFiles)) {
21803
+ changedFiles = obj.changedFiles.map(coerceChangedFile).filter((f) => f !== void 0);
21804
+ }
21805
+ if (typeof obj.baseRef === "string") baseRef = baseRef ?? obj.baseRef;
21806
+ if (typeof obj.headRef === "string") headRef = headRef ?? obj.headRef;
21807
+ } else {
21808
+ changedFiles = parseNameStatus(text2);
21809
+ }
21810
+ }
21811
+ return { changedFiles, baseRef, headRef };
21812
+ }
21813
+ function writeReviewReport(review, args) {
21814
+ const title = args.htmlTitle && args.htmlTitle !== "Test Results" ? args.htmlTitle : void 0;
21815
+ const titleOpt = title ? { title } : {};
21816
+ const markdown = new ReviewMarkdownFormatter(titleOpt).format(review);
21817
+ const html = new ReviewHtmlFormatter({ ...titleOpt, theme: args.htmlTheme }).format(review);
21818
+ const outputDir = args.outputDir ?? "reports";
21819
+ const baseName = args.outputName ?? "evidence-review";
21820
+ const suffix = args.outputNameTimestamp ? `-${Math.floor(review.run.startedAtMs / 1e3)}` : "";
21821
+ fs8.mkdirSync(outputDir, { recursive: true });
21822
+ const mdPath = path9.join(outputDir, `${baseName}${suffix}.md`);
21823
+ const htmlPath = path9.join(outputDir, `${baseName}${suffix}.html`);
21824
+ fs8.writeFileSync(mdPath, markdown, "utf8");
21825
+ fs8.writeFileSync(htmlPath, html, "utf8");
21826
+ return [mdPath, htmlPath];
21827
+ }
21828
+ function evaluateReviewGate(review, args) {
21829
+ const failures = [];
21830
+ const { summary } = review;
21831
+ if (args.failOn === "uncovered" && summary.uncovered > 0) {
21832
+ failures.push(`${summary.uncovered} changed source file(s) have no evidence`);
21833
+ }
21834
+ if (args.failOn === "weak" && summary.uncovered + summary.weaklyCovered > 0) {
21835
+ failures.push(
21836
+ `${summary.uncovered + summary.weaklyCovered} changed source file(s) lack moderate+ evidence`
21837
+ );
21838
+ }
21839
+ if (args.minEvidence) {
21840
+ const threshold = STRENGTH_RANK2[args.minEvidence];
21841
+ const below = review.claims.filter((c) => STRENGTH_RANK2[c.strength] < threshold);
21842
+ if (below.length > 0) {
21843
+ failures.push(
21844
+ `${below.length} claim(s) below "${args.minEvidence}" evidence strength`
21845
+ );
21846
+ }
21847
+ }
21848
+ return failures;
21849
+ }
20962
21850
  function printResult(result, args, startMs, droppedMissingStory = 0) {
20963
21851
  const durationMs = Date.now() - startMs;
20964
21852
  if (args.jsonSummary) {