executable-stories-formatters 0.7.15 → 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) {
@@ -1997,11 +2045,53 @@ function initKeyboardShortcuts() {
1997
2045
  });
1998
2046
  }
1999
2047
 
2000
- // Collapse/expand functionality
2048
+ // Collapse/expand functionality (persisted in localStorage)
2049
+ var COLLAPSE_KEY = 'es-collapsed-ids';
2050
+
2051
+ function loadCollapseState() {
2052
+ try {
2053
+ var raw = localStorage.getItem(COLLAPSE_KEY);
2054
+ return raw ? new Set(JSON.parse(raw)) : new Set();
2055
+ } catch (e) {
2056
+ return new Set();
2057
+ }
2058
+ }
2059
+
2060
+ function saveCollapseState(set) {
2061
+ try {
2062
+ localStorage.setItem(COLLAPSE_KEY, JSON.stringify(Array.from(set)));
2063
+ } catch (e) { /* quota or disabled */ }
2064
+ }
2065
+
2066
+ function persistCollapse(container) {
2067
+ if (!container || !container.id) return;
2068
+ var state = loadCollapseState();
2069
+ if (container.classList.contains('collapsed')) {
2070
+ state.add(container.id);
2071
+ } else {
2072
+ state.delete(container.id);
2073
+ }
2074
+ saveCollapseState(state);
2075
+ }
2076
+
2001
2077
  function toggleCollapse(header, container) {
2002
2078
  container?.classList.toggle('collapsed');
2003
2079
  const isCollapsed = container?.classList.contains('collapsed');
2004
2080
  header.setAttribute('aria-expanded', !isCollapsed);
2081
+ persistCollapse(container);
2082
+ }
2083
+
2084
+ function restoreCollapseState() {
2085
+ var state = loadCollapseState();
2086
+ if (state.size === 0) return;
2087
+ state.forEach(function(id) {
2088
+ var el = document.getElementById(id);
2089
+ if (!el) return;
2090
+ if (!el.classList.contains('feature') && !el.classList.contains('scenario')) return;
2091
+ el.classList.add('collapsed');
2092
+ var header = el.querySelector('.feature-header, .scenario-header');
2093
+ if (header) header.setAttribute('aria-expanded', 'false');
2094
+ });
2005
2095
  }
2006
2096
 
2007
2097
  function initCollapse() {
@@ -2048,14 +2138,20 @@ function expandAll() {
2048
2138
  const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
2049
2139
  header?.setAttribute('aria-expanded', 'true');
2050
2140
  });
2141
+ saveCollapseState(new Set());
2051
2142
  }
2052
2143
 
2053
2144
  function collapseAll() {
2145
+ var ids = new Set();
2054
2146
  document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
2055
2147
  el.classList.add('collapsed');
2056
2148
  const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
2057
2149
  header?.setAttribute('aria-expanded', 'false');
2150
+ if (el.id && (el.classList.contains('feature') || el.classList.contains('scenario'))) {
2151
+ ids.add(el.id);
2152
+ }
2058
2153
  });
2154
+ saveCollapseState(ids);
2059
2155
  }
2060
2156
 
2061
2157
  // Detail level toggle
@@ -2182,6 +2278,68 @@ function copyScenarioAsMarkdown(scenarioId) {
2182
2278
  });
2183
2279
  }
2184
2280
 
