executable-stories-formatters 1.17.0 → 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 {
@@ -9187,27 +9319,39 @@ var ReportGenerator = class {
9187
9319
  bundleAssets(htmlPath, { allowMissing: this.options.allowMissingAssets });
9188
9320
  }
9189
9321
  }
9190
- const astroPaths = results.get("astro-markdown");
9191
- if (astroPaths) {
9192
- for (const mdPath of astroPaths) {
9193
- const content = await fsPromises2.readFile(mdPath, "utf8");
9194
- const mdDir = path14.dirname(mdPath);
9195
- const assetsDir = path14.resolve(this.options.astro.assetsDir);
9196
- const result = copyMarkdownAssets({
9197
- markdown: content,
9198
- markdownDir: mdDir,
9199
- assetsDir,
9200
- assetsBaseUrl: this.options.astro.assetsBaseUrl,
9201
- allowMissing: this.options.allowMissingAssets
9202
- });
9203
- if (result.copiedCount > 0 || result.missingCount > 0) {
9204
- await this.deps.writeFile(mdPath, result.markdown);
9205
- }
9206
- }
9207
- }
9322
+ await this.bundleMarkdownAssets(results.get("markdown"), (markdownDir) => ({
9323
+ assetsDir: path14.join(markdownDir, "assets"),
9324
+ assetsBaseUrl: "assets"
9325
+ }));
9326
+ await this.bundleMarkdownAssets(results.get("astro-markdown"), () => ({
9327
+ // assetsDir is resolved from CWD (same as outputDir), not relative to outputDir
9328
+ assetsDir: path14.resolve(this.options.astro.assetsDir),
9329
+ assetsBaseUrl: this.options.astro.assetsBaseUrl
9330
+ }));
9208
9331
  }
9209
9332
  return results;
9210
9333
  }
