executable-stories-formatters 0.7.15 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.cts +1 -1
- package/dist/adapters.d.ts +1 -1
- package/dist/cli.js +1121 -18
- 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 +937 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -126
- package/dist/index.d.ts +216 -126
- package/dist/index.js +929 -14
- 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) {
|
|
@@ -1997,11 +2045,53 @@ function initKeyboardShortcuts() {
|
|
|
1997
2045
|
});
|
|
1998
2046
|
}
|
|
1999
2047
|
|
|
2000
|
-
// Collapse/expand functionality
|
|
2048
|
+
// Collapse/expand functionality (persisted in localStorage)
|
|
2049
|
+
var COLLAPSE_KEY = 'es-collapsed-ids';
|
|
2050
|
+
|
|
2051
|
+
function loadCollapseState() {
|
|
2052
|
+
try {
|
|
2053
|
+
var raw = localStorage.getItem(COLLAPSE_KEY);
|
|
2054
|
+
return raw ? new Set(JSON.parse(raw)) : new Set();
|
|
2055
|
+
} catch (e) {
|
|
2056
|
+
return new Set();
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function saveCollapseState(set) {
|
|
2061
|
+
try {
|
|
2062
|
+
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(Array.from(set)));
|
|
2063
|
+
} catch (e) { /* quota or disabled */ }
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function persistCollapse(container) {
|
|
2067
|
+
if (!container || !container.id) return;
|
|
2068
|
+
var state = loadCollapseState();
|
|
2069
|
+
if (container.classList.contains('collapsed')) {
|
|
2070
|
+
state.add(container.id);
|
|
2071
|
+
} else {
|
|
2072
|
+
state.delete(container.id);
|
|
2073
|
+
}
|
|
2074
|
+
saveCollapseState(state);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2001
2077
|
function toggleCollapse(header, container) {
|
|
2002
2078
|
container?.classList.toggle('collapsed');
|
|
2003
2079
|
const isCollapsed = container?.classList.contains('collapsed');
|
|
2004
2080
|
header.setAttribute('aria-expanded', !isCollapsed);
|
|
2081
|
+
persistCollapse(container);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function restoreCollapseState() {
|
|
2085
|
+
var state = loadCollapseState();
|
|
2086
|
+
if (state.size === 0) return;
|
|
2087
|
+
state.forEach(function(id) {
|
|
2088
|
+
var el = document.getElementById(id);
|
|
2089
|
+
if (!el) return;
|
|
2090
|
+
if (!el.classList.contains('feature') && !el.classList.contains('scenario')) return;
|
|
2091
|
+
el.classList.add('collapsed');
|
|
2092
|
+
var header = el.querySelector('.feature-header, .scenario-header');
|
|
2093
|
+
if (header) header.setAttribute('aria-expanded', 'false');
|
|
2094
|
+
});
|
|
2005
2095
|
}
|
|
2006
2096
|
|
|
2007
2097
|
function initCollapse() {
|
|
@@ -2048,14 +2138,20 @@ function expandAll() {
|
|
|
2048
2138
|
const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
|
|
2049
2139
|
header?.setAttribute('aria-expanded', 'true');
|
|
2050
2140
|
});
|
|
2141
|
+
saveCollapseState(new Set());
|
|
2051
2142
|
}
|
|
2052
2143
|
|
|
2053
2144
|
function collapseAll() {
|
|
2145
|
+
var ids = new Set();
|
|
2054
2146
|
document.querySelectorAll('.feature, .scenario, .trace-view').forEach(el => {
|
|
2055
2147
|
el.classList.add('collapsed');
|
|
2056
2148
|
const header = el.querySelector('.feature-header, .scenario-header, .trace-view-header');
|
|
2057
2149
|
header?.setAttribute('aria-expanded', 'false');
|
|
2150
|
+
if (el.id && (el.classList.contains('feature') || el.classList.contains('scenario'))) {
|
|
2151
|
+
ids.add(el.id);
|
|
2152
|
+
}
|
|
2058
2153
|
});
|
|
2154
|
+
saveCollapseState(ids);
|
|
2059
2155
|
}
|
|
2060
2156
|
|
|
2061
2157
|
// Detail level toggle
|
|
@@ -2182,6 +2278,68 @@ function copyScenarioAsMarkdown(scenarioId) {
|
|
|
2182
2278
|
});
|
|
2183
2279
|
}
|
|
2184
2280
|
|
|
2281
|
+
// Copy scenario as Claude-ready prompt (failure investigation context)
|
|
2282
|
+
function copyScenarioAsPrompt(scenarioId) {
|
|
2283
|
+
var scenario = document.getElementById(scenarioId);
|
|
2284
|
+
if (!scenario) return;
|
|
2285
|
+
|
|
2286
|
+
var feature = scenario.closest('.feature');
|
|
2287
|
+
var featureTitle = feature ? (feature.querySelector('.feature-title') || {}).textContent || '' : '';
|
|
2288
|
+
var title = (scenario.querySelector('.scenario-name') || {}).textContent || '';
|
|
2289
|
+
var statusEl = scenario.querySelector('.status-icon');
|
|
2290
|
+
var status = statusEl && statusEl.classList.contains('status-passed') ? 'passed' :
|
|
2291
|
+
statusEl && statusEl.classList.contains('status-failed') ? 'failed' :
|
|
2292
|
+
statusEl && statusEl.classList.contains('status-skipped') ? 'skipped' : 'pending';
|
|
2293
|
+
var sourceLink = scenario.querySelector('.source-link');
|
|
2294
|
+
var source = sourceLink ? sourceLink.textContent || '' : '';
|
|
2295
|
+
var tags = Array.from(scenario.querySelectorAll('.scenario-meta .tag')).map(function(t) { return t.textContent.trim(); });
|
|
2296
|
+
var steps = scenario.querySelectorAll('.step, .step.continuation');
|
|
2297
|
+
|
|
2298
|
+
var lines = [];
|
|
2299
|
+
lines.push('I need help investigating a failing executable-story scenario.');
|
|
2300
|
+
lines.push('');
|
|
2301
|
+
if (featureTitle.trim()) lines.push('Feature: ' + featureTitle.trim());
|
|
2302
|
+
lines.push('Scenario: ' + title.trim());
|
|
2303
|
+
lines.push('Status: ' + status);
|
|
2304
|
+
if (source.trim()) lines.push('Source: ' + source.trim());
|
|
2305
|
+
if (tags.length > 0) lines.push('Tags: ' + tags.join(', '));
|
|
2306
|
+
lines.push('');
|
|
2307
|
+
lines.push('Steps:');
|
|
2308
|
+
steps.forEach(function(step) {
|
|
2309
|
+
var keyword = step.getAttribute('data-keyword') || '';
|
|
2310
|
+
var text = step.getAttribute('data-text') || '';
|
|
2311
|
+
var stepStatusEl = step.querySelector('.step-status');
|
|
2312
|
+
var marker = ' ';
|
|
2313
|
+
if (stepStatusEl) {
|
|
2314
|
+
if (stepStatusEl.classList.contains('status-failed')) marker = 'x ';
|
|
2315
|
+
else if (stepStatusEl.classList.contains('status-passed')) marker = '+ ';
|
|
2316
|
+
else if (stepStatusEl.classList.contains('status-skipped')) marker = '- ';
|
|
2317
|
+
}
|
|
2318
|
+
lines.push(marker + keyword + ' ' + text);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
var errorBox = scenario.querySelector('.error-message');
|
|
2322
|
+
if (errorBox) {
|
|
2323
|
+
lines.push('');
|
|
2324
|
+
lines.push('Error:');
|
|
2325
|
+
lines.push((errorBox.textContent || '').trim());
|
|
2326
|
+
}
|
|
2327
|
+
var stackBox = scenario.querySelector('.error-stack');
|
|
2328
|
+
if (stackBox) {
|
|
2329
|
+
lines.push('');
|
|
2330
|
+
lines.push('Stack:');
|
|
2331
|
+
lines.push((stackBox.textContent || '').trim());
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
lines.push('');
|
|
2335
|
+
lines.push('Please read the source file, identify the root cause, and propose a fix.');
|
|
2336
|
+
|
|
2337
|
+
var text = lines.join('\\n');
|
|
2338
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
2339
|
+
showCopyToast(scenario);
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2185
2343
|
// Hash scroll on load
|
|
2186
2344
|
function initHashScroll() {
|
|
2187
2345
|
if (!location.hash) return;
|
|
@@ -2351,6 +2509,7 @@ function generateScript(options) {
|
|
|
2351
2509
|
initCalls.push("initStatusFilter();");
|
|
2352
2510
|
initCalls.push("initKeyboardShortcuts();");
|
|
2353
2511
|
initCalls.push("initCollapse();");
|
|
2512
|
+
initCalls.push("restoreCollapseState();");
|
|
2354
2513
|
initCalls.push("initDetailLevel();");
|
|
2355
2514
|
initCalls.push("applyAllFilters();");
|
|
2356
2515
|
initCalls.push("initHashScroll();");
|
|
@@ -4432,6 +4591,33 @@ body {
|
|
|
4432
4591
|
color: var(--primary);
|
|
4433
4592
|
}
|
|
4434
4593
|
|
|
4594
|
+
.copy-prompt-btn {
|
|
4595
|
+
display: inline-flex;
|
|
4596
|
+
align-items: center;
|
|
4597
|
+
justify-content: center;
|
|
4598
|
+
width: 1.5rem;
|
|
4599
|
+
height: 1.5rem;
|
|
4600
|
+
border: none;
|
|
4601
|
+
background: none;
|
|
4602
|
+
color: var(--muted-foreground);
|
|
4603
|
+
cursor: pointer;
|
|
4604
|
+
opacity: 0.6;
|
|
4605
|
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
4606
|
+
font-size: 0.95rem;
|
|
4607
|
+
padding: 0;
|
|
4608
|
+
flex-shrink: 0;
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
.scenario-header:hover .copy-prompt-btn,
|
|
4612
|
+
.copy-prompt-btn:focus-visible {
|
|
4613
|
+
opacity: 1;
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
.copy-prompt-btn:hover {
|
|
4617
|
+
color: var(--primary);
|
|
4618
|
+
transform: scale(1.15);
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4435
4621
|
/* ============================================================================
|
|
4436
4622
|
Keyboard Navigation
|
|
4437
4623
|
============================================================================ */
|
|
@@ -4669,6 +4855,82 @@ a.toc-title:hover {
|
|
|
4669
4855
|
outline-offset: 2px;
|
|
4670
4856
|
}
|
|
4671
4857
|
|
|
4858
|
+
/* ============================================================================
|
|
4859
|
+
Mobile responsive refinements
|
|
4860
|
+
============================================================================ */
|
|
4861
|
+
@media (max-width: 640px) {
|
|
4862
|
+
.container {
|
|
4863
|
+
padding: 0.875rem;
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
.header {
|
|
4867
|
+
flex-direction: column;
|
|
4868
|
+
align-items: stretch;
|
|
4869
|
+
gap: 0.75rem;
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
.header-actions {
|
|
4873
|
+
flex-wrap: wrap;
|
|
4874
|
+
gap: 0.5rem;
|
|
4875
|
+
}
|
|
4876
|
+
|
|
4877
|
+
.search-input {
|
|
4878
|
+
width: 100%;
|
|
4879
|
+
flex: 1 1 100%;
|
|
4880
|
+
min-width: 0;
|
|
4881
|
+
}
|
|
4882
|
+
|
|
4883
|
+
.header h1 {
|
|
4884
|
+
font-size: 1.25rem;
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
.scenario-header,
|
|
4888
|
+
.feature-header {
|
|
4889
|
+
flex-wrap: wrap;
|
|
4890
|
+
gap: 0.5rem;
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
.scenario-meta {
|
|
4894
|
+
flex-wrap: wrap;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
.scenario-actions {
|
|
4898
|
+
flex-wrap: wrap;
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
/* Always-visible action buttons on touch (no hover) */
|
|
4902
|
+
.copy-scenario-btn,
|
|
4903
|
+
.copy-prompt-btn,
|
|
4904
|
+
.permalink-anchor {
|
|
4905
|
+
opacity: 1 !important;
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
.summary-card {
|
|
4909
|
+
padding: 0.75rem 0.875rem;
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
.summary-card .value {
|
|
4913
|
+
font-size: 1.5rem;
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
.tag-bar {
|
|
4917
|
+
overflow-x: auto;
|
|
4918
|
+
-webkit-overflow-scrolling: touch;
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4921
|
+
.shortcuts-overlay {
|
|
4922
|
+
padding: 1rem;
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
@media (hover: none) and (pointer: coarse) {
|
|
4927
|
+
.copy-scenario-btn,
|
|
4928
|
+
.copy-prompt-btn,
|
|
4929
|
+
.permalink-anchor {
|
|
4930
|
+
opacity: 1;
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4672
4934
|
`;
|
|
4673
4935
|
|
|
4674
4936
|
// src/formatters/html/themes/default.ts
|
|
@@ -14247,7 +14509,7 @@ function renderDocEntry(entry, deps) {
|
|
|
14247
14509
|
// src/formatters/html/renderers/steps.ts
|
|
14248
14510
|
var CONTINUATION_KEYWORDS = ["And", "But", "*"];
|
|
14249
14511
|
function renderStep(step, stepResult, index, deps) {
|
|
14250
|
-
const
|
|
14512
|
+
const statusIcon4 = stepResult ? deps.getStatusIcon(stepResult.status) : "\u25CB";
|
|
14251
14513
|
const statusClass = stepResult ? `status-${stepResult.status}` : "";
|
|
14252
14514
|
const duration = stepResult && stepResult.durationMs > 0 ? `${stepResult.durationMs}ms` : "";
|
|
14253
14515
|
const keywordTrimmed = step.keyword.trim();
|
|
@@ -14256,7 +14518,7 @@ function renderStep(step, stepResult, index, deps) {
|
|
|
14256
14518
|
const stepDocs = deps.renderDocs(step.docs, "step-docs");
|
|
14257
14519
|
const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
|
|
14258
14520
|
return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
|
|
14259
|
-
<span class="step-status ${statusClass}">${
|
|
14521
|
+
<span class="step-status ${statusClass}">${statusIcon4}</span>
|
|
14260
14522
|
<span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
|
|
14261
14523
|
<span class="step-text">${textHtml}</span>
|
|
14262
14524
|
<span class="step-duration">${duration}</span>
|
|
@@ -14301,16 +14563,16 @@ function highlightStepParams(text2, deps) {
|
|
|
14301
14563
|
var MIN_METRIC_SAMPLES = 5;
|
|
14302
14564
|
|
|
14303
14565
|
// src/formatters/html/renderers/scenario.ts
|
|
14304
|
-
function renderTicket(ticket, template,
|
|
14566
|
+
function renderTicket(ticket, template, escapeHtml4) {
|
|
14305
14567
|
const url = ticket.url ?? (template ? template.replace("{ticket}", ticket.id) : void 0);
|
|
14306
14568
|
if (url) {
|
|
14307
|
-
return `<a class="tag ticket-tag" href="${
|
|
14569
|
+
return `<a class="tag ticket-tag" href="${escapeHtml4(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml4(ticket.id)}</a>`;
|
|
14308
14570
|
}
|
|
14309
|
-
return `<span class="tag ticket-tag">${
|
|
14571
|
+
return `<span class="tag ticket-tag">${escapeHtml4(ticket.id)}</span>`;
|
|
14310
14572
|
}
|
|
14311
14573
|
function renderScenario(args, deps) {
|
|
14312
14574
|
const { tc } = args;
|
|
14313
|
-
const
|
|
14575
|
+
const statusIcon4 = deps.getStatusIcon(tc.status);
|
|
14314
14576
|
const statusClass = `status-${tc.status}`;
|
|
14315
14577
|
const duration = tc.durationMs > 0 ? `${(tc.durationMs / 1e3).toFixed(2)}s` : "";
|
|
14316
14578
|
const tags = tc.tags.map((t) => `<span class="tag">${deps.escapeHtml(t)}</span>`).join("");
|
|
@@ -14380,13 +14642,14 @@ function renderScenario(args, deps) {
|
|
|
14380
14642
|
<div class="scenario-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
|
|
14381
14643
|
<div class="scenario-info">
|
|
14382
14644
|
<div class="scenario-title">
|
|
14383
|
-
<span class="status-icon ${statusClass}">${
|
|
14645
|
+
<span class="status-icon ${statusClass}">${statusIcon4}</span>
|
|
14384
14646
|
<span class="scenario-name">${deps.escapeHtml(tc.story.scenario)}</span>
|
|
14385
14647
|
</div>
|
|
14386
14648
|
<div class="scenario-meta">${tags}${tickets}${outcomeBadge}${sourceLink}${traceBadge}${metricBadges}</div>
|
|
14387
14649
|
</div>
|
|
14388
14650
|
<div class="scenario-actions">
|
|
14389
14651
|
<button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
|
14652
|
+
${tc.status === "failed" ? `<button class="copy-prompt-btn" onclick="copyScenarioAsPrompt('scenario-${tc.id}')" aria-label="Copy as Claude prompt" title="Copy as AI prompt">\u2728</button>` : ""}
|
|
14390
14653
|
<button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
|
|
14391
14654
|
<span class="scenario-duration">${duration}</span>
|
|
14392
14655
|
</div>
|
|
@@ -14505,7 +14768,7 @@ function flattenTree(roots) {
|
|
|
14505
14768
|
}
|
|
14506
14769
|
return result;
|
|
14507
14770
|
}
|
|
14508
|
-
function buildTooltip(span,
|
|
14771
|
+
function buildTooltip(span, escapeHtml4) {
|
|
14509
14772
|
const parts = [];
|
|
14510
14773
|
parts.push(`${span.name} (${formatDuration(span.durationMs)})`);
|
|
14511
14774
|
if (span.statusMessage) {
|
|
@@ -14523,7 +14786,7 @@ function buildTooltip(span, escapeHtml3) {
|
|
|
14523
14786
|
if (text2.length > TOOLTIP_MAX_LENGTH) {
|
|
14524
14787
|
text2 = text2.slice(0, TOOLTIP_MAX_LENGTH - 3) + "...";
|
|
14525
14788
|
}
|
|
14526
|
-
return
|
|
14789
|
+
return escapeHtml4(text2);
|
|
14527
14790
|
}
|
|
14528
14791
|
function renderTraceView(args, deps) {
|
|
14529
14792
|
if (!args.spans || args.spans.length === 0) return "";
|
|
@@ -14746,11 +15009,11 @@ function renderToc(args, deps) {
|
|
|
14746
15009
|
const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
|
|
14747
15010
|
const featureSlug = `feature-${slugify(file)}`;
|
|
14748
15011
|
const scenarios = testCases.map((tc) => {
|
|
14749
|
-
const
|
|
15012
|
+
const statusIcon4 = deps.getStatusIcon(tc.status);
|
|
14750
15013
|
const statusClass = `status-${tc.status}`;
|
|
14751
15014
|
const failedClass = tc.status === "failed" ? " toc-failed" : "";
|
|
14752
15015
|
return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
|
|
14753
|
-
<span class="toc-status ${statusClass}">${
|
|
15016
|
+
<span class="toc-status ${statusClass}">${statusIcon4}</span>
|
|
14754
15017
|
${deps.escapeHtml(tc.story.scenario)}
|
|
14755
15018
|
</a>`;
|
|
14756
15019
|
}).join("\n");
|
|
@@ -19267,6 +19530,697 @@ function listScenarios(args, _deps) {
|
|
|
19267
19530
|
return lines.join("\n");
|
|
19268
19531
|
}
|
|
19269
19532
|
|
|
19533
|
+
// src/review/conventions.ts
|
|
19534
|
+
var CHANGE_TAG_PREFIX = "change:";
|
|
19535
|
+
var AUDIENCE_TAG_PREFIX = "audience:";
|
|
19536
|
+
var VALID_CHANGE_TYPES = /* @__PURE__ */ new Set([
|
|
19537
|
+
"feature",
|
|
19538
|
+
"bugfix",
|
|
19539
|
+
"refactor",
|
|
19540
|
+
"perf",
|
|
19541
|
+
"deps"
|
|
19542
|
+
]);
|
|
19543
|
+
var STAKEHOLDER_FILE = /(?:\.e2e\.)|(?:^|\/)e2e\/|(?:\.spec\.)/i;
|
|
19544
|
+
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
19545
|
+
"ts",
|
|
19546
|
+
"tsx",
|
|
19547
|
+
"js",
|
|
19548
|
+
"jsx",
|
|
19549
|
+
"mjs",
|
|
19550
|
+
"cjs",
|
|
19551
|
+
"py",
|
|
19552
|
+
"go",
|
|
19553
|
+
"rs",
|
|
19554
|
+
"kt",
|
|
19555
|
+
"kts",
|
|
19556
|
+
"java",
|
|
19557
|
+
"cs",
|
|
19558
|
+
"rb"
|
|
19559
|
+
]);
|
|
19560
|
+
var TEST_INFIX = /\.(?:story\.)?(?:int\.|e2e\.|unit\.)?(?:test|spec|cy)\.[a-z]+$/i;
|
|
19561
|
+
function deriveAudience(sourceFile, tags) {
|
|
19562
|
+
const override = tags.map((t) => t.toLowerCase()).find((t) => t.startsWith(AUDIENCE_TAG_PREFIX));
|
|
19563
|
+
if (override) {
|
|
19564
|
+
const value = override.slice(AUDIENCE_TAG_PREFIX.length);
|
|
19565
|
+
if (value === "stakeholder" || value === "engineer") return value;
|
|
19566
|
+
}
|
|
19567
|
+
return STAKEHOLDER_FILE.test(sourceFile) ? "stakeholder" : "engineer";
|
|
19568
|
+
}
|
|
19569
|
+
function deriveChangeType(tags) {
|
|
19570
|
+
for (const tag of tags) {
|
|
19571
|
+
const lower = tag.toLowerCase();
|
|
19572
|
+
if (lower.startsWith(CHANGE_TAG_PREFIX)) {
|
|
19573
|
+
const value = lower.slice(CHANGE_TAG_PREFIX.length);
|
|
19574
|
+
if (VALID_CHANGE_TYPES.has(value)) return value;
|
|
19575
|
+
}
|
|
19576
|
+
}
|
|
19577
|
+
return "unknown";
|
|
19578
|
+
}
|
|
19579
|
+
function extensionOf(path10) {
|
|
19580
|
+
const base = path10.split("/").pop() ?? path10;
|
|
19581
|
+
const dot = base.lastIndexOf(".");
|
|
19582
|
+
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
|
|
19583
|
+
}
|
|
19584
|
+
function isTestFile(path10) {
|
|
19585
|
+
return TEST_INFIX.test(path10);
|
|
19586
|
+
}
|
|
19587
|
+
function isReviewableSource(path10) {
|
|
19588
|
+
if (isTestFile(path10)) return false;
|
|
19589
|
+
if (path10.endsWith(".d.ts")) return false;
|
|
19590
|
+
return CODE_EXTENSIONS.has(extensionOf(path10));
|
|
19591
|
+
}
|
|
19592
|
+
function testBaseKey(testFile) {
|
|
19593
|
+
return testFile.replace(TEST_INFIX, "");
|
|
19594
|
+
}
|
|
19595
|
+
function sourceBaseKey(sourceFile) {
|
|
19596
|
+
const dot = sourceFile.lastIndexOf(".");
|
|
19597
|
+
const slash = sourceFile.lastIndexOf("/");
|
|
19598
|
+
return dot > slash ? sourceFile.slice(0, dot) : sourceFile;
|
|
19599
|
+
}
|
|
19600
|
+
|
|
19601
|
+
// src/review/build-review.ts
|
|
19602
|
+
var STRENGTH_RANK = {
|
|
19603
|
+
none: 0,
|
|
19604
|
+
weak: 1,
|
|
19605
|
+
moderate: 2,
|
|
19606
|
+
strong: 3
|
|
19607
|
+
};
|
|
19608
|
+
var INTENT_SECTION_TITLE = /\b(why|intent|approach|rationale|reasoning)\b/i;
|
|
19609
|
+
function findDoc(docs, predicate) {
|
|
19610
|
+
if (!docs) return void 0;
|
|
19611
|
+
for (const doc of docs) {
|
|
19612
|
+
if (predicate(doc)) return doc;
|
|
19613
|
+
const nested = findDoc(doc.children, predicate);
|
|
19614
|
+
if (nested) return nested;
|
|
19615
|
+
}
|
|
19616
|
+
return void 0;
|
|
19617
|
+
}
|
|
19618
|
+
function anyDoc(docs, predicate) {
|
|
19619
|
+
return findDoc(docs, predicate) !== void 0;
|
|
19620
|
+
}
|
|
19621
|
+
function extractIntent(testCase) {
|
|
19622
|
+
const docs = testCase.story.docs;
|
|
19623
|
+
const section = findDoc(
|
|
19624
|
+
docs,
|
|
19625
|
+
(d) => d.kind === "section" && INTENT_SECTION_TITLE.test(d.title)
|
|
19626
|
+
);
|
|
19627
|
+
if (section && section.kind === "section") return section.markdown;
|
|
19628
|
+
const note = findDoc(docs, (d) => d.kind === "note");
|
|
19629
|
+
if (note && note.kind === "note") return note.text;
|
|
19630
|
+
return void 0;
|
|
19631
|
+
}
|
|
19632
|
+
function hasScreenshot(testCase) {
|
|
19633
|
+
if (testCase.attachments.some((a) => a.mediaType.startsWith("image/"))) {
|
|
19634
|
+
return true;
|
|
19635
|
+
}
|
|
19636
|
+
if (anyDoc(testCase.story.docs, (d) => d.kind === "screenshot")) return true;
|
|
19637
|
+
return testCase.story.steps.some(
|
|
19638
|
+
(step) => anyDoc(step.docs, (d) => d.kind === "screenshot")
|
|
19639
|
+
);
|
|
19640
|
+
}
|
|
19641
|
+
function hasOtelTrace(testCase) {
|
|
19642
|
+
return (testCase.story.otelSpans?.length ?? 0) > 0;
|
|
19643
|
+
}
|
|
19644
|
+
function gradeEvidence(testCase, audience) {
|
|
19645
|
+
if (testCase.status !== "passed") {
|
|
19646
|
+
return {
|
|
19647
|
+
strength: "none",
|
|
19648
|
+
reasons: [`test is ${testCase.status} \u2014 the proof does not hold`]
|
|
19649
|
+
};
|
|
19650
|
+
}
|
|
19651
|
+
const ev = testCase.evidence;
|
|
19652
|
+
const screenshot = hasScreenshot(testCase);
|
|
19653
|
+
const otel = hasOtelTrace(testCase);
|
|
19654
|
+
const isIntegration = /\.int\.test\./i.test(testCase.sourceFile);
|
|
19655
|
+
const mutation = ev?.mutationScorePct;
|
|
19656
|
+
const changedCov = ev?.changedLineCoveragePct;
|
|
19657
|
+
const strong2 = [];
|
|
19658
|
+
if (ev?.failingFirstVerified) {
|
|
19659
|
+
strong2.push("failing-first verified (red on base ref, green on head)");
|
|
19660
|
+
}
|
|
19661
|
+
if (typeof mutation === "number" && mutation >= 80) {
|
|
19662
|
+
strong2.push(`mutation score ${mutation}% (\u226580%)`);
|
|
19663
|
+
}
|
|
19664
|
+
if (screenshot && otel) {
|
|
19665
|
+
strong2.push("backed by screenshot + OTEL trace");
|
|
19666
|
+
} else if (audience === "stakeholder" && (screenshot || otel)) {
|
|
19667
|
+
strong2.push(`stakeholder proof: ${screenshot ? "screenshot" : "OTEL trace"}`);
|
|
19668
|
+
}
|
|
19669
|
+
if (strong2.length > 0) return { strength: "strong", reasons: strong2 };
|
|
19670
|
+
const moderate = [];
|
|
19671
|
+
if (screenshot) moderate.push("screenshot attached");
|
|
19672
|
+
if (otel) moderate.push("OTEL trace attached");
|
|
19673
|
+
if (typeof mutation === "number" && mutation >= 50) {
|
|
19674
|
+
moderate.push(`mutation score ${mutation}%`);
|
|
19675
|
+
}
|
|
19676
|
+
if (typeof changedCov === "number" && changedCov >= 80) {
|
|
19677
|
+
moderate.push(`changed-line coverage ${changedCov}%`);
|
|
19678
|
+
}
|
|
19679
|
+
if (isIntegration) moderate.push("integration-level test");
|
|
19680
|
+
if (moderate.length > 0) return { strength: "moderate", reasons: moderate };
|
|
19681
|
+
return {
|
|
19682
|
+
strength: "weak",
|
|
19683
|
+
reasons: [
|
|
19684
|
+
"passing test only \u2014 no corroborating evidence (add e2e proof, mutation score, or failing-first)"
|
|
19685
|
+
]
|
|
19686
|
+
};
|
|
19687
|
+
}
|
|
19688
|
+
function toClaim(testCase, changedSourcePaths) {
|
|
19689
|
+
const audience = deriveAudience(testCase.sourceFile, testCase.tags);
|
|
19690
|
+
const changeType = deriveChangeType(testCase.tags);
|
|
19691
|
+
const { strength, reasons } = gradeEvidence(testCase, audience);
|
|
19692
|
+
const key = testBaseKey(testCase.sourceFile);
|
|
19693
|
+
const coversFiles = changedSourcePaths.filter(
|
|
19694
|
+
(path10) => sourceBaseKey(path10) === key
|
|
19695
|
+
);
|
|
19696
|
+
return {
|
|
19697
|
+
id: testCase.id,
|
|
19698
|
+
scenario: testCase.story.scenario,
|
|
19699
|
+
sourceFile: testCase.sourceFile,
|
|
19700
|
+
sourceLine: testCase.sourceLine,
|
|
19701
|
+
status: testCase.status,
|
|
19702
|
+
audience,
|
|
19703
|
+
changeType,
|
|
19704
|
+
strength,
|
|
19705
|
+
strengthReasons: reasons,
|
|
19706
|
+
intent: extractIntent(testCase),
|
|
19707
|
+
coversFiles,
|
|
19708
|
+
testCase
|
|
19709
|
+
};
|
|
19710
|
+
}
|
|
19711
|
+
function bandFor(claims) {
|
|
19712
|
+
if (claims.length === 0) return "uncovered";
|
|
19713
|
+
const maxRank = Math.max(...claims.map((c) => STRENGTH_RANK[c.strength]));
|
|
19714
|
+
return maxRank >= STRENGTH_RANK.moderate ? "covered" : "weak";
|
|
19715
|
+
}
|
|
19716
|
+
var AUDIENCE_ORDER = {
|
|
19717
|
+
stakeholder: 0,
|
|
19718
|
+
engineer: 1
|
|
19719
|
+
};
|
|
19720
|
+
function buildReview(run, context = { changedFiles: [] }) {
|
|
19721
|
+
const changedSource = context.changedFiles.filter(
|
|
19722
|
+
(f) => isReviewableSource(f.path)
|
|
19723
|
+
);
|
|
19724
|
+
const changedSourcePaths = changedSource.map((f) => f.path);
|
|
19725
|
+
const claims = run.testCases.map((tc) => toClaim(tc, changedSourcePaths));
|
|
19726
|
+
const changedFiles = changedSource.map((file) => {
|
|
19727
|
+
const covering = claims.filter((c) => c.coversFiles.includes(file.path));
|
|
19728
|
+
return {
|
|
19729
|
+
path: file.path,
|
|
19730
|
+
changeKind: file.changeKind,
|
|
19731
|
+
band: bandFor(covering),
|
|
19732
|
+
claims: covering.map((c) => ({
|
|
19733
|
+
id: c.id,
|
|
19734
|
+
scenario: c.scenario,
|
|
19735
|
+
strength: c.strength
|
|
19736
|
+
}))
|
|
19737
|
+
};
|
|
19738
|
+
});
|
|
19739
|
+
const sortedClaims = [...claims].sort((a, b) => {
|
|
19740
|
+
if (AUDIENCE_ORDER[a.audience] !== AUDIENCE_ORDER[b.audience]) {
|
|
19741
|
+
return AUDIENCE_ORDER[a.audience] - AUDIENCE_ORDER[b.audience];
|
|
19742
|
+
}
|
|
19743
|
+
if (STRENGTH_RANK[a.strength] !== STRENGTH_RANK[b.strength]) {
|
|
19744
|
+
return STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength];
|
|
19745
|
+
}
|
|
19746
|
+
if (a.sourceFile !== b.sourceFile) {
|
|
19747
|
+
return a.sourceFile.localeCompare(b.sourceFile);
|
|
19748
|
+
}
|
|
19749
|
+
return a.scenario.localeCompare(b.scenario);
|
|
19750
|
+
});
|
|
19751
|
+
const bandRank = { uncovered: 0, weak: 1, covered: 2 };
|
|
19752
|
+
const sortedFiles = [...changedFiles].sort((a, b) => {
|
|
19753
|
+
if (bandRank[a.band] !== bandRank[b.band]) {
|
|
19754
|
+
return bandRank[a.band] - bandRank[b.band];
|
|
19755
|
+
}
|
|
19756
|
+
return a.path.localeCompare(b.path);
|
|
19757
|
+
});
|
|
19758
|
+
const summary = buildSummary2(sortedClaims, sortedFiles);
|
|
19759
|
+
return {
|
|
19760
|
+
run,
|
|
19761
|
+
context,
|
|
19762
|
+
summary,
|
|
19763
|
+
claims: sortedClaims,
|
|
19764
|
+
changedFiles: sortedFiles
|
|
19765
|
+
};
|
|
19766
|
+
}
|
|
19767
|
+
function buildSummary2(claims, changedFiles) {
|
|
19768
|
+
const byAudience = {
|
|
19769
|
+
stakeholder: 0,
|
|
19770
|
+
engineer: 0
|
|
19771
|
+
};
|
|
19772
|
+
const byStrength = {
|
|
19773
|
+
none: 0,
|
|
19774
|
+
weak: 0,
|
|
19775
|
+
moderate: 0,
|
|
19776
|
+
strong: 0
|
|
19777
|
+
};
|
|
19778
|
+
for (const claim of claims) {
|
|
19779
|
+
byAudience[claim.audience] += 1;
|
|
19780
|
+
byStrength[claim.strength] += 1;
|
|
19781
|
+
}
|
|
19782
|
+
return {
|
|
19783
|
+
totalClaims: claims.length,
|
|
19784
|
+
byAudience,
|
|
19785
|
+
byStrength,
|
|
19786
|
+
changedSourceFiles: changedFiles.length,
|
|
19787
|
+
uncovered: changedFiles.filter((f) => f.band === "uncovered").length,
|
|
19788
|
+
weaklyCovered: changedFiles.filter((f) => f.band === "weak").length,
|
|
19789
|
+
covered: changedFiles.filter((f) => f.band === "covered").length
|
|
19790
|
+
};
|
|
19791
|
+
}
|
|
19792
|
+
|
|
19793
|
+
// src/formatters/review-markdown.ts
|
|
19794
|
+
var STRENGTH_BADGE = {
|
|
19795
|
+
strong: "\u{1F7E2} strong",
|
|
19796
|
+
moderate: "\u{1F7E1} moderate",
|
|
19797
|
+
weak: "\u{1F7E0} weak",
|
|
19798
|
+
none: "\u{1F534} none"
|
|
19799
|
+
};
|
|
19800
|
+
function statusIcon2(status) {
|
|
19801
|
+
switch (status) {
|
|
19802
|
+
case "passed":
|
|
19803
|
+
return "\u2705";
|
|
19804
|
+
case "failed":
|
|
19805
|
+
return "\u274C";
|
|
19806
|
+
case "skipped":
|
|
19807
|
+
return "\u2298";
|
|
19808
|
+
default:
|
|
19809
|
+
return "\u2022";
|
|
19810
|
+
}
|
|
19811
|
+
}
|
|
19812
|
+
function escapeCell2(value) {
|
|
19813
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
19814
|
+
}
|
|
19815
|
+
function intentSummary(intent) {
|
|
19816
|
+
const firstLine = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
19817
|
+
const trimmed = firstLine.trim();
|
|
19818
|
+
return trimmed.length > 200 ? `${trimmed.slice(0, 197)}\u2026` : trimmed;
|
|
19819
|
+
}
|
|
19820
|
+
function renderTicket2(ticket) {
|
|
19821
|
+
return ticket.url ? `[${ticket.id}](${ticket.url})` : `\`${ticket.id}\``;
|
|
19822
|
+
}
|
|
19823
|
+
function renderUncoveredBand(lines, files) {
|
|
19824
|
+
const uncovered = files.filter((f) => f.band === "uncovered");
|
|
19825
|
+
if (uncovered.length === 0) return;
|
|
19826
|
+
lines.push(`## \u{1F534} Changed code with no evidence (${uncovered.length})`);
|
|
19827
|
+
lines.push("");
|
|
19828
|
+
lines.push("Start here \u2014 these changed source files have no claim or test behind them.");
|
|
19829
|
+
lines.push("");
|
|
19830
|
+
for (const file of uncovered) {
|
|
19831
|
+
lines.push(`- \`${file.path}\` _(${file.changeKind})_`);
|
|
19832
|
+
}
|
|
19833
|
+
lines.push("");
|
|
19834
|
+
}
|
|
19835
|
+
function renderWeakBand(lines, files) {
|
|
19836
|
+
const weak = files.filter((f) => f.band === "weak");
|
|
19837
|
+
if (weak.length === 0) return;
|
|
19838
|
+
lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
|
|
19839
|
+
lines.push("");
|
|
19840
|
+
for (const file of weak) {
|
|
19841
|
+
const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
|
|
19842
|
+
lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
|
|
19843
|
+
}
|
|
19844
|
+
lines.push("");
|
|
19845
|
+
}
|
|
19846
|
+
function renderClaim(lines, claim) {
|
|
19847
|
+
lines.push(`### ${statusIcon2(claim.status)} ${claim.scenario}`);
|
|
19848
|
+
lines.push("");
|
|
19849
|
+
lines.push(`- File: \`${claim.sourceFile}:${claim.sourceLine}\``);
|
|
19850
|
+
if (claim.changeType !== "unknown") {
|
|
19851
|
+
lines.push(`- Change: \`${claim.changeType}\``);
|
|
19852
|
+
}
|
|
19853
|
+
const tickets = claim.testCase.story.tickets ?? [];
|
|
19854
|
+
if (tickets.length > 0) {
|
|
19855
|
+
lines.push(`- Tickets: ${tickets.map(renderTicket2).join(", ")}`);
|
|
19856
|
+
}
|
|
19857
|
+
lines.push(
|
|
19858
|
+
`- Evidence: ${STRENGTH_BADGE[claim.strength]} \u2014 ${claim.strengthReasons.join("; ")}`
|
|
19859
|
+
);
|
|
19860
|
+
if (claim.coversFiles.length > 0) {
|
|
19861
|
+
lines.push(
|
|
19862
|
+
`- Covers: ${claim.coversFiles.map((f) => `\`${f}\``).join(", ")}`
|
|
19863
|
+
);
|
|
19864
|
+
}
|
|
19865
|
+
if (claim.intent) {
|
|
19866
|
+
lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
|
|
19867
|
+
}
|
|
19868
|
+
lines.push("");
|
|
19869
|
+
}
|
|
19870
|
+
function renderAudienceSection(lines, title, claims) {
|
|
19871
|
+
if (claims.length === 0) return;
|
|
19872
|
+
lines.push(`## ${title} (${claims.length})`);
|
|
19873
|
+
lines.push("");
|
|
19874
|
+
for (const claim of claims) {
|
|
19875
|
+
renderClaim(lines, claim);
|
|
19876
|
+
}
|
|
19877
|
+
}
|
|
19878
|
+
var ReviewMarkdownFormatter = class {
|
|
19879
|
+
title;
|
|
19880
|
+
constructor(options = {}) {
|
|
19881
|
+
this.title = options.title ?? "Evidence Review";
|
|
19882
|
+
}
|
|
19883
|
+
format(review) {
|
|
19884
|
+
const lines = [];
|
|
19885
|
+
const { summary, context } = review;
|
|
19886
|
+
lines.push(`# ${this.title}`);
|
|
19887
|
+
lines.push("");
|
|
19888
|
+
if (context.baseRef || context.headRef) {
|
|
19889
|
+
lines.push(
|
|
19890
|
+
`Comparing \`${context.baseRef ?? "base"}\` \u2192 \`${context.headRef ?? "head"}\`.`
|
|
19891
|
+
);
|
|
19892
|
+
lines.push("");
|
|
19893
|
+
}
|
|
19894
|
+
lines.push("## Review priority");
|
|
19895
|
+
lines.push("");
|
|
19896
|
+
if (summary.changedSourceFiles === 0) {
|
|
19897
|
+
lines.push(
|
|
19898
|
+
"No changed source files supplied \u2014 showing claims and evidence only."
|
|
19899
|
+
);
|
|
19900
|
+
} else if (summary.uncovered > 0) {
|
|
19901
|
+
lines.push(
|
|
19902
|
+
`Review the ${summary.uncovered} unaccounted-for file(s) first: changed code with no evidence behind it.`
|
|
19903
|
+
);
|
|
19904
|
+
} else if (summary.weaklyCovered > 0) {
|
|
19905
|
+
lines.push(
|
|
19906
|
+
`No unaccounted-for changes. Review ${summary.weaklyCovered} weakly-covered file(s) next.`
|
|
19907
|
+
);
|
|
19908
|
+
} else {
|
|
19909
|
+
lines.push("Every changed source file is backed by at least moderate evidence.");
|
|
19910
|
+
}
|
|
19911
|
+
lines.push("");
|
|
19912
|
+
if (summary.changedSourceFiles > 0) {
|
|
19913
|
+
lines.push("| \u{1F534} Uncovered | \u{1F7E1} Weak | \u{1F7E2} Covered | Changed files |");
|
|
19914
|
+
lines.push("| ---: | ---: | ---: | ---: |");
|
|
19915
|
+
lines.push(
|
|
19916
|
+
`| ${summary.uncovered} | ${summary.weaklyCovered} | ${summary.covered} | ${summary.changedSourceFiles} |`
|
|
19917
|
+
);
|
|
19918
|
+
lines.push("");
|
|
19919
|
+
}
|
|
19920
|
+
lines.push("| Claims | Stakeholder | Engineer | Strong | Moderate | Weak | None |");
|
|
19921
|
+
lines.push("| ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
|
|
19922
|
+
lines.push(
|
|
19923
|
+
`| ${summary.totalClaims} | ${summary.byAudience.stakeholder} | ${summary.byAudience.engineer} | ${summary.byStrength.strong} | ${summary.byStrength.moderate} | ${summary.byStrength.weak} | ${summary.byStrength.none} |`
|
|
19924
|
+
);
|
|
19925
|
+
lines.push("");
|
|
19926
|
+
renderUncoveredBand(lines, review.changedFiles);
|
|
19927
|
+
renderWeakBand(lines, review.changedFiles);
|
|
19928
|
+
renderAudienceSection(
|
|
19929
|
+
lines,
|
|
19930
|
+
"Stakeholder behaviour",
|
|
19931
|
+
review.claims.filter((c) => c.audience === "stakeholder")
|
|
19932
|
+
);
|
|
19933
|
+
renderAudienceSection(
|
|
19934
|
+
lines,
|
|
19935
|
+
"Engineer changes",
|
|
19936
|
+
review.claims.filter((c) => c.audience === "engineer")
|
|
19937
|
+
);
|
|
19938
|
+
return lines.join("\n").trimEnd();
|
|
19939
|
+
}
|
|
19940
|
+
};
|
|
19941
|
+
|
|
19942
|
+
// src/formatters/review-html.ts
|
|
19943
|
+
function escapeHtml3(value) {
|
|
19944
|
+
return value.replace(/&/g, "&").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
|
+
|
|
19270
20224
|
// src/index.ts
|
|
19271
20225
|
var FORMAT_EXTENSIONS = {
|
|
19272
20226
|
astro: ".md",
|
|
@@ -19388,7 +20342,7 @@ var ReportGenerator = class {
|
|
|
19388
20342
|
exclude: options.exclude ?? [],
|
|
19389
20343
|
includeTags: options.includeTags ?? [],
|
|
19390
20344
|
excludeTags: options.excludeTags ?? [],
|
|
19391
|
-
formats: options.formats ?? ["
|
|
20345
|
+
formats: options.formats ?? ["html"],
|
|
19392
20346
|
outputDir: options.outputDir ?? "reports",
|
|
19393
20347
|
outputName: options.outputName ?? "index",
|
|
19394
20348
|
outputNameTimestamp: options.outputNameTimestamp ?? false,
|
|
@@ -19762,6 +20716,7 @@ var EXIT_CANONICAL_VALIDATION = 2;
|
|
|
19762
20716
|
var EXIT_GENERATION = 3;
|
|
19763
20717
|
var EXIT_USAGE = 4;
|
|
19764
20718
|
var EXIT_COMPARE_GATE = 5;
|
|
20719
|
+
var EXIT_REVIEW_GATE = 5;
|
|
19765
20720
|
var HELP_TEXT = `
|
|
19766
20721
|
executable-stories \u2014 Generate reports from test results JSON.
|
|
19767
20722
|
|
|
@@ -19769,6 +20724,7 @@ USAGE
|
|
|
19769
20724
|
executable-stories format <file> [options]
|
|
19770
20725
|
executable-stories format --stdin [options]
|
|
19771
20726
|
executable-stories compare <baseline-file> <current-file> [options]
|
|
20727
|
+
executable-stories review <file> --changed-files <path> [options]
|
|
19772
20728
|
executable-stories list <file> [options]
|
|
19773
20729
|
executable-stories validate <file>
|
|
19774
20730
|
executable-stories validate --stdin
|
|
@@ -19779,6 +20735,7 @@ USAGE
|
|
|
19779
20735
|
SUBCOMMANDS
|
|
19780
20736
|
format Read raw test results and generate reports
|
|
19781
20737
|
compare Compare two runs and generate a diff report
|
|
20738
|
+
review Generate an Evidence Review of AI-authored changes (correlate a run to the diff)
|
|
19782
20739
|
list List scenarios from a test run (text table or JSON)
|
|
19783
20740
|
validate Validate a JSON file against the schema (no output generated)
|
|
19784
20741
|
init-astro Scaffold an Astro docs site for story output (Starlight with themed CSS)
|
|
@@ -19829,6 +20786,11 @@ OPTIONS
|
|
|
19829
20786
|
--fail-on-regression Exit non-zero when any regression is detected in compare
|
|
19830
20787
|
--fail-on-added-failures Exit non-zero when newly added scenarios are failing
|
|
19831
20788
|
--max-regressions <n> Exit non-zero when regressions exceed threshold
|
|
20789
|
+
--changed-files <path> (review) Changed files: JSON (ChangedFile[] or {changedFiles,baseRef,headRef}) or "git diff --name-status" text
|
|
20790
|
+
--base-ref <ref> (review) Base ref label shown in the report (informational)
|
|
20791
|
+
--head-ref <ref> (review) Head ref label shown in the report (informational)
|
|
20792
|
+
--fail-on <band> (review) Gate: "uncovered" or "weak" \u2014 exit non-zero when changed code lacks evidence (default: off)
|
|
20793
|
+
--min-evidence <strength> (review) Gate: "weak"|"moderate"|"strong" \u2014 exit non-zero when any claim is below this strength (default: off)
|
|
19832
20794
|
--emit-canonical <path> Write canonical JSON to given path
|
|
19833
20795
|
--help Show this help message
|
|
19834
20796
|
|
|
@@ -19901,9 +20863,9 @@ async function parseCliArgs(argv) {
|
|
|
19901
20863
|
process.exit(EXIT_SUCCESS);
|
|
19902
20864
|
}
|
|
19903
20865
|
const subcommand = args[0];
|
|
19904
|
-
if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
|
|
20866
|
+
if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "review" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
|
|
19905
20867
|
console.error(
|
|
19906
|
-
`Unknown subcommand: "${subcommand}". Use "format", "compare", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
|
|
20868
|
+
`Unknown subcommand: "${subcommand}". Use "format", "compare", "review", "list", "validate", "init-astro", "publish-confluence", or "publish-jira".`
|
|
19907
20869
|
);
|
|
19908
20870
|
process.exit(EXIT_USAGE);
|
|
19909
20871
|
}
|
|
@@ -19997,6 +20959,11 @@ async function parseCliArgs(argv) {
|
|
|
19997
20959
|
"fail-on-regression": { type: "boolean", default: false },
|
|
19998
20960
|
"fail-on-added-failures": { type: "boolean", default: false },
|
|
19999
20961
|
"max-regressions": { type: "string" },
|
|
20962
|
+
"changed-files": { type: "string" },
|
|
20963
|
+
"base-ref": { type: "string" },
|
|
20964
|
+
"head-ref": { type: "string" },
|
|
20965
|
+
"fail-on": { type: "string" },
|
|
20966
|
+
"min-evidence": { type: "string" },
|
|
20000
20967
|
"config": { type: "string" },
|
|
20001
20968
|
help: { type: "boolean", default: false }
|
|
20002
20969
|
},
|
|
@@ -20122,6 +21089,17 @@ async function parseCliArgs(argv) {
|
|
|
20122
21089
|
console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
|
|
20123
21090
|
process.exit(EXIT_USAGE);
|
|
20124
21091
|
}
|
|
21092
|
+
const failOnRaw = values["fail-on"];
|
|
21093
|
+
if (failOnRaw !== void 0 && failOnRaw !== "uncovered" && failOnRaw !== "weak") {
|
|
21094
|
+
console.error(`Error: --fail-on must be "uncovered" or "weak", got "${failOnRaw}".`);
|
|
21095
|
+
process.exit(EXIT_USAGE);
|
|
21096
|
+
}
|
|
21097
|
+
const minEvidenceRaw = values["min-evidence"];
|
|
21098
|
+
const validMinEvidence = /* @__PURE__ */ new Set(["weak", "moderate", "strong"]);
|
|
21099
|
+
if (minEvidenceRaw !== void 0 && !validMinEvidence.has(minEvidenceRaw)) {
|
|
21100
|
+
console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
|
|
21101
|
+
process.exit(EXIT_USAGE);
|
|
21102
|
+
}
|
|
20125
21103
|
const cliArgs = {
|
|
20126
21104
|
subcommand,
|
|
20127
21105
|
inputFile,
|
|
@@ -20173,6 +21151,11 @@ async function parseCliArgs(argv) {
|
|
|
20173
21151
|
failOnRegression: values["fail-on-regression"],
|
|
20174
21152
|
failOnAddedFailures: values["fail-on-added-failures"],
|
|
20175
21153
|
maxRegressions,
|
|
21154
|
+
changedFilesPath: values["changed-files"],
|
|
21155
|
+
baseRef: values["base-ref"],
|
|
21156
|
+
headRef: values["head-ref"],
|
|
21157
|
+
failOn: failOnRaw,
|
|
21158
|
+
minEvidence: minEvidenceRaw,
|
|
20176
21159
|
config: values["config"]
|
|
20177
21160
|
};
|
|
20178
21161
|
return { args: cliArgs, pluginConfig, customRequested };
|
|
@@ -20386,6 +21369,30 @@ async function main() {
|
|
|
20386
21369
|
process.exit(EXIT_GENERATION);
|
|
20387
21370
|
}
|
|
20388
21371
|
}
|
|
21372
|
+
if (args.subcommand === "review") {
|
|
21373
|
+
const text3 = await readInput(args);
|
|
21374
|
+
const run = applySelection(normalizeRunFromText(text3, args).run, args);
|
|
21375
|
+
const context = loadReviewContext(args);
|
|
21376
|
+
const review = buildReview(run, context);
|
|
21377
|
+
try {
|
|
21378
|
+
const files = writeReviewReport(review, args);
|
|
21379
|
+
for (const f of files) {
|
|
21380
|
+
console.log(f);
|
|
21381
|
+
}
|
|
21382
|
+
const gateFailures = evaluateReviewGate(review, args);
|
|
21383
|
+
if (gateFailures.length > 0) {
|
|
21384
|
+
for (const failure of gateFailures) {
|
|
21385
|
+
console.error(`Review gate failed: ${failure}`);
|
|
21386
|
+
}
|
|
21387
|
+
process.exit(EXIT_REVIEW_GATE);
|
|
21388
|
+
}
|
|
21389
|
+
process.exit(EXIT_SUCCESS);
|
|
21390
|
+
} catch (err) {
|
|
21391
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
21392
|
+
console.error(`Review failed: ${msg}`);
|
|
21393
|
+
process.exit(EXIT_GENERATION);
|
|
21394
|
+
}
|
|
21395
|
+
}
|
|
20389
21396
|
if (args.subcommand === "list") {
|
|
20390
21397
|
const text3 = await readInput(args);
|
|
20391
21398
|
const run = applySelection(normalizeRunFromText(text3, args).run, args);
|
|
@@ -20744,6 +21751,102 @@ async function generateCompareReports(baseline, current, baselineFile, args) {
|
|
|
20744
21751
|
prSummary: args.prSummary || args.prSummaryFile ? createPrCommentSummary(result.diff) : void 0
|
|
20745
21752
|
};
|
|
20746
21753
|
}
|
|
21754
|
+
var STRENGTH_RANK2 = {
|
|
21755
|
+
none: 0,
|
|
21756
|
+
weak: 1,
|
|
21757
|
+
moderate: 2,
|
|
21758
|
+
strong: 3
|
|
21759
|
+
};
|
|
21760
|
+
function mapStatus(status) {
|
|
21761
|
+
const letter = status.charAt(0).toUpperCase();
|
|
21762
|
+
if (letter === "A") return "added";
|
|
21763
|
+
if (letter === "D") return "deleted";
|
|
21764
|
+
if (letter === "R") return "renamed";
|
|
21765
|
+
if (letter === "C") return "added";
|
|
21766
|
+
return "modified";
|
|
21767
|
+
}
|
|
21768
|
+
function parseNameStatus(text2) {
|
|
21769
|
+
const files = [];
|
|
21770
|
+
for (const raw of text2.split("\n")) {
|
|
21771
|
+
const line = raw.trim();
|
|
21772
|
+
if (!line) continue;
|
|
21773
|
+
const cols = line.includes(" ") ? line.split(" ") : line.split(/\s+/);
|
|
21774
|
+
const status = cols[0];
|
|
21775
|
+
if (!status) continue;
|
|
21776
|
+
const filePath = /^[RC]/i.test(status) && cols.length >= 3 ? cols[cols.length - 1] : cols[1];
|
|
21777
|
+
if (!filePath) continue;
|
|
21778
|
+
files.push({ path: filePath, changeKind: mapStatus(status) });
|
|
21779
|
+
}
|
|
21780
|
+
return files;
|
|
21781
|
+
}
|
|
21782
|
+
var VALID_CHANGE_KINDS = /* @__PURE__ */ new Set(["added", "modified", "deleted", "renamed"]);
|
|
21783
|
+
function coerceChangedFile(value) {
|
|
21784
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
21785
|
+
const obj = value;
|
|
21786
|
+
if (typeof obj.path !== "string") return void 0;
|
|
21787
|
+
const kind = typeof obj.changeKind === "string" && VALID_CHANGE_KINDS.has(obj.changeKind) ? obj.changeKind : "modified";
|
|
21788
|
+
const changedLines = Array.isArray(obj.changedLines) ? obj.changedLines.filter((n) => typeof n === "number") : void 0;
|
|
21789
|
+
return changedLines ? { path: obj.path, changeKind: kind, changedLines } : { path: obj.path, changeKind: kind };
|
|
21790
|
+
}
|
|
21791
|
+
function loadReviewContext(args) {
|
|
21792
|
+
let changedFiles = [];
|
|
21793
|
+
let baseRef = args.baseRef;
|
|
21794
|
+
let headRef = args.headRef;
|
|
21795
|
+
if (args.changedFilesPath) {
|
|
21796
|
+
const text2 = readFileInput(args.changedFilesPath);
|
|
21797
|
+
const parsed = tryParseJson(text2);
|
|
21798
|
+
if (Array.isArray(parsed)) {
|
|
21799
|
+
changedFiles = parsed.map(coerceChangedFile).filter((f) => f !== void 0);
|
|
21800
|
+
} else if (parsed && typeof parsed === "object") {
|
|
21801
|
+
const obj = parsed;
|
|
21802
|
+
if (Array.isArray(obj.changedFiles)) {
|
|
21803
|
+
changedFiles = obj.changedFiles.map(coerceChangedFile).filter((f) => f !== void 0);
|
|
21804
|
+
}
|
|
21805
|
+
if (typeof obj.baseRef === "string") baseRef = baseRef ?? obj.baseRef;
|
|
21806
|
+
if (typeof obj.headRef === "string") headRef = headRef ?? obj.headRef;
|
|
21807
|
+
} else {
|
|
21808
|
+
changedFiles = parseNameStatus(text2);
|
|
21809
|
+
}
|
|
21810
|
+
}
|
|
21811
|
+
return { changedFiles, baseRef, headRef };
|
|
21812
|
+
}
|
|
21813
|
+
function writeReviewReport(review, args) {
|
|
21814
|
+
const title = args.htmlTitle && args.htmlTitle !== "Test Results" ? args.htmlTitle : void 0;
|
|
21815
|
+
const titleOpt = title ? { title } : {};
|
|
21816
|
+
const markdown = new ReviewMarkdownFormatter(titleOpt).format(review);
|
|
21817
|
+
const html = new ReviewHtmlFormatter({ ...titleOpt, theme: args.htmlTheme }).format(review);
|
|
21818
|
+
const outputDir = args.outputDir ?? "reports";
|
|
21819
|
+
const baseName = args.outputName ?? "evidence-review";
|
|
21820
|
+
const suffix = args.outputNameTimestamp ? `-${Math.floor(review.run.startedAtMs / 1e3)}` : "";
|
|
21821
|
+
fs8.mkdirSync(outputDir, { recursive: true });
|
|
21822
|
+
const mdPath = path9.join(outputDir, `${baseName}${suffix}.md`);
|
|
21823
|
+
const htmlPath = path9.join(outputDir, `${baseName}${suffix}.html`);
|
|
21824
|
+
fs8.writeFileSync(mdPath, markdown, "utf8");
|
|
21825
|
+
fs8.writeFileSync(htmlPath, html, "utf8");
|
|
21826
|
+
return [mdPath, htmlPath];
|
|
21827
|
+
}
|
|
21828
|
+
function evaluateReviewGate(review, args) {
|
|
21829
|
+
const failures = [];
|
|
21830
|
+
const { summary } = review;
|
|
21831
|
+
if (args.failOn === "uncovered" && summary.uncovered > 0) {
|
|
21832
|
+
failures.push(`${summary.uncovered} changed source file(s) have no evidence`);
|
|
21833
|
+
}
|
|
21834
|
+
if (args.failOn === "weak" && summary.uncovered + summary.weaklyCovered > 0) {
|
|
21835
|
+
failures.push(
|
|
21836
|
+
`${summary.uncovered + summary.weaklyCovered} changed source file(s) lack moderate+ evidence`
|
|
21837
|
+
);
|
|
21838
|
+
}
|
|
21839
|
+
if (args.minEvidence) {
|
|
21840
|
+
const threshold = STRENGTH_RANK2[args.minEvidence];
|
|
21841
|
+
const below = review.claims.filter((c) => STRENGTH_RANK2[c.strength] < threshold);
|
|
21842
|
+
if (below.length > 0) {
|
|
21843
|
+
failures.push(
|
|
21844
|
+
`${below.length} claim(s) below "${args.minEvidence}" evidence strength`
|
|
21845
|
+
);
|
|
21846
|
+
}
|
|
21847
|
+
}
|
|
21848
|
+
return failures;
|
|
21849
|
+
}
|
|
20747
21850
|
function printResult(result, args, startMs, droppedMissingStory = 0) {
|
|
20748
21851
|
const durationMs = Date.now() - startMs;
|
|
20749
21852
|
if (args.jsonSummary) {
|