2281
+ // Copy scenario as Claude-ready prompt (failure investigation context)
2282
+ function copyScenarioAsPrompt(scenarioId) {
2283
+ var scenario = document.getElementById(scenarioId);
2284
+ if (!scenario) return;
2285
+
2286
+ var feature = scenario.closest('.feature');
2287
+ var featureTitle = feature ? (feature.querySelector('.feature-title') || {}).textContent || '' : '';
2288
+ var title = (scenario.querySelector('.scenario-name') || {}).textContent || '';
2289
+ var statusEl = scenario.querySelector('.status-icon');
2290
+ var status = statusEl && statusEl.classList.contains('status-passed') ? 'passed' :
2291
+ statusEl && statusEl.classList.contains('status-failed') ? 'failed' :
2292
+ statusEl && statusEl.classList.contains('status-skipped') ? 'skipped' : 'pending';
2293
+ var sourceLink = scenario.querySelector('.source-link');
2294
+ var source = sourceLink ? sourceLink.textContent || '' : '';
2295
+ var tags = Array.from(scenario.querySelectorAll('.scenario-meta .tag')).map(function(t) { return t.textContent.trim(); });
2296
+ var steps = scenario.querySelectorAll('.step, .step.continuation');
2297
+
2298
+ var lines = [];
2299
+ lines.push('I need help investigating a failing executable-story scenario.');
2300
+ lines.push('');
2301
+ if (featureTitle.trim()) lines.push('Feature: ' + featureTitle.trim());
2302
+ lines.push('Scenario: ' + title.trim());
2303
+ lines.push('Status: ' + status);
2304
+ if (source.trim()) lines.push('Source: ' + source.trim());
2305
+ if (tags.length > 0) lines.push('Tags: ' + tags.join(', '));
2306
+ lines.push('');
2307
+ lines.push('Steps:');
2308
+ steps.forEach(function(step) {
2309
+ var keyword = step.getAttribute('data-keyword') || '';
2310
+ var text = step.getAttribute('data-text') || '';
2311
+ var stepStatusEl = step.querySelector('.step-status');
2312
+ var marker = ' ';
2313
+ if (stepStatusEl) {
2314
+ if (stepStatusEl.classList.contains('status-failed')) marker = 'x ';
2315
+ else if (stepStatusEl.classList.contains('status-passed')) marker = '+ ';
2316
+ else if (stepStatusEl.classList.contains('status-skipped')) marker = '- ';
2317
+ }
2318
+ lines.push(marker + keyword + ' ' + text);
2319
+ });
2320
+
2321
+ var errorBox = scenario.querySelector('.error-message');
2322
+ if (errorBox) {
2323
+ lines.push('');
2324
+ lines.push('Error:');
2325
+ lines.push((errorBox.textContent || '').trim());
2326
+ }
2327
+ var stackBox = scenario.querySelector('.error-stack');
2328
+ if (stackBox) {
2329
+ lines.push('');
2330
+ lines.push('Stack:');
2331
+ lines.push((stackBox.textContent || '').trim());
2332
+ }
2333
+
2334
+ lines.push('');
2335
+ lines.push('Please read the source file, identify the root cause, and propose a fix.');
2336
+
2337
+ var text = lines.join('\\n');
2338
+ navigator.clipboard.writeText(text).then(function() {
2339
+ showCopyToast(scenario);
2340
+ });
2341
+ }
2342
+
2185
2343
  // Hash scroll on load
