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/index.cjs CHANGED
@@ -44,6 +44,8 @@ __export(src_exports, {
44
44
  MIN_PERF_SAMPLES: () => MIN_PERF_SAMPLES,
45
45
  MarkdownFormatter: () => MarkdownFormatter,
46
46
  ReportGenerator: () => ReportGenerator,
47
+ ReviewHtmlFormatter: () => ReviewHtmlFormatter,
48
+ ReviewMarkdownFormatter: () => ReviewMarkdownFormatter,
47
49
  RunDiffHtmlFormatter: () => RunDiffHtmlFormatter,
48
50
  RunDiffMarkdownFormatter: () => RunDiffMarkdownFormatter,
49
51
  STORY_META_KEY: () => STORY_META_KEY,
@@ -54,6 +56,7 @@ __export(src_exports, {
54
56
  adaptPlaywrightRun: () => adaptPlaywrightRun,
55
57
  adaptVitestRun: () => adaptVitestRun,
56
58
  assertValidRun: () => assertValidRun,
59
+ buildReview: () => buildReview,
57
60
  bundleAssets: () => bundleAssets,
58
61
  calculateFlakiness: () => calculateFlakiness,
59
62
  calculateStability: () => calculateStability,
@@ -63,6 +66,8 @@ __export(src_exports, {
63
66
  copyMarkdownAssets: () => copyMarkdownAssets,
64
67
  createPrCommentSummary: () => createPrCommentSummary,
65
68
  createReportGenerator: () => createReportGenerator,
69
+ deriveAudience: () => deriveAudience,
70
+ deriveChangeType: () => deriveChangeType,
66
71
  deriveStepResults: () => deriveStepResults,
67
72
  detectCI: () => detectCI4,
68
73
  detectPerformanceTrend: () => detectPerformanceTrend,
@@ -74,7 +79,10 @@ __export(src_exports, {
74
79
  generateTestCaseId: () => generateTestCaseId,
75
80
  getAvailableThemes: () => getAvailableThemes,
76
81
  getCssOnlyThemes: () => getCssOnlyThemes,
82
+ gradeEvidence: () => gradeEvidence,
77
83
  hasSufficientHistory: () => hasSufficientHistory,
84
+ isReviewableSource: () => isReviewableSource,
85
+ isTestFile: () => isTestFile,
78
86
  listScenarios: () => listScenarios,
79
87
  loadHistory: () => loadHistory,
80
88
  mergeStepResults: () => mergeStepResults,
@@ -394,7 +402,8 @@ function canonicalizeTestCase(raw, options, projectRoot) {
394
402
  projectName: raw.projectName,
395
403
  retry: raw.retry ?? 0,
396
404
  retries: raw.retries ?? 0,
397
- tags
405
+ tags,
406
+ ...raw.evidence ? { evidence: raw.evidence } : {}
398
407
  };
399
408
  }
400
409
  function normalizeTags(story) {
@@ -13927,7 +13936,7 @@ function renderDocEntry(entry, deps) {
13927
13936
  // src/formatters/html/renderers/steps.ts
13928
13937
  var CONTINUATION_KEYWORDS = ["And", "But", "*"];
13929
13938
  function renderStep(step, stepResult, index, deps) {
13930
- const statusIcon2 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
13939
+ const statusIcon4 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
13931
13940
  const statusClass = stepResult ? `status-${stepResult.status}` : "";
13932
13941
  const duration = stepResult && stepResult.durationMs > 0 ? `${stepResult.durationMs}ms` : "";
13933
13942
  const keywordTrimmed = step.keyword.trim();
@@ -13936,7 +13945,7 @@ function renderStep(step, stepResult, index, deps) {
13936
13945
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
13937
13946
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
13938
13947
  return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
13939
- <span class="step-status ${statusClass}">${statusIcon2}</span>
13948
+ <span class="step-status ${statusClass}">${statusIcon4}</span>
13940
13949
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
13941
13950
  <span class="step-text">${textHtml}</span>
13942
13951
  <span class="step-duration">${duration}</span>
@@ -13986,16 +13995,16 @@ function hasSufficientHistory(entries, min) {
13986
13995
  }
13987
13996
 
13988
13997
  // src/formatters/html/renderers/scenario.ts
13989
- function renderTicket(ticket, template, escapeHtml3) {
13998
+ function renderTicket(ticket, template, escapeHtml4) {
13990
13999
  const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
13991
14000
  if (url) {
13992
- return `<a class="tag ticket-tag" href="${escapeHtml3(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml3(ticket.id)}</a>`;
14001
+ return `<a class="tag ticket-tag" href="${escapeHtml4(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml4(ticket.id)}</a>`;
13993
14002
  }
13994
- return `<span class="tag ticket-tag">${escapeHtml3(ticket.id)}</span>`;
14003
+ return `<span class="tag ticket-tag">${escapeHtml4(ticket.id)}</span>`;
13995
14004
  }
13996
14005
  function renderScenario(args, deps) {
13997
14006
  const { tc } = args;
13998
- const statusIcon2 = deps.getStatusIcon(tc.status);
14007
+ const statusIcon4 = deps.getStatusIcon(tc.status);
13999
14008
  const statusClass = `status-${tc.status}`;
14000
14009
  const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
14001
14010
  const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
@@ -14065,7 +14074,7 @@ function renderScenario(args, deps) {
14065
14074
  <div class="scenario-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14066
14075
  <div class="scenario-info">
14067
14076
  <div class="scenario-title">
14068
- <span class="status-icon ${statusClass}">${statusIcon2}</span>
14077
+ <span class="status-icon ${statusClass}">${statusIcon4}</span>
14069
14078
  <span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
14070
14079
  </div>
14071
14080
  <div class="scenario-meta">${tags}${tickets}${outcomeBadge}${sourceLink}${traceBadge}${metricBadges}</div>
@@ -14191,7 +14200,7 @@ function flattenTree(roots) {
14191
14200
  }
14192
14201
  return result;
14193
14202
  }
14194
- function buildTooltip(span, escapeHtml3) {
14203
+ function buildTooltip(span, escapeHtml4) {
14195
14204
  const parts = [];
14196
14205
  parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
14197
14206
  if (span.statusMessage) {
@@ -14209,7 +14218,7 @@ function buildTooltip(span, escapeHtml3) {
14209
14218
  if (text2.length > TOOLTIP_MAX_LENGTH) {
14210
14219
  text2 = text2.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
14211
14220
  }
14212
- return escapeHtml3(text2);
14221
+ return escapeHtml4(text2);
14213
14222
  }
14214
14223
  function renderTraceView(args, deps) {
14215
14224
  if (!args.spans || args.spans.length === 0) return "";
@@ -14432,11 +14441,11 @@ function renderToc(args, deps) {
14432
14441
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14433
14442
  const featureSlug = `feature-${slugify(file)}`;
14434
14443
  const scenarios = testCases.map((tc) => {
14435
- const statusIcon2 = deps.getStatusIcon(tc.status);
14444
+ const statusIcon4 = deps.getStatusIcon(tc.status);
14436
14445
  const statusClass = `status-${tc.status}`;
14437
14446
  const failedClass = tc.status === "failed" ? " toc-failed" : "";
14438
14447
  return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14439
- <span class="toc-status ${statusClass}">${statusIcon2}</span>
14448
+ <span class="toc-status ${statusClass}">${statusIcon4}</span>
14440
14449
  ${deps.escapeHtml(tc.story.scenario)}
14441
14450
  </a>`;
14442
14451
  }).join("\n");
@@ -19748,6 +19757,697 @@ function listScenarios(args, _deps) {
19748
19757
  return lines.join("\n");
19749
19758
  }
19750
19759
 
19760
+ // src/review/conventions.ts
19761
+ var CHANGE_TAG_PREFIX = "change:";
19762
+ var AUDIENCE_TAG_PREFIX = "audience:";
19763
+ var VALID_CHANGE_TYPES = /* @__PURE__ */ new Set([
19764
+ "feature",
19765
+ "bugfix",
19766
+ "refactor",
19767
+ "perf",
19768
+ "deps"
19769
+ ]);
19770
+ var STAKEHOLDER_FILE = /(?:\.e2e\.)|(?:^|\/)e2e\/|(?:\.spec\.)/i;
19771
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
19772
+ "ts",
19773
+ "tsx",
19774
+ "js",
19775
+ "jsx",
19776
+ "mjs",
19777
+ "cjs",
19778
+ "py",
19779
+ "go",
19780
+ "rs",
19781
+ "kt",
19782
+ "kts",
19783
+ "java",
19784
+ "cs",
19785
+ "rb"
19786
+ ]);
19787
+ var TEST_INFIX = /\.(?:story\.)?(?:int\.|e2e\.|unit\.)?(?:test|spec|cy)\.[a-z]+$/i;
19788
+ function deriveAudience(sourceFile, tags) {
19789
+ const override = tags.map((t) => t.toLowerCase()).find((t) => t.startsWith(AUDIENCE_TAG_PREFIX));
19790
+ if (override) {
19791
+ const value = override.slice(AUDIENCE_TAG_PREFIX.length);
19792
+ if (value === "stakeholder" || value === "engineer") return value;
19793
+ }
19794
+ return STAKEHOLDER_FILE.test(sourceFile) ? "stakeholder" : "engineer";
19795
+ }
19796
+ function deriveChangeType(tags) {
19797
+ for (const tag of tags) {
19798
+ const lower = tag.toLowerCase();
19799
+ if (lower.startsWith(CHANGE_TAG_PREFIX)) {
19800
+ const value = lower.slice(CHANGE_TAG_PREFIX.length);
19801
+ if (VALID_CHANGE_TYPES.has(value)) return value;
19802
+ }
19803
+ }
19804
+ return "unknown";
19805
+ }
19806
+ function extensionOf(path10) {
19807
+ const base = path10.split("/").pop() ?? path10;
19808
+ const dot = base.lastIndexOf(".");
19809
+ return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
19810
+ }
19811
+ function isTestFile(path10) {
19812
+ return TEST_INFIX.test(path10);
19813
+ }
19814
+ function isReviewableSource(path10) {
19815
+ if (isTestFile(path10)) return false;
19816
+ if (path10.endsWith(".d.ts")) return false;
19817
+ return CODE_EXTENSIONS.has(extensionOf(path10));
19818
+ }
19819
+ function testBaseKey(testFile) {
19820
+ return testFile.replace(TEST_INFIX, "");
19821
+ }
19822
+ function sourceBaseKey(sourceFile) {
19823
+ const dot = sourceFile.lastIndexOf(".");
19824
+ const slash = sourceFile.lastIndexOf("/");
19825
+ return dot > slash ? sourceFile.slice(0, dot) : sourceFile;
19826
+ }
19827
+
19828
+ // src/review/build-review.ts
19829
+ var STRENGTH_RANK = {
19830
+ none: 0,
19831
+ weak: 1,
19832
+ moderate: 2,
19833
+ strong: 3
19834
+ };
19835
+ var INTENT_SECTION_TITLE = /\b(why|intent|approach|rationale|reasoning)\b/i;
19836
+ function findDoc(docs, predicate) {
19837
+ if (!docs) return void 0;
19838
+ for (const doc of docs) {
19839
+ if (predicate(doc)) return doc;
19840
+ const nested = findDoc(doc.children, predicate);
19841
+ if (nested) return nested;
19842
+ }
19843
+ return void 0;
19844
+ }
19845
+ function anyDoc(docs, predicate) {
19846
+ return findDoc(docs, predicate) !== void 0;
19847
+ }
19848
+ function extractIntent(testCase) {
19849
+ const docs = testCase.story.docs;
19850
+ const section = findDoc(
19851
+ docs,
19852
+ (d) => d.kind === "section" && INTENT_SECTION_TITLE.test(d.title)
19853
+ );
19854
+ if (section && section.kind === "section") return section.markdown;
19855
+ const note = findDoc(docs, (d) => d.kind === "note");
19856
+ if (note && note.kind === "note") return note.text;
19857
+ return void 0;
19858
+ }
19859
+ function hasScreenshot(testCase) {
19860
+ if (testCase.attachments.some((a) => a.mediaType.startsWith("image/"))) {
19861
+ return true;
19862
+ }
19863
+ if (anyDoc(testCase.story.docs, (d) => d.kind === "screenshot")) return true;
19864
+ return testCase.story.steps.some(
19865
+ (step) => anyDoc(step.docs, (d) => d.kind === "screenshot")
19866
+ );
19867
+ }
19868
+ function hasOtelTrace(testCase) {
19869
+ return (testCase.story.otelSpans?.length ?? 0) > 0;
19870
+ }
19871
+ function gradeEvidence(testCase, audience) {
19872
+ if (testCase.status !== "passed") {
19873
+ return {
19874
+ strength: "none",
19875
+ reasons: [`test is ${testCase.status} \u2014 the proof does not hold`]
19876
+ };
19877
+ }
19878
+ const ev = testCase.evidence;
19879
+ const screenshot = hasScreenshot(testCase);
19880
+ const otel = hasOtelTrace(testCase);
19881
+ const isIntegration = /\.int\.test\./i.test(testCase.sourceFile);
19882
+ const mutation = ev?.mutationScorePct;
19883
+ const changedCov = ev?.changedLineCoveragePct;
19884
+ const strong2 = [];
19885
+ if (ev?.failingFirstVerified) {
19886
+ strong2.push("failing-first verified (red on base ref, green on head)");
19887
+ }
19888
+ if (typeof mutation === "number" && mutation >= 80) {
19889
+ strong2.push(`mutation score ${mutation}% (\u226580%)`);
19890
+ }
19891
+ if (screenshot && otel) {
19892
+ strong2.push("backed by screenshot + OTEL trace");
19893
+ } else if (audience === "stakeholder" && (screenshot || otel)) {
19894
+ strong2.push(`stakeholder proof: ${screenshot ? "screenshot" : "OTEL trace"}`);
19895
+ }
19896
+ if (strong2.length > 0) return { strength: "strong", reasons: strong2 };
19897
+ const moderate = [];
19898
+ if (screenshot) moderate.push("screenshot attached");
19899
+ if (otel) moderate.push("OTEL trace attached");
19900
+ if (typeof mutation === "number" && mutation >= 50) {
19901
+ moderate.push(`mutation score ${mutation}%`);
19902
+ }
19903
+ if (typeof changedCov === "number" && changedCov >= 80) {
19904
+ moderate.push(`changed-line coverage ${changedCov}%`);
19905
+ }
19906
+ if (isIntegration) moderate.push("integration-level test");
19907
+ if (moderate.length > 0) return { strength: "moderate", reasons: moderate };
19908
+ return {
19909
+ strength: "weak",
19910
+ reasons: [
19911
+ "passing test only \u2014 no corroborating evidence (add e2e proof, mutation score, or failing-first)"
19912
+ ]
19913
+ };
19914
+ }
19915
+ function toClaim(testCase, changedSourcePaths) {
19916
+ const audience = deriveAudience(testCase.sourceFile, testCase.tags);
19917
+ const changeType = deriveChangeType(testCase.tags);
19918
+ const { strength, reasons } = gradeEvidence(testCase, audience);
19919
+ const key = testBaseKey(testCase.sourceFile);
19920
+ const coversFiles = changedSourcePaths.filter(
19921
+ (path10) => sourceBaseKey(path10) === key
19922
+ );
19923
+ return {
19924
+ id: testCase.id,
19925
+ scenario: testCase.story.scenario,
19926
+ sourceFile: testCase.sourceFile,
19927
+ sourceLine: testCase.sourceLine,
19928
+ status: testCase.status,
19929
+ audience,
19930
+ changeType,
19931
+ strength,
19932
+ strengthReasons: reasons,
19933
+ intent: extractIntent(testCase),
19934
+ coversFiles,
19935
+ testCase
19936
+ };
19937
+ }
19938
+ function bandFor(claims) {
19939
+ if (claims.length === 0) return "uncovered";
19940
+ const maxRank = Math.max(...claims.map((c) => STRENGTH_RANK[c.strength]));
19941
+ return maxRank >= STRENGTH_RANK.moderate ? "covered" : "weak";
19942
+ }
19943
+ var AUDIENCE_ORDER = {
19944
+ stakeholder: 0,
19945
+ engineer: 1
19946
+ };
19947
+ function buildReview(run, context = { changedFiles: [] }) {
19948
+ const changedSource = context.changedFiles.filter(
19949
+ (f) => isReviewableSource(f.path)
19950
+ );
19951
+ const changedSourcePaths = changedSource.map((f) => f.path);
19952
+ const claims = run.testCases.map((tc) => toClaim(tc, changedSourcePaths));
19953
+ const changedFiles = changedSource.map((file) => {
19954
+ const covering = claims.filter((c) => c.coversFiles.includes(file.path));
19955
+ return {
19956
+ path: file.path,
19957
+ changeKind: file.changeKind,
19958
+ band: bandFor(covering),
19959
+ claims: covering.map((c) => ({
19960
+ id: c.id,
19961
+ scenario: c.scenario,
19962
+ strength: c.strength
19963
+ }))
19964
+ };
19965
+ });
19966
+ const sortedClaims = [...claims].sort((a, b) => {
19967
+ if (AUDIENCE_ORDER[a.audience] !== AUDIENCE_ORDER[b.audience]) {
19968
+ return AUDIENCE_ORDER[a.audience] - AUDIENCE_ORDER[b.audience];
19969
+ }
19970
+ if (STRENGTH_RANK[a.strength] !== STRENGTH_RANK[b.strength]) {
19971
+ return STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength];
19972
+ }
19973
+ if (a.sourceFile !== b.sourceFile) {
19974
+ return a.sourceFile.localeCompare(b.sourceFile);
19975
+ }
19976
+ return a.scenario.localeCompare(b.scenario);
19977
+ });
19978
+ const bandRank = { uncovered: 0, weak: 1, covered: 2 };
19979
+ const sortedFiles = [...changedFiles].sort((a, b) => {
19980
+ if (bandRank[a.band] !== bandRank[b.band]) {
19981
+ return bandRank[a.band] - bandRank[b.band];
19982
+ }
19983
+ return a.path.localeCompare(b.path);
19984
+ });
19985
+ const summary = buildSummary2(sortedClaims, sortedFiles);
19986
+ return {
19987
+ run,
19988
+ context,
19989
+ summary,
19990
+ claims: sortedClaims,
19991
+ changedFiles: sortedFiles
19992
+ };
19993
+ }
19994
+ function buildSummary2(claims, changedFiles) {
19995
+ const byAudience = {
19996
+ stakeholder: 0,
19997
+ engineer: 0
19998
+ };
19999
+ const byStrength = {
20000
+ none: 0,
20001
+ weak: 0,
20002
+ moderate: 0,
20003
+ strong: 0
20004
+ };
20005
+ for (const claim of claims) {
20006
+ byAudience[claim.audience] += 1;
20007
+ byStrength[claim.strength] += 1;
20008
+ }
20009
+ return {
20010
+ totalClaims: claims.length,
20011
+ byAudience,
20012
+ byStrength,
20013
+ changedSourceFiles: changedFiles.length,
20014
+ uncovered: changedFiles.filter((f) => f.band === "uncovered").length,
20015
+ weaklyCovered: changedFiles.filter((f) => f.band === "weak").length,
20016
+ covered: changedFiles.filter((f) => f.band === "covered").length
20017
+ };
20018
+ }
20019
+
20020
+ // src/formatters/review-markdown.ts
20021
+ var STRENGTH_BADGE = {
20022
+ strong: "\u{1F7E2} strong",
20023
+ moderate: "\u{1F7E1} moderate",
20024
+ weak: "\u{1F7E0} weak",
20025
+ none: "\u{1F534} none"
20026
+ };
20027
+ function statusIcon2(status) {
20028
+ switch (status) {
20029
+ case "passed":
20030
+ return "\u2705";
20031
+ case "failed":
20032
+ return "\u274C";
20033
+ case "skipped":
20034
+ return "\u2298";
20035
+ default:
20036
+ return "\u2022";
20037
+ }
20038
+ }
20039
+ function escapeCell2(value) {
20040
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
20041
+ }
20042
+ function intentSummary(intent) {
20043
+ const firstLine = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
20044
+ const trimmed = firstLine.trim();
20045
+ return trimmed.length > 200 ? `${trimmed.slice(0, 197)}\u2026` : trimmed;
20046
+ }
20047
+ function renderTicket2(ticket) {
20048
+ return ticket.url ? `[${ticket.id}](${ticket.url})` : `\`${ticket.id}\``;
20049
+ }
20050
+ function renderUncoveredBand(lines, files) {
20051
+ const uncovered = files.filter((f) => f.band === "uncovered");
20052
+ if (uncovered.length === 0) return;
20053
+ lines.push(`## \u{1F534} Changed code with no evidence (${uncovered.length})`);
20054
+ lines.push("");
20055
+ lines.push("Start here \u2014 these changed source files have no claim or test behind them.");
20056
+ lines.push("");
20057
+ for (const file of uncovered) {
20058
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_`);
20059
+ }
20060
+ lines.push("");
20061
+ }
20062
+ function renderWeakBand(lines, files) {
20063
+ const weak = files.filter((f) => f.band === "weak");
20064
+ if (weak.length === 0) return;
20065
+ lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
20066
+ lines.push("");
20067
+ for (const file of weak) {
20068
+ const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
20069
+ lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
20070
+ }
20071
+ lines.push("");
20072
+ }
20073
+ function renderClaim(lines, claim) {
20074
+ lines.push(`### ${statusIcon2(claim.status)} ${claim.scenario}`);
20075
+ lines.push("");
20076
+ lines.push(`- File: \`${claim.sourceFile}:${claim.sourceLine}\``);
20077
+ if (claim.changeType !== "unknown") {
20078
+ lines.push(`- Change: \`${claim.changeType}\``);
20079
+ }
20080
+ const tickets = claim.testCase.story.tickets ?? [];
20081
+ if (tickets.length > 0) {
20082
+ lines.push(`- Tickets: ${tickets.map(renderTicket2).join(", ")}`);
20083
+ }
20084
+ lines.push(
20085
+ `- Evidence: ${STRENGTH_BADGE[claim.strength]} \u2014 ${claim.strengthReasons.join("; ")}`
20086
+ );
20087
+ if (claim.coversFiles.length > 0) {
20088
+ lines.push(
20089
+ `- Covers: ${claim.coversFiles.map((f) => `\`${f}\``).join(", ")}`
20090
+ );
20091
+ }
20092
+ if (claim.intent) {
20093
+ lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
20094
+ }
20095
+ lines.push("");
20096
+ }
20097
+ function renderAudienceSection(lines, title, claims) {
20098
+ if (claims.length === 0) return;
20099
+ lines.push(`## ${title} (${claims.length})`);
20100
+ lines.push("");
20101
+ for (const claim of claims) {
20102
+ renderClaim(lines, claim);
20103
+ }
20104
+ }
20105
+ var ReviewMarkdownFormatter = class {
20106
+ title;
20107
+ constructor(options = {}) {
20108
+ this.title = options.title ?? "Evidence Review";
20109
+ }
20110
+ format(review) {
20111
+ const lines = [];
20112
+ const { summary, context } = review;
20113
+ lines.push(`# ${this.title}`);
20114
+ lines.push("");
20115
+ if (context.baseRef || context.headRef) {
20116
+ lines.push(
20117
+ `Comparing \`${context.baseRef ?? "base"}\` \u2192 \`${context.headRef ?? "head"}\`.`
20118
+ );
20119
+ lines.push("");
20120
+ }
20121
+ lines.push("## Review priority");
20122
+ lines.push("");
20123
+ if (summary.changedSourceFiles === 0) {
20124
+ lines.push(
20125
+ "No changed source files supplied \u2014 showing claims and evidence only."
20126
+ );
20127
+ } else if (summary.uncovered > 0) {
20128
+ lines.push(
20129
+ `Review the ${summary.uncovered} unaccounted-for file(s) first: changed code with no evidence behind it.`
20130
+ );
20131
+ } else if (summary.weaklyCovered > 0) {
20132
+ lines.push(
20133
+ `No unaccounted-for changes. Review ${summary.weaklyCovered} weakly-covered file(s) next.`
20134
+ );
20135
+ } else {
20136
+ lines.push("Every changed source file is backed by at least moderate evidence.");
20137
+ }
20138
+ lines.push("");
20139
+ if (summary.changedSourceFiles > 0) {
20140
+ lines.push("| \u{1F534} Uncovered | \u{1F7E1} Weak | \u{1F7E2} Covered | Changed files |");
20141
+ lines.push("| ---: | ---: | ---: | ---: |");
20142
+ lines.push(
20143
+ `| ${summary.uncovered} | ${summary.weaklyCovered} | ${summary.covered} | ${summary.changedSourceFiles} |`
20144
+ );
20145
+ lines.push("");
20146
+ }
20147
+ lines.push("| Claims | Stakeholder | Engineer | Strong | Moderate | Weak | None |");
20148
+ lines.push("| ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
20149
+ lines.push(
20150
+ `| ${summary.totalClaims} | ${summary.byAudience.stakeholder} | ${summary.byAudience.engineer} | ${summary.byStrength.strong} | ${summary.byStrength.moderate} | ${summary.byStrength.weak} | ${summary.byStrength.none} |`
20151
+ );
20152
+ lines.push("");
20153
+ renderUncoveredBand(lines, review.changedFiles);
20154
+ renderWeakBand(lines, review.changedFiles);
20155
+ renderAudienceSection(
20156
+ lines,
20157
+ "Stakeholder behaviour",
20158
+ review.claims.filter((c) => c.audience === "stakeholder")
20159
+ );
20160
+ renderAudienceSection(
20161
+ lines,
20162
+ "Engineer changes",
20163
+ review.claims.filter((c) => c.audience === "engineer")
20164
+ );
20165
+ return lines.join("\n").trimEnd();
20166
+ }
20167
+ };
20168
+
20169
+ // src/formatters/review-html.ts
20170
+ function escapeHtml3(value) {
20171
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20172
+ }
20173
+ var STRENGTH_LABEL = {
20174
+ strong: "Strong",
20175
+ moderate: "Moderate",
20176
+ weak: "Weak",
20177
+ none: "None"
20178
+ };
20179
+ function statusIcon3(status) {
20180
+ switch (status) {
20181
+ case "passed":
20182
+ return "\u2705";
20183
+ case "failed":
20184
+ return "\u274C";
20185
+ case "skipped":
20186
+ return "\u2298";
20187
+ default:
20188
+ return "\u2022";
20189
+ }
20190
+ }
20191
+ function formatStep3(step) {
20192
+ return `<li><strong>${escapeHtml3(step.keyword)}</strong> ${escapeHtml3(step.text)}</li>`;
20193
+ }
20194
+ function inlineDoc(doc) {
20195
+ switch (doc.kind) {
20196
+ case "note":
20197
+ return escapeHtml3(doc.text);
20198
+ case "section":
20199
+ return `<strong>${escapeHtml3(doc.title)}</strong>: ${escapeHtml3(doc.markdown)}`;
20200
+ case "kv":
20201
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(String(doc.value))}`;
20202
+ case "code":
20203
+ return `${escapeHtml3(doc.label)}: <code>${escapeHtml3(doc.content)}</code>`;
20204
+ case "link":
20205
+ return `${escapeHtml3(doc.label)}: ${escapeHtml3(doc.url)}`;
20206
+ default:
20207
+ return escapeHtml3(doc.kind);
20208
+ }
20209
+ }
20210
+ function renderEvidenceArtifacts(testCase) {
20211
+ const parts = [];
20212
+ for (const att of testCase.attachments) {
20213
+ if (att.mediaType.startsWith("image/") && att.contentEncoding === "BASE64") {
20214
+ parts.push(
20215
+ `<img class="shot" alt="${escapeHtml3(att.name)}" src="data:${escapeHtml3(att.mediaType)};base64,${att.body}" />`
20216
+ );
20217
+ }
20218
+ }
20219
+ if ((testCase.story.otelSpans?.length ?? 0) > 0) {
20220
+ parts.push(
20221
+ `<p class="trace-note">\u{1F4E1} ${testCase.story.otelSpans.length} OTEL span(s) captured</p>`
20222
+ );
20223
+ }
20224
+ return parts.length > 0 ? `<div class="artifacts">${parts.join("")}</div>` : "";
20225
+ }
20226
+ function renderTicketPills(claim) {
20227
+ const tickets = claim.testCase.story.tickets ?? [];
20228
+ if (tickets.length === 0) return "";
20229
+ return `<div class="ticket-row">${tickets.map((ticket) => {
20230
+ const label = escapeHtml3(ticket.id);
20231
+ if (ticket.url) {
20232
+ return `<a class="ticket-pill" href="${escapeHtml3(ticket.url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
20233
+ }
20234
+ return `<span class="ticket-pill">${label}</span>`;
20235
+ }).join("")}</div>`;
20236
+ }
20237
+ function renderClaimCard(claim) {
20238
+ const ticketSearch = (claim.testCase.story.tickets ?? []).map((ticket) => ticket.id).join(" ");
20239
+ const search = escapeHtml3(
20240
+ `${claim.scenario} ${claim.sourceFile} ${claim.changeType} ${claim.audience} ${claim.strength} ${ticketSearch}`
20241
+ ).toLowerCase();
20242
+ const steps = claim.testCase.story.steps.length > 0 ? `<ul class="step-list">${claim.testCase.story.steps.map(formatStep3).join("")}</ul>` : "";
20243
+ const reasons = `<ul class="reasons">${claim.strengthReasons.map((r) => `<li>${escapeHtml3(r)}</li>`).join("")}</ul>`;
20244
+ const intent = claim.intent !== void 0 ? `<div class="intent"><span class="intent-label">Why</span> ${escapeHtml3(claim.intent)}</div>` : "";
20245
+ const covers = claim.coversFiles.length > 0 ? `<p class="covers">Covers ${claim.coversFiles.map((f) => `<code>${escapeHtml3(f)}</code>`).join(", ")}</p>` : "";
20246
+ const docs = (claim.testCase.story.docs ?? []).filter(
20247
+ (d) => d.kind === "section" || d.kind === "note"
20248
+ );
20249
+ const extraDocs = docs.length > 0 && claim.intent === void 0 ? `<div class="intent">${docs.map(inlineDoc).join("<br>")}</div>` : "";
20250
+ return `
20251
+ <article class="claim-card" data-audience="${claim.audience}" data-strength="${claim.strength}" data-search="${search}">
20252
+ <header class="claim-header">
20253
+ <div>
20254
+ <span class="strength-badge strength-${claim.strength}">${STRENGTH_LABEL[claim.strength]}</span>
20255
+ ${claim.changeType !== "unknown" ? `<span class="change-pill">${escapeHtml3(claim.changeType)}</span>` : ""}
20256
+ <h3>${statusIcon3(claim.status)} ${escapeHtml3(claim.scenario)}</h3>
20257
+ <p class="source">${escapeHtml3(`${claim.sourceFile}:${claim.sourceLine}`)}</p>
20258
+ ${renderTicketPills(claim)}
20259
+ </div>
20260
+ </header>
20261
+ ${intent}${extraDocs}
20262
+ <div class="evidence-block">
20263
+ <span class="evidence-label">Evidence</span>
20264
+ ${reasons}
20265
+ </div>
20266
+ ${covers}
20267
+ ${renderEvidenceArtifacts(claim.testCase)}
20268
+ ${steps}
20269
+ </article>`;
20270
+ }
20271
+ function renderChangedFileRow(file) {
20272
+ const claims = file.claims.length > 0 ? file.claims.map((c) => `${escapeHtml3(c.scenario)} <em>(${c.strength})</em>`).join(", ") : "\u2014";
20273
+ return `<tr data-band="${file.band}">
20274
+ <td><span class="band-dot band-${file.band}"></span></td>
20275
+ <td><code>${escapeHtml3(file.path)}</code></td>
20276
+ <td>${escapeHtml3(file.changeKind)}</td>
20277
+ <td>${claims}</td>
20278
+ </tr>`;
20279
+ }
20280
+ function renderAudienceSection2(title, claims) {
20281
+ if (claims.length === 0) return "";
20282
+ return `<section class="audience-section">
20283
+ <h2>${escapeHtml3(title)} <span class="count">${claims.length}</span></h2>
20284
+ <div class="claim-list">${claims.map(renderClaimCard).join("\n")}</div>
20285
+ </section>`;
20286
+ }
20287
+ var REVIEW_CSS = `
20288
+ * { box-sizing: border-box; }
20289
+ body { margin: 0; font-family: var(--font-sans, system-ui, sans-serif); background: var(--background); color: var(--foreground); }
20290
+ main { max-width: 1100px; margin: 0 auto; padding: 32px 20px 80px; }
20291
+ h1, h2, h3, p { margin: 0; }
20292
+ .review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
20293
+ .subtle { color: var(--muted-foreground); margin-top: 6px; }
20294
+ .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); }
20295
+ .card, .claim-card, .summary-card, .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius, 16px); }
20296
+ .hero-card { padding: 24px; margin-bottom: 20px; }
20297
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 20px; }
20298
+ .summary-card { padding: 14px 16px; }
20299
+ .summary-card strong { display: block; font-size: 1.8rem; }
20300
+ .priority-banner { padding: 18px 20px; margin-bottom: 20px; background: linear-gradient(135deg, color-mix(in srgb, var(--destructive) 10%, transparent), var(--card)); }
20301
+ .panel { padding: 18px; margin-bottom: 24px; }
20302
+ table { width: 100%; border-collapse: collapse; }
20303
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
20304
+ th { color: var(--muted-foreground); font-weight: 600; }
20305
+ .band-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
20306
+ .band-uncovered { background: var(--destructive); }
20307
+ .band-weak { background: var(--warning, #b58900); }
20308
+ .band-covered { background: var(--success, #2e7d32); }
20309
+ .toolbar { position: sticky; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 20px; }
20310
+ .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); }
20311
+ .toolbar button { border: 1px solid var(--border); background: var(--secondary); border-radius: 999px; padding: 10px 14px; font: inherit; cursor: pointer; color: var(--foreground); }
20312
+ .toolbar button.active { background: var(--foreground); color: var(--background); }
20313
+ .audience-section { margin-bottom: 28px; }
20314
+ .audience-section h2 { margin-bottom: 12px; }
20315
+ .count { color: var(--muted-foreground); font-weight: 400; }
20316
+ .claim-list { display: grid; gap: 14px; }
20317
+ .claim-card { padding: 18px; }
20318
+ .claim-header h3 { margin-top: 8px; }
20319
+ .source { color: var(--muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; margin-top: 4px; }
20320
+ .ticket-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
20321
+ .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; }
20322
+ .ticket-pill:hover { color: var(--foreground); border-color: var(--muted-foreground); }
20323
+ .strength-badge, .change-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 0.8rem; margin-right: 6px; }
20324
+ .change-pill { background: var(--secondary); }
20325
+ .strength-strong { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); }
20326
+ .strength-moderate { background: color-mix(in srgb, var(--warning, #b58900) 20%, transparent); color: var(--warning, #b58900); }
20327
+ .strength-weak { background: color-mix(in srgb, #d2691e 20%, transparent); color: #b5530a; }
20328
+ .strength-none { background: color-mix(in srgb, var(--destructive) 16%, transparent); color: var(--destructive); }
20329
+ .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; }
20330
+ .intent-label { font-weight: 700; margin-right: 6px; }
20331
+ .evidence-block { margin-top: 10px; }
20332
+ .evidence-label { font-weight: 600; color: var(--muted-foreground); }
20333
+ .reasons { margin: 6px 0 0; padding-left: 18px; }
20334
+ .covers { color: var(--muted-foreground); margin-top: 8px; font-size: 0.9rem; }
20335
+ .artifacts { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
20336
+ .shot { max-width: 280px; max-height: 200px; border: 1px solid var(--border); border-radius: 8px; }
20337
+ .trace-note { color: var(--muted-foreground); }
20338
+ .step-list { margin: 12px 0 0; padding-left: 18px; color: var(--muted-foreground); }
20339
+ `;
20340
+ var JS_THEME_TOGGLE2 = `
20341
+ function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
20342
+ function getEffectiveTheme() { var s = localStorage.getItem('review-theme'); return (s === 'dark' || s === 'light') ? s : getSystemTheme(); }
20343
+ function toggleTheme() { var n = getEffectiveTheme() === 'dark' ? 'light' : 'dark'; localStorage.setItem('review-theme', n); applyTheme(n); }
20344
+ function applyTheme(t) {
20345
+ document.documentElement.setAttribute('data-theme', t);
20346
+ var b = document.querySelector('.theme-toggle');
20347
+ if (b) { b.textContent = t === 'dark' ? '\\u2600\\ufe0f' : '\\ud83c\\udf19'; }
20348
+ }
20349
+ `;
20350
+ var ReviewHtmlFormatter = class {
20351
+ title;
20352
+ theme;
20353
+ darkMode;
20354
+ constructor(options = {}) {
20355
+ this.title = options.title ?? "Evidence Review";
20356
+ this.theme = resolveTheme(options.theme ?? "default");
20357
+ this.darkMode = options.darkMode ?? true;
20358
+ }
20359
+ format(review) {
20360
+ const { summary, context } = review;
20361
+ 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.";
20362
+ const changedFilesPanel = summary.changedSourceFiles > 0 ? `<section class="panel">
20363
+ <h2>Changed files</h2>
20364
+ <table>
20365
+ <thead><tr><th></th><th>File</th><th>Change</th><th>Evidence</th></tr></thead>
20366
+ <tbody>${review.changedFiles.map(renderChangedFileRow).join("")}</tbody>
20367
+ </table>
20368
+ </section>` : "";
20369
+ const themeToggleHtml = this.darkMode ? `<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>` : "";
20370
+ const themeInitJs = this.darkMode ? `${JS_THEME_TOGGLE2}
20371
+ applyTheme(getEffectiveTheme());` : "";
20372
+ const themeAttr = this.darkMode ? ' data-theme="light"' : "";
20373
+ const refsLine = context.baseRef || context.headRef ? `<p class="subtle">Comparing ${escapeHtml3(context.baseRef ?? "base")} \u2192 ${escapeHtml3(context.headRef ?? "head")}</p>` : "";
20374
+ return `<!doctype html>
20375
+ <html lang="en"${themeAttr}>
20376
+ <head>
20377
+ <meta charset="utf-8" />
20378
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20379
+ <title>${escapeHtml3(this.title)}</title>
20380
+ <style>
20381
+ ${this.theme.css}
20382
+ ${REVIEW_CSS}
20383
+ </style>
20384
+ </head>
20385
+ <body>
20386
+ <main>
20387
+ <div class="hero-card card">
20388
+ <div class="review-header">
20389
+ <h1>${escapeHtml3(this.title)}</h1>
20390
+ ${themeToggleHtml}
20391
+ </div>
20392
+ ${refsLine}
20393
+ </div>
20394
+ <section class="summary-grid">
20395
+ <div class="summary-card"><strong>${summary.uncovered}</strong><span>\u{1F534} Uncovered</span></div>
20396
+ <div class="summary-card"><strong>${summary.weaklyCovered}</strong><span>\u{1F7E1} Weak</span></div>
20397
+ <div class="summary-card"><strong>${summary.covered}</strong><span>\u{1F7E2} Covered</span></div>
20398
+ <div class="summary-card"><strong>${summary.totalClaims}</strong><span>Claims</span></div>
20399
+ <div class="summary-card"><strong>${summary.byStrength.strong}</strong><span>Strong</span></div>
20400
+ <div class="summary-card"><strong>${summary.byStrength.weak + summary.byStrength.none}</strong><span>Weak/None</span></div>
20401
+ </section>
20402
+ <section class="card priority-banner">
20403
+ <h2>Review priority</h2>
20404
+ <p class="subtle">${escapeHtml3(priority)}</p>
20405
+ </section>
20406
+ ${changedFilesPanel}
20407
+ <section class="toolbar">
20408
+ <input type="search" placeholder="Filter claims by scenario, file, change-type" aria-label="Filter claims" />
20409
+ <button type="button" class="active" data-filter="all">All</button>
20410
+ <button type="button" data-filter="stakeholder">Stakeholder</button>
20411
+ <button type="button" data-filter="engineer">Engineer</button>
20412
+ <button type="button" data-filter="weak">Weak/None</button>
20413
+ </section>
20414
+ ${renderAudienceSection2("Stakeholder behaviour", review.claims.filter((c) => c.audience === "stakeholder"))}
20415
+ ${renderAudienceSection2("Engineer changes", review.claims.filter((c) => c.audience === "engineer"))}
20416
+ </main>
20417
+ <script>
20418
+ ${themeInitJs}
20419
+ const input = document.querySelector('input[type="search"]');
20420
+ const buttons = Array.from(document.querySelectorAll('[data-filter]'));
20421
+ const cards = Array.from(document.querySelectorAll('.claim-card'));
20422
+ let activeFilter = 'all';
20423
+ function applyFilters() {
20424
+ const query = (input.value || '').trim().toLowerCase();
20425
+ cards.forEach((card) => {
20426
+ const audience = card.getAttribute('data-audience');
20427
+ const strength = card.getAttribute('data-strength');
20428
+ const haystack = card.getAttribute('data-search') || '';
20429
+ let matchesFilter = activeFilter === 'all'
20430
+ || audience === activeFilter
20431
+ || (activeFilter === 'weak' && (strength === 'weak' || strength === 'none'));
20432
+ const matchesSearch = !query || haystack.includes(query);
20433
+ card.style.display = matchesFilter && matchesSearch ? '' : 'none';
20434
+ });
20435
+ }
20436
+ input.addEventListener('input', applyFilters);
20437
+ buttons.forEach((button) => {
20438
+ button.addEventListener('click', () => {
20439
+ activeFilter = button.getAttribute('data-filter');
20440
+ buttons.forEach((b) => b.classList.toggle('active', b === button));
20441
+ applyFilters();
20442
+ });
20443
+ });
20444
+ applyFilters();
20445
+ </script>
20446
+ </body>
20447
+ </html>`;
20448
+ }
20449
+ };
20450
+
19751
20451
  // src/index.ts
19752
20452
  var FORMAT_EXTENSIONS = {
19753
20453
  astro: ".md",
@@ -20211,6 +20911,8 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
20211
20911
  MIN_PERF_SAMPLES,
20212
20912
  MarkdownFormatter,
20213
20913
  ReportGenerator,
20914
+ ReviewHtmlFormatter,
20915
+ ReviewMarkdownFormatter,
20214
20916
  RunDiffHtmlFormatter,
20215
20917
  RunDiffMarkdownFormatter,
20216
20918
  STORY_META_KEY,
@@ -20221,6 +20923,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
20221
20923
  adaptPlaywrightRun,
20222
20924
  adaptVitestRun,
20223
20925
  assertValidRun,
20926
+ buildReview,
20224
20927
  bundleAssets,
20225
20928
  calculateFlakiness,
20226
20929
  calculateStability,
@@ -20230,6 +20933,8 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
20230
20933
  copyMarkdownAssets,
20231
20934
  createPrCommentSummary,
20232
20935
  createReportGenerator,
20936
+ deriveAudience,
20937
+ deriveChangeType,
20233
20938
  deriveStepResults,
20234
20939
  detectCI,
20235
20940
  detectPerformanceTrend,
@@ -20241,7 +20946,10 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
20241
20946
  generateTestCaseId,
20242
20947
  getAvailableThemes,
20243
20948
  getCssOnlyThemes,
20949
+ gradeEvidence,
20244
20950
  hasSufficientHistory,
20951
+ isReviewableSource,
20952
+ isTestFile,
20245
20953
  listScenarios,
20246
20954
  loadHistory,
20247
20955
  mergeStepResults,