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