2186
2344
  function initHashScroll() {
2187
2345
  if (!location.hash) return;
@@ -2351,6 +2509,7 @@ function generateScript(options) {
2351
2509
  initCalls.push("initStatusFilter();");
2352
2510
  initCalls.push("initKeyboardShortcuts();");
2353
2511
  initCalls.push("initCollapse();");
2512
+ initCalls.push("restoreCollapseState();");
2354
2513
  initCalls.push("initDetailLevel();");
2355
2514
  initCalls.push("applyAllFilters();");
2356
2515
  initCalls.push("initHashScroll();");
@@ -4432,6 +4591,33 @@ body {
4432
4591
  color: var(--primary);
4433
4592
  }
4434
4593
 
4594
+ .copy-prompt-btn {
4595
+ display: inline-flex;
4596
+ align-items: center;
4597
+ justify-content: center;
4598
+ width: 1.5rem;
4599
+ height: 1.5rem;
4600
+ border: none;
4601
+ background: none;
4602
+ color: var(--muted-foreground);
4603
+ cursor: pointer;
4604
+ opacity: 0.6;
4605
+ transition: opacity 0.15s ease, transform 0.15s ease;
4606
+ font-size: 0.95rem;
4607
+ padding: 0;
4608
+ flex-shrink: 0;
4609
+ }
4610
+
4611
+ .scenario-header:hover .copy-prompt-btn,
4612
+ .copy-prompt-btn:focus-visible {
4613
+ opacity: 1;
4614
+ }
4615
+
4616
+ .copy-prompt-btn:hover {
4617
+ color: var(--primary);
4618
+ transform: scale(1.15);
4619
+ }
4620
+
4435
4621
  /* ============================================================================
4436
4622
  Keyboard Navigation
4437
4623
  ============================================================================ */
@@ -4669,6 +4855,82 @@ a.toc-title:hover {
4669
4855
  outline-offset: 2px;
4670
4856
  }
4671
4857
 
4858
+ /* ============================================================================
4859
+ Mobile responsive refinements
4860
+ ============================================================================ */
4861
+ @media (max-width: 640px) {
4862
+ .container {
4863
+ padding: 0.875rem;
4864
+ }
4865
+
4866
+ .header {
4867
+ flex-direction: column;
4868
+ align-items: stretch;
4869
+ gap: 0.75rem;
4870
+ }
4871
+
4872
+ .header-actions {
4873
+ flex-wrap: wrap;
4874
+ gap: 0.5rem;
4875
+ }
4876
+
4877
+ .search-input {
4878
+ width: 100%;
4879
+ flex: 1 1 100%;
4880
+ min-width: 0;
4881
+ }
4882
+
4883
+ .header h1 {
4884
+ font-size: 1.25rem;
4885
+ }
4886
+
4887
+ .scenario-header,
4888
+ .feature-header {
4889
+ flex-wrap: wrap;
4890
+ gap: 0.5rem;
4891
+ }
4892
+
4893
+ .scenario-meta {
4894
+ flex-wrap: wrap;
4895
+ }
4896
+
4897
+ .scenario-actions {
4898
+ flex-wrap: wrap;
4899
+ }
4900
+
4901
+ /* Always-visible action buttons on touch (no hover) */
4902
+ .copy-scenario-btn,
4903
+ .copy-prompt-btn,
4904
+ .permalink-anchor {
4905
+ opacity: 1 !important;
4906
+ }
4907
+
4908
+ .summary-card {
4909
+ padding: 0.75rem 0.875rem;
4910
+ }
4911
+
4912
+ .summary-card .value {
4913
+ font-size: 1.5rem;
4914
+ }
4915
+
4916
+ .tag-bar {
4917
+ overflow-x: auto;
4918
+ -webkit-overflow-scrolling: touch;
4919
+ }
4920
+
4921
+ .shortcuts-overlay {
4922
+ padding: 1rem;
4923
+ }
4924
+ }
4925
+
4926
+ @media (hover: none) and (pointer: coarse) {
4927
+ .copy-scenario-btn,
4928
+ .copy-prompt-btn,
4929
+ .permalink-anchor {
4930
+ opacity: 1;
4931
+ }
4932
+ }
4933
+
4672
4934
  `;
4673
4935
 
4674
4936
  // src/formatters/html/themes/default.ts
@@ -14247,7 +14509,7 @@ function renderDocEntry(entry, deps) {
14247
14509
  // src/formatters/html/renderers/steps.ts
14248
14510
  var CONTINUATION_KEYWORDS = ["And", "But", "*"];
14249
14511
  function renderStep(step, stepResult, index, deps) {
14250
- const statusIcon2 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14512
+ const statusIcon4 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
14251
14513
  const statusClass = stepResult ? `status-${stepResult.status}` : "";
14252
14514
  const duration = stepResult && stepResult.durationMs > 0 ? `${stepResult.durationMs}ms` : "";
14253
14515
  const keywordTrimmed = step.keyword.trim();
@@ -14256,7 +14518,7 @@ function renderStep(step, stepResult, index, deps) {
14256
14518
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
14257
14519
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
14258
14520
  return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
14259
- <span class="step-status ${statusClass}">${statusIcon2}</span>
14521
+ <span class="step-status ${statusClass}">${statusIcon4}</span>
14260
14522
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
14261
14523
  <span class="step-text">${textHtml}</span>
14262
14524
  <span class="step-duration">${duration}</span>
@@ -14301,16 +14563,16 @@ function highlightStepParams(text2, deps) {
14301
14563
  var MIN_METRIC_SAMPLES = 5;
14302
14564
 
14303
14565
  // src/formatters/html/renderers/scenario.ts
14304
- function renderTicket(ticket, template, escapeHtml3) {
14566
+ function renderTicket(ticket, template, escapeHtml4) {
14305
14567
  const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
14306
14568
  if (url) {
14307
- 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>`;
14308
14570
  }
14309
- return `<span class="tag ticket-tag">${escapeHtml3(ticket.id)}</span>`;
14571
+ return `<span class="tag ticket-tag">${escapeHtml4(ticket.id)}</span>`;
14310
14572
  }
