executable-stories-formatters 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -148,8 +148,8 @@ import { formatDuration } from "executable-stories-core/utils/duration";
148
148
 
149
149
  // src/scenario-failure.ts
150
150
  function failingScenarioMessage(tc) {
151
- const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
152
- return failingStep?.errorMessage ?? tc.errorMessage;
151
+ const failingStep2 = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
152
+ return failingStep2?.errorMessage ?? tc.errorMessage;
153
153
  }
154
154
 
155
155
  // src/check.ts
@@ -257,8 +257,8 @@ function renderCheckText(report) {
257
257
  lines.push(`${marker}${step.keyword} ${step.text}`);
258
258
  }
259
259
  if (f.errorMessage) {
260
- const firstLine2 = f.errorMessage.split("\n")[0];
261
- lines.push(` \u2192 ${firstLine2}`);
260
+ const firstLine3 = f.errorMessage.split("\n")[0];
261
+ lines.push(` \u2192 ${firstLine3}`);
262
262
  }
263
263
  if (f.covers.length > 0) {
264
264
  lines.push(` covers: ${f.covers.join(", ")}`);
@@ -2200,8 +2200,8 @@ function escapeCell(value) {
2200
2200
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2201
2201
  }
2202
2202
  function intentSummary(intent) {
2203
- const firstLine2 = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
2204
- const trimmed = firstLine2.trim();
2203
+ const firstLine3 = intent.split("\n").find((l) => l.trim().length > 0) ?? "";
2204
+ const trimmed = firstLine3.trim();
2205
2205
  return trimmed.length > 200 ? `${trimmed.slice(0, 197)}\u2026` : trimmed;
2206
2206
  }
2207
2207
  function renderTicket(ticket) {
@@ -2396,6 +2396,138 @@ var ReviewMarkdownFormatter = class {
2396
2396
  }
2397
2397
  };
2398
2398
 
2399
+ // src/formatters/review-json.ts
2400
+ var SEVERITY_RANK = {
2401
+ blocker: 0,
2402
+ major: 1,
2403
+ minor: 2
2404
+ };
2405
+ function failingStep(testCase) {
2406
+ const failed = testCase.stepResults.find((s) => s.status === "failed");
2407
+ if (!failed) return void 0;
2408
+ const step = testCase.story.steps?.[failed.index];
2409
+ return step ? `${step.keyword} ${step.text}` : void 0;
2410
+ }
2411
+ function firstLine(message) {
2412
+ const line = message.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
2413
+ return line.length > 300 ? `${line.slice(0, 297)}\u2026` : line;
2414
+ }
2415
+ function failedFinding(claim) {
2416
+ const testCase = claim.testCase;
2417
+ const evidence = [`the scenario is ${claim.status}`];
2418
+ const step = failingStep(testCase);
2419
+ if (step) evidence.push(`failed at: ${step}`);
2420
+ if (testCase.errorMessage) evidence.push(firstLine(testCase.errorMessage));
2421
+ return {
2422
+ kind: "failed",
2423
+ severity: "blocker",
2424
+ title: `Unproven claim: ${claim.scenario}`,
2425
+ file: claim.sourceFile,
2426
+ line: claim.sourceLine,
2427
+ detail: "This scenario states a claim about the change and does not pass, so the claim is unproven.",
2428
+ evidence,
2429
+ remedy: "Fix the behaviour so the scenario passes, or correct the scenario if it states the wrong claim."
2430
+ };
2431
+ }
2432
+ function skippedFinding(claim) {
2433
+ return {
2434
+ kind: "skipped",
2435
+ severity: "minor",
2436
+ title: `Claim not exercised: ${claim.scenario}`,
2437
+ file: claim.sourceFile,
2438
+ line: claim.sourceLine,
2439
+ detail: `This scenario is ${claim.status}, so it did not run and its claim about the change is unproven.`,
2440
+ evidence: claim.strengthReasons,
2441
+ remedy: "Run the scenario, or delete it if the claim no longer applies \u2014 a permanently skipped claim is worse than no claim."
2442
+ };
2443
+ }
2444
+ function unassertedFinding(claim) {
2445
+ return {
2446
+ kind: "unasserted",
2447
+ severity: "major",
2448
+ title: `Green but proves nothing: ${claim.scenario}`,
2449
+ file: claim.sourceFile,
2450
+ line: claim.sourceLine,
2451
+ detail: "This scenario passed without asserting anything, so it cannot fail and proves nothing about the change.",
2452
+ evidence: claim.strengthReasons,
2453
+ remedy: "Assert the outcome the scenario claims, so that breaking the behaviour turns it red."
2454
+ };
2455
+ }
2456
+ function uncoveredFinding(file) {
2457
+ return {
2458
+ kind: "uncovered",
2459
+ severity: "major",
2460
+ title: "Changed with no evidence",
2461
+ file: file.path,
2462
+ detail: `This file was ${file.changeKind} in the diff and no scenario in the run claims anything about it.`,
2463
+ evidence: ["no claim in this run correlates to this file"],
2464
+ remedy: "Add a scenario covering the behaviour this file changed, or say in the PR why it needs none."
2465
+ };
2466
+ }
2467
+ function weakFinding(file) {
2468
+ return {
2469
+ kind: "weak",
2470
+ severity: "minor",
2471
+ title: "Weak evidence only",
2472
+ file: file.path,
2473
+ detail: `This file was ${file.changeKind} in the diff and its only claims are weakly evidenced.`,
2474
+ evidence: file.claims.map((c) => `${c.scenario} (${c.strength})`),
2475
+ remedy: "Strengthen the proof: verify the test fails on the base ref, add integration or e2e coverage, or attach a screenshot or trace."
2476
+ };
2477
+ }
2478
+ function reviewFindings(review) {
2479
+ const findings = [];
2480
+ for (const claim of review.claims) {
2481
+ if (claim.status === "failed") {
2482
+ findings.push(failedFinding(claim));
2483
+ } else if (claim.status === "skipped" || claim.status === "pending") {
2484
+ findings.push(skippedFinding(claim));
2485
+ } else if (claim.strength === "none") {
2486
+ findings.push(unassertedFinding(claim));
2487
+ }
2488
+ }
2489
+ for (const file of review.changedFiles) {
2490
+ if (file.band === "uncovered") findings.push(uncoveredFinding(file));
2491
+ else if (file.band === "weak") findings.push(weakFinding(file));
2492
+ }
2493
+ return findings.sort(
2494
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
2495
+ );
2496
+ }
2497
+ function toJsonClaim(claim) {
2498
+ return {
2499
+ id: claim.id,
2500
+ scenario: claim.scenario,
2501
+ sourceFile: claim.sourceFile,
2502
+ sourceLine: claim.sourceLine,
2503
+ status: claim.status,
2504
+ audience: claim.audience,
2505
+ changeType: claim.changeType,
2506
+ strength: claim.strength,
2507
+ strengthReasons: claim.strengthReasons,
2508
+ coversFiles: claim.coversFiles
2509
+ };
2510
+ }
2511
+ function buildReviewJson(review) {
2512
+ const run = { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
2513
+ for (const testCase of review.run.testCases) {
2514
+ run.total++;
2515
+ if (testCase.status === "passed" || testCase.status === "failed" || testCase.status === "skipped" || testCase.status === "pending") {
2516
+ run[testCase.status]++;
2517
+ }
2518
+ }
2519
+ return {
2520
+ version: 1,
2521
+ baseRef: review.context.baseRef,
2522
+ headRef: review.context.headRef,
2523
+ summary: review.summary,
2524
+ run,
2525
+ findings: reviewFindings(review),
2526
+ changedFiles: review.changedFiles,
2527
+ claims: review.claims.map(toJsonClaim)
2528
+ };
2529
+ }
2530
+
2399
2531
  // src/goal.ts
2400
2532
  var ACTIVE = ["passed", "failed"];
2401
2533
  function buildGoal(args, _deps = {}) {
@@ -3045,14 +3177,14 @@ function scenarioLine(diff) {
3045
3177
  case "moved":
3046
3178
  return `- **${title}** moved from \`${diff.baseline?.sourceFile ?? "?"}\` to ${source}`;
3047
3179
  case "regressed":
3048
- return `- **${title}** (${source})${diff.current?.errorMessage ? ` \u2014 ${firstLine(diff.current.errorMessage)}` : ""}`;
3180
+ return `- **${title}** (${source})${diff.current?.errorMessage ? ` \u2014 ${firstLine2(diff.current.errorMessage)}` : ""}`;
3049
3181
  case "changed":
3050
3182
  return `- **${title}** (${source}) \u2014 ${diff.changedFields.join(", ")}`;
3051
3183
  default:
3052
3184
  return `- **${title}** (${source})`;
3053
3185
  }
3054
3186
  }
3055
- function firstLine(text2) {
3187
+ function firstLine2(text2) {
3056
3188
  return text2.split("\n", 1)[0].trim();
3057
3189
  }
3058
3190
  var RunDiffChangelogFormatter = class {
@@ -10966,6 +11098,12 @@ Options:
10966
11098
  cloud can recommend a test scope for the change.
10967
11099
  --format <fmt> auto (default), story, junit, playwright or allure.
10968
11100
  Only needed when detection guesses wrong.
11101
+ --review-json <path>
11102
+ Write what a CI surface renders \u2014 the cloud run URL, this
11103
+ run's outcome counts, and, when --gate is used, the org's
11104
+ verdict and its blocking reasons \u2014 as a StoryReport
11105
+ ReviewJson. Written on every successful push, not only when
11106
+ a gate runs, so the PR comment can link the run either way.
10969
11107
  --gate After pushing, ask the cloud whether this commit is safe
10970
11108
  to release and exit 5 if it is blocked. The policy lives
10971
11109
  in your organization's settings, not in a file here.
@@ -10992,6 +11130,7 @@ function defaultDeps() {
10992
11130
  }
10993
11131
  },
10994
11132
  appendFile: (filePath, text2) => fs14.appendFileSync(filePath, text2),
11133
+ writeFile: (filePath, text2) => fs14.writeFileSync(filePath, text2, "utf8"),
10995
11134
  fetchFn: fetch,
10996
11135
  git: (args) => {
10997
11136
  try {
@@ -11105,6 +11244,19 @@ function summaryWriter(deps) {
11105
11244
  `);
11106
11245
  };
11107
11246
  }
11247
+ var NO_COUNTS = { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
11248
+ function runCounts(report) {
11249
+ const summary = report?.summary;
11250
+ if (!summary) return NO_COUNTS;
11251
+ const read = (key) => typeof summary[key] === "number" ? summary[key] : 0;
11252
+ return {
11253
+ total: read("total"),
11254
+ passed: read("passed"),
11255
+ failed: read("failed"),
11256
+ skipped: read("skipped"),
11257
+ pending: read("pending")
11258
+ };
11259
+ }
11108
11260
  function cell(text2) {
11109
11261
  return text2.replaceAll("|", "\\|");
11110
11262
  }
@@ -11124,6 +11276,7 @@ async function runPush(rawArgs, depsOverride = {}) {
11124
11276
  base: { type: "string" },
11125
11277
  format: { type: "string" },
11126
11278
  gate: { type: "boolean" },
11279
+ "review-json": { type: "string" },
11127
11280
  force: { type: "boolean" },
11128
11281
  help: { type: "boolean", short: "h" }
11129
11282
  }
@@ -11205,8 +11358,11 @@ async function runPush(rawArgs, depsOverride = {}) {
11205
11358
  deps.error(`Warning: no changed files found against ${base}; pushing without change metadata.`);
11206
11359
  }
11207
11360
  const forced = parsed.values.force === true;
11208
- const gateArgs = () => ({
11361
+ const gateArgs = (runUrl, counts2 = NO_COUNTS) => ({
11209
11362
  wanted: parsed.values.gate === true,
11363
+ reviewJson: parsed.values["review-json"],
11364
+ runUrl,
11365
+ counts: counts2,
11210
11366
  forced,
11211
11367
  baseUrl,
11212
11368
  key,
@@ -11279,6 +11435,13 @@ async function runPush(rawArgs, depsOverride = {}) {
11279
11435
  result = JSON.parse(body);
11280
11436
  } catch {
11281
11437
  }
11438
+ const counts = runCounts(report);
11439
+ writeReviewJson(
11440
+ parsed.values["review-json"],
11441
+ void 0,
11442
+ { repo, gitSha, runUrl: result.url, counts },
11443
+ deps
11444
+ );
11282
11445
  const runId = String(result.runId ?? "");
11283
11446
  deps.log(runId ? `Pushed run ${runId} (${repo}${branch ? `@${branch}` : ""})` : "Pushed run.");
11284
11447
  if (result.url) deps.log(result.url);
@@ -11306,7 +11469,7 @@ Recommended scope for this change (${recommendations.length}):`);
11306
11469
  summary(`| ${cell(item.confidence)} | ${cell(item.title)} | ${cell(item.reason)} |`);
11307
11470
  }
11308
11471
  }
11309
- return await gateIfRequested(gateArgs(), deps);
11472
+ return await gateIfRequested(gateArgs(result.url, counts), deps);
11310
11473
  }
11311
11474
  async function gateIfRequested(args, deps) {
11312
11475
  if (!args.wanted) return EXIT_SUCCESS;
@@ -11317,14 +11480,78 @@ async function gateIfRequested(args, deps) {
11317
11480
  const code = await runGate({ ...args, gitSha: args.gitSha }, deps);
11318
11481
  return args.forced && code !== EXIT_GATE_BLOCKED ? EXIT_SUCCESS : code;
11319
11482
  }
11483
+ function gateReviewJson(gate, context) {
11484
+ const where = context.gitSha ? `${context.repo}@${context.gitSha.slice(0, 12)}` : context.repo;
11485
+ const evidence = [`organisation release policy, evaluated for ${where}`];
11486
+ const findings = gate === void 0 ? [] : [
11487
+ ...(gate.blocking ?? []).map((reason) => ({
11488
+ kind: "policy",
11489
+ severity: "blocker",
11490
+ title: "Release policy not satisfied",
11491
+ detail: reason,
11492
+ evidence,
11493
+ remedy: (
11494
+ // Named without naming the product: this text reaches a PR comment on
11495
+ // every gated push, and the cloud is not launched yet.
11496
+ "Satisfy the policy this names, or record a decision against the release."
11497
+ )
11498
+ })),
11499
+ ...(gate.warnings ?? []).map((reason) => ({
11500
+ kind: "policy",
11501
+ severity: "minor",
11502
+ title: "Release policy warning",
11503
+ detail: reason,
11504
+ evidence,
11505
+ remedy: "Not blocking this release. Worth clearing before the next one."
11506
+ }))
11507
+ ];
11508
+ const gateStatus = gate === void 0 ? void 0 : gate.status === "no-release" ? "not-evaluated" : gate.status === "blocked" ? "blocked" : "clear";
11509
+ const review = {
11510
+ version: 1,
11511
+ ...context.gitSha ? { headRef: context.gitSha } : {},
11512
+ summary: {
11513
+ totalClaims: 0,
11514
+ byAudience: { stakeholder: 0, engineer: 0 },
11515
+ byStrength: { none: 0, weak: 0, moderate: 0, strong: 0 },
11516
+ changedSourceFiles: 0,
11517
+ uncovered: 0,
11518
+ weaklyCovered: 0,
11519
+ covered: 0
11520
+ },
11521
+ // Taken from the StoryReport this command just pushed, so a comment written
11522
+ // with no gate still says what the run actually did rather than nothing.
11523
+ run: context.counts,
11524
+ findings,
11525
+ changedFiles: [],
11526
+ claims: [],
11527
+ ...context.runUrl ? { reportUrl: context.runUrl } : {},
11528
+ ...gateStatus ? { gate: gateStatus } : {}
11529
+ };
11530
+ return `${JSON.stringify(review, null, 2)}
11531
+ `;
11532
+ }
11533
+ function writeReviewJson(target, gate, context, deps) {
11534
+ if (!target) return;
11535
+ try {
11536
+ deps.writeFile(target, gateReviewJson(gate, context));
11537
+ } catch (err) {
11538
+ deps.error(
11539
+ `Could not write --review-json to ${target}: ${err instanceof Error ? err.message : String(err)}`
11540
+ );
11541
+ }
11542
+ }
11320
11543
  async function runGate({
11321
11544
  baseUrl,
11322
11545
  key,
11323
11546
  repo,
11324
11547
  gitSha,
11325
- onActions
11548
+ onActions,
11549
+ reviewJson,
11550
+ runUrl,
11551
+ counts
11326
11552
  }, deps) {
11327
11553
  const summary = summaryWriter(deps);
11554
+ const writeGateJson = (gate2) => writeReviewJson(reviewJson, gate2, { repo, gitSha, runUrl, counts }, deps);
11328
11555
  const query = `repo=${encodeURIComponent(repo)}&sha=${encodeURIComponent(gitSha)}`;
11329
11556
  let response;
11330
11557
  try {
@@ -11359,6 +11586,7 @@ async function runGate({
11359
11586
  deps.log(`
11360
11587
  No release recorded for ${commit} \u2014 nothing to gate on.`);
11361
11588
  summary("\n**Release gate: no release recorded for this commit**");
11589
+ writeGateJson(gate);
11362
11590
  return EXIT_SUCCESS;
11363
11591
  }
11364
11592
  if (gate.status === "blocked") {
@@ -11370,11 +11598,13 @@ Release gate: BLOCKED for ${commit}`);
11370
11598
  summary(`- ${reason}`);
11371
11599
  if (onActions) deps.error(`::error::Release gate: ${reason}`);
11372
11600
  }
11601
+ writeGateJson(gate);
11373
11602
  return EXIT_GATE_BLOCKED;
11374
11603
  }
11375
11604
  deps.log(`
11376
11605
  Release gate: clear for ${commit}`);
11377
11606
  summary("\n**Release gate: clear**");
11607
+ writeGateJson(gate);
11378
11608
  return EXIT_SUCCESS;
11379
11609
  }
11380
11610
 
@@ -13601,6 +13831,10 @@ ${presetHelpLines().map((l) => ` ${l}`).join("\
13601
13831
  --code-diff <path> (review) Code Diff annotation sidecar (JSON: {title, annotations: [{file, match, text, label?, scenarioIds?}]})
13602
13832
  --patch <path> (review) Unified patch for --code-diff; generate with "git diff --histogram"
13603
13833
  --strict-code-diff (review) Gate: exit non-zero on orphaned/ambiguous anchors or unverified scenario references (default: off)
13834
+
13835
+ review writes three files: <output-name>.md and .html for people, and
13836
+ <output-name>.review.json \u2014 the machine contract CI surfaces render (ranked
13837
+ findings, evidence bands, per-claim strength). See the ReviewJson type.
13604
13838
  --emit-canonical <path> Write canonical JSON to given path
13605
13839
  --help Show this help message
13606
13840
 
@@ -15485,9 +15719,16 @@ function writeReviewReport(review, args) {
15485
15719
  fs19.mkdirSync(outputDir, { recursive: true });
15486
15720
  const mdPath = path25.join(outputDir, `${baseName}${suffix}.md`);
15487
15721
  const htmlPath = path25.join(outputDir, `${baseName}${suffix}.html`);
15722
+ const jsonPath = path25.join(outputDir, `${baseName}${suffix}.review.json`);
15488
15723
  fs19.writeFileSync(mdPath, markdown, "utf8");
15489
15724
  fs19.writeFileSync(htmlPath, html, "utf8");
15490
- return [mdPath, htmlPath];
15725
+ fs19.writeFileSync(
15726
+ jsonPath,
15727
+ `${JSON.stringify(buildReviewJson(review), null, 2)}
15728
+ `,
15729
+ "utf8"
15730
+ );
15731
+ return [mdPath, htmlPath, jsonPath];
15491
15732
  }
15492
15733
  function evaluateReviewGate(review, args) {
15493
15734
  const failures = [];