9334
+ /**
9335
+ * Copy every local asset these markdown reports reference into an assets
9336
+ * directory and rewrite the refs. The destination is computed per report
9337
+ * because colocated output writes one per source file.
9338
+ */
9339
+ async bundleMarkdownAssets(markdownPaths, target) {
9340
+ if (!markdownPaths) return;
9341
+ for (const markdownPath of markdownPaths) {
9342
+ const markdown = await fsPromises2.readFile(markdownPath, "utf8");
9343
+ const markdownDir = path14.dirname(markdownPath);
9344
+ const result = copyMarkdownAssets({
9345
+ markdown,
9346
+ markdownDir,
9347
+ allowMissing: this.options.allowMissingAssets,
9348
+ ...target(markdownDir)
9349
+ });
9350
+ if (result.copiedCount > 0 || result.missingCount > 0) {
9351
+ await this.deps.writeFile(markdownPath, result.markdown);
9352
+ }
9353
+ }
9354
+ }
9211
9355
  /**
9212
9356
  * Whether any output is colocated — the global mode, or any per-rule mode.
9213
9357
  * A colocated rule under a global aggregated mode still writes per-file
@@ -10954,6 +11098,12 @@ Options:
10954
11098
  cloud can recommend a test scope for the change.
10955
11099
  --format <fmt> auto (default), story, junit, playwright or allure.
10956
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.
10957
11107
  --gate After pushing, ask the cloud whether this commit is safe
10958
11108
  to release and exit 5 if it is blocked. The policy lives
10959
11109
  in your organization's settings, not in a file here.
@@ -10980,6 +11130,7 @@ function defaultDeps() {
10980
11130
  }
10981
11131
  },
10982
11132
  appendFile: (filePath, text2) => fs14.appendFileSync(filePath, text2),
11133
+ writeFile: (filePath, text2) => fs14.writeFileSync(filePath, text2, "utf8"),
10983
11134
  fetchFn: fetch,
10984
11135
  git: (args) => {
10985
11136
  try {
@@ -11093,6 +11244,19 @@ function summaryWriter(deps) {
11093
11244
  `);
11094
11245
  };
11095
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
+ }
11096
11260
  function cell(text2) {
11097
11261
  return text2.replaceAll("|", "\\|");
11098
11262
  }
@@ -11112,6 +11276,7 @@ async function runPush(rawArgs, depsOverride = {}) {
11112
11276
  base: { type: "string" },
11113
11277
  format: { type: "string" },
11114
11278
  gate: { type: "boolean" },
11279
+ "review-json": { type: "string" },
11115
11280
  force: { type: "boolean" },
11116
11281
  help: { type: "boolean", short: "h" }
11117
11282
  }
@@ -11193,8 +11358,11 @@ async function runPush(rawArgs, depsOverride = {}) {
11193
11358
  deps.error(`Warning: no changed files found against ${base}; pushing without change metadata.`);
11194
11359
  }
11195
11360
  const forced = parsed.values.force === true;
11196
- const gateArgs = () => ({
11361
+ const gateArgs = (runUrl, counts2 = NO_COUNTS) => ({
11197
11362
  wanted: parsed.values.gate === true,
11363
+ reviewJson: parsed.values["review-json"],
11364
+ runUrl,
11365
+ counts: counts2,
11198
11366
  forced,
11199
11367
  baseUrl,
11200
11368
  key,
@@ -11267,6 +11435,13 @@ async function runPush(rawArgs, depsOverride = {}) {
11267
11435
  result = JSON.parse(body);
11268
11436
  } catch {
11269
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
+ );
11270
11445
  const runId = String(result.runId ?? "");
11271
11446
  deps.log(runId ? `Pushed run ${runId} (${repo}${branch ? `@${branch}` : ""})` : "Pushed run.");
11272
11447
  if (result.url) deps.log(result.url);
@@ -11294,7 +11469,7 @@ Recommended scope for this change (${recommendations.length}):`);
11294
11469
  summary(`| ${cell(item.confidence)} | ${cell(item.title)} | ${cell(item.reason)} |`);
11295
11470
  }
11296
11471
  }
11297
- return await gateIfRequested(gateArgs(), deps);
11472
+ return await gateIfRequested(gateArgs(result.url, counts), deps);
11298
11473
  }
11299
11474
  async function gateIfRequested(args, deps) {
11300
11475
  if (!args.wanted) return EXIT_SUCCESS;
@@ -11305,14 +11480,78 @@ async function gateIfRequested(args, deps) {
11305
11480
  const code = await runGate({ ...args, gitSha: args.gitSha }, deps);
11306
11481
  return args.forced && code !== EXIT_GATE_BLOCKED ? EXIT_SUCCESS : code;
11307
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
+ }
11308
11543
  async function runGate({
11309
11544
  baseUrl,
11310
11545
  key,
11311
11546
  repo,
11312
11547
  gitSha,
11313
- onActions
11548
+ onActions,
11549
+ reviewJson,
11550
+ runUrl,
11551
+ counts
11314
11552
  }, deps) {
11315
11553
  const summary = summaryWriter(deps);
11554
+ const writeGateJson = (gate2) => writeReviewJson(reviewJson, gate2, { repo, gitSha, runUrl, counts }, deps);
11316
11555
  const query = `repo=${encodeURIComponent(repo)}&sha=${encodeURIComponent(gitSha)}`;
11317
11556
  let response;
11318
11557
  try {
@@ -11347,6 +11586,7 @@ async function runGate({
11347
11586
  deps.log(`
11348
11587
  No release recorded for ${commit} \u2014 nothing to gate on.`);
11349
11588
  summary("\n**Release gate: no release recorded for this commit**");
11589
+ writeGateJson(gate);
11350
11590
  return EXIT_SUCCESS;
11351
11591
  }
11352
11592
  if (gate.status === "blocked") {
@@ -11358,11 +11598,13 @@ Release gate: BLOCKED for ${commit}`);
11358
11598
  summary(`- ${reason}`);
11359
11599
  if (onActions) deps.error(`::error::Release gate: ${reason}`);
11360
11600
  }
11601
+ writeGateJson(gate);
11361
11602
  return EXIT_GATE_BLOCKED;
11362
11603
  }
11363
11604
  deps.log(`
11364
11605
  Release gate: clear for ${commit}`);
11365
11606
  summary("\n**Release gate: clear**");
11607
+ writeGateJson(gate);
11366
11608
  return EXIT_SUCCESS;
11367
11609
  }
11368
11610
 
@@ -13589,6 +13831,10 @@ ${presetHelpLines().map((l) => ` ${l}`).join("\
13589
13831
  --code-diff <path> (review) Code Diff annotation sidecar (JSON: {title, annotations: [{file, match, text, label?, scenarioIds?}]})
13590
13832
  --patch <path> (review) Unified patch for --code-diff; generate with "git diff --histogram"
13591
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.
13592
13838
  --emit-canonical <path> Write canonical JSON to given path
13593
13839
  --help Show this help message
13594
13840
 
@@ -15473,9 +15719,16 @@ function writeReviewReport(review, args) {
15473
15719
  fs19.mkdirSync(outputDir, { recursive: true });
15474
15720
  const mdPath = path25.join(outputDir, `${baseName}${suffix}.md`);
15475
15721
  const htmlPath = path25.join(outputDir, `${baseName}${suffix}.html`);
15722
+ const jsonPath = path25.join(outputDir, `${baseName}${suffix}.review.json`);
15476
15723
  fs19.writeFileSync(mdPath, markdown, "utf8");
15477
15724
  fs19.writeFileSync(htmlPath, html, "utf8");
15478
- return [mdPath, htmlPath];
15725
+ fs19.writeFileSync(
15726
+ jsonPath,
15727
+ `${JSON.stringify(buildReviewJson(review), null, 2)}
15728
+ `,
15729
+ "utf8"
15730
+ );
15731
+ return [mdPath, htmlPath, jsonPath];
15479
15732
  }
15480
15733
  function evaluateReviewGate(review, args) {
15481
15734
  const failures = [];