14311
14573
  function renderScenario(args, deps) {
14312
14574
  const { tc } = args;
14313
- const statusIcon2 = deps.getStatusIcon(tc.status);
14575
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14314
14576
  const statusClass = `status-${tc.status}`;
14315
14577
  const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
14316
14578
  const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
@@ -14380,13 +14642,14 @@ function renderScenario(args, deps) {
14380
14642
  <div class="scenario-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14381
14643
  <div class="scenario-info">
14382
14644
  <div class="scenario-title">
14383
- <span class="status-icon ${statusClass}">${statusIcon2}</span>
14645
+ <span class="status-icon ${statusClass}">${statusIcon4}</span>
14384
14646
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
14385
14647
  </div>
14386
14648
  <div class="scenario-meta">${tags}${tickets}${outcomeBadge}${sourceLink}${traceBadge}${metricBadges}</div>
14387
14649
  </div>
14388
14650
  <div class="scenario-actions">
14389
14651
  <button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
14652
+ ${tc.status === "failed" ? `<button class="copy-prompt-btn" onclick="copyScenarioAsPrompt('scenario-${tc.id}')" aria-label="Copy as Claude prompt" title="Copy as AI prompt">\u2728</button>` : ""}
14390
14653
  <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
14391
14654
  <span class="scenario-duration">${duration}</span>
14392
14655
  </div>
@@ -14505,7 +14768,7 @@ function flattenTree(roots) {
14505
14768
  }
14506
14769
  return result;
14507
14770
  }
14508
- function buildTooltip(span, escapeHtml3) {
14771
+ function buildTooltip(span, escapeHtml4) {
14509
14772
  const parts = [];
14510
14773
  parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
14511
14774
  if (span.statusMessage) {
@@ -14523,7 +14786,7 @@ function buildTooltip(span, escapeHtml3) {
14523
14786
  if (text2.length > TOOLTIP_MAX_LENGTH) {
14524
14787
  text2 = text2.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
14525
14788
  }
14526
- return escapeHtml3(text2);
14789
+ return escapeHtml4(text2);
14527
14790
  }
14528
14791
  function renderTraceView(args, deps) {
14529
14792
  if (!args.spans || args.spans.length === 0) return "";
@@ -14746,11 +15009,11 @@ function renderToc(args, deps) {
14746
15009
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14747
15010
  const featureSlug = `feature-${slugify(file)}`;
14748
15011
  const scenarios = testCases.map((tc) => {
14749
- const statusIcon2 = deps.getStatusIcon(tc.status);
15012
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14750
15013
  const statusClass = `status-${tc.status}`;
14751
15014
  const failedClass = tc.status === "failed" ? " toc-failed" : "";
14752
15015
  return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14753
- <span class="toc-status ${statusClass}">${statusIcon2}</span>
15016
+ <span class="toc-status ${statusClass}">${statusIcon4}</span>
14754
15017
  ${deps.escapeHtml(tc.story.scenario)}
14755
15018
  </a>`;
14756
15019
  }).join("\n");
@@ -19267,6 +19530,697 @@ function listScenarios(args, _deps) {
19267
19530
  return lines.join("\n");
19268
19531
  }
19269
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
+
19270
20224
  // src/index.ts
19271
20225
  var FORMAT_EXTENSIONS = {
19272
20226
  astro: ".md",
@@ -19388,7 +20342,7 @@ var ReportGenerator = class {
19388
20342
  exclude: options.exclude ?? [],
19389
20343
  includeTags: options.includeTags ?? [],
19390
20344
  excludeTags: options.excludeTags ?? [],
19391
- formats: options.formats ?? ["cucumber-json"],
20345
+ formats: options.formats ?? ["html"],
19392
20346
  outputDir: options.outputDir ?? "reports",
19393
20347
  outputName: options.outputName ?? "index",
19394
20348
  outputNameTimestamp: options.outputNameTimestamp ?? false,
@@ -19762,6 +20716,7 @@ var EXIT_CANONICAL_VALIDATION = 2;
19762
20716
  var EXIT_GENERATION = 3;
19763
20717
  var EXIT_USAGE = 4;
19764
20718
  var EXIT_COMPARE_GATE = 5;
20719
+ var EXIT_REVIEW_GATE = 5;
19765
20720
  var HELP_TEXT = `
19766
20721
  executable-stories \u2014 Generate reports from test results JSON.
19767
20722
 
@@ -19769,6 +20724,7 @@ USAGE
19769
20724
  executable-stories format <file> [options]
19770
20725
  executable-stories format --stdin [options]
19771
20726
  executable-stories compare <baseline-file> <current-file> [options]
20727
+ executable-stories review <file> --changed-files <path> [options]
19772
20728
  executable-stories list <file> [options]
19773
20729
  executable-stories validate <file>
19774
20730
  executable-stories validate --stdin
@@ -19779,6 +20735,7 @@ USAGE
19779
20735
  SUBCOMMANDS
19780
20736
  format Read raw test results and generate reports
19781
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)
19782
20739
  list List scenarios from a test run (text table or JSON)
19783
20740
  validate Validate a JSON file against the schema (no output generated)
19784
20741
  init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
@@ -19829,6 +20786,11 @@ OPTIONS
19829
20786
  --fail-on-regression Exit non-zero when any regression is detected in compare
19830
20787
  --fail-on-added-failures Exit non-zero when newly added scenarios are failing
19831
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)
19832
20794
  --emit-canonical <path> Write canonical JSON to given path
19833
20795
  --help Show this help message
19834
20796
 
@@ -19901,9 +20863,9 @@ async function parseCliArgs(argv) {
19901
20863
  process.exit(EXIT_SUCCESS);
19902
20864
  }
19903
20865
  const subcommand = args[0];
19904
- 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") {
19905
20867
  console.error(
19906
- `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".`
19907
20869
  );
19908
20870
  process.exit(EXIT_USAGE);
19909
20871
  }
@@ -19997,6 +20959,11 @@ async function parseCliArgs(argv) {
19997
20959
  "fail-on-regression": { type: "boolean", default: false },
19998
20960
  "fail-on-added-failures": { type: "boolean", default: false },
19999
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" },
20000
20967
  "config": { type: "string" },
20001
20968
  help: { type: "boolean", default: false }
20002
20969
  },
@@ -20122,6 +21089,17 @@ async function parseCliArgs(argv) {
20122
21089
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
20123
21090
  process.exit(EXIT_USAGE);
20124
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
+ }
20125
21103
  const cliArgs = {
20126
21104
  subcommand,
20127
21105
  inputFile,
@@ -20173,6 +21151,11 @@ async function parseCliArgs(argv) {
20173
21151
  failOnRegression: values["fail-on-regression"],
20174
21152
  failOnAddedFailures: values["fail-on-added-failures"],
20175
21153
  maxRegressions,
21154
+ changedFilesPath: values["changed-files"],
21155
+ baseRef: values["base-ref"],
21156
+ headRef: values["head-ref"],
21157
+ failOn: failOnRaw,
21158
+ minEvidence: minEvidenceRaw,
20176
21159
  config: values["config"]
20177
21160
  };
20178
21161
  return { args: cliArgs, pluginConfig, customRequested };
@@ -20386,6 +21369,30 @@ async function main() {
20386
21369
  process.exit(EXIT_GENERATION);
20387
21370
  }
20388
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
+ }
20389
21396
  if (args.subcommand === "list") {
20390
21397
  const text3 = await readInput(args);
20391
21398
  const run = applySelection(normalizeRunFromText(text3, args).run, args);
@@ -20744,6 +21751,102 @@ async function generateCompareReports(baseline, current, baselineFile, args) {
20744
21751
  prSummary: args.prSummary || args.prSummaryFile ? createPrCommentSummary(result.diff) : void 0
20745
21752
  };
20746
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
+ }
20747
21850
  function printResult(result, args, startMs, droppedMissingStory = 0) {
20748
21851
  const durationMs = Date.now() - startMs;
20749
21852
  if (args.jsonSummary) {