executable-stories-formatters 1.9.2 → 1.11.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
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { parseArgs as parseArgs2 } from "util";
5
- import * as fs15 from "fs";
6
- import * as path17 from "path";
4
+ import { parseArgs as parseArgs3 } from "util";
5
+ import * as fs17 from "fs";
6
+ import * as path19 from "path";
7
7
 
8
8
  // src/validation/schema-validator.ts
9
9
  import Ajv from "ajv/dist/2020.js";
@@ -941,28 +941,28 @@ function validateRawRun(data) {
941
941
  return { valid: true, errors: [] };
942
942
  }
943
943
  const errors = (validate.errors ?? []).map((err) => {
944
- const path18 = err.instancePath || "/";
944
+ const path20 = err.instancePath || "/";
945
945
  const message = err.message ?? "unknown error";
946
946
  if (err.keyword === "additionalProperties") {
947
947
  const extra = err.params.additionalProperty;
948
- return `${path18}: ${message} \u2014 '${extra}'`;
948
+ return `${path20}: ${message} \u2014 '${extra}'`;
949
949
  }
950
950
  if (err.keyword === "enum") {
951
951
  const allowed = err.params.allowedValues;
952
- return `${path18}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
952
+ return `${path20}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
953
953
  }
954
- return `${path18}: ${message}`;
954
+ return `${path20}: ${message}`;
955
955
  });
956
956
  return { valid: false, errors };
957
957
  }
958
958
 
959
959
  // src/cli.ts
960
- import { synthesizeStories as synthesizeStories3 } from "executable-stories-core/converters/synthesize";
961
- import { canonicalizeRun as canonicalizeRun5 } from "executable-stories-core/converters/acl/index";
960
+ import { synthesizeStories as synthesizeStories4 } from "executable-stories-core/converters/synthesize";
961
+ import { canonicalizeRun as canonicalizeRun6 } from "executable-stories-core/converters/acl/index";
962
962
  import { assertValidRun as assertValidRun2 } from "executable-stories-core/converters/acl/validate";
963
963
 
964
964
  // src/index.ts
965
- import * as path7 from "path";
965
+ import * as path8 from "path";
966
966
  import * as fsPromises from "fs/promises";
967
967
  import { toStoryReportWithIndex } from "executable-stories-core/converters/story-report";
968
968
 
@@ -1610,50 +1610,50 @@ function scenarioLines(scenario) {
1610
1610
  if (scenario.errorMessage) lines.push(indent(`error: ${scenario.errorMessage}`, " "));
1611
1611
  return lines;
1612
1612
  }
1613
- function docLines(entry, pad) {
1614
- const lines = ownDocLines(entry, pad);
1615
- for (const child of entry.children ?? []) lines.push(...docLines(child, pad + " "));
1613
+ function docLines(entry, pad2) {
1614
+ const lines = ownDocLines(entry, pad2);
1615
+ for (const child of entry.children ?? []) lines.push(...docLines(child, pad2 + " "));
1616
1616
  return lines;
1617
1617
  }
1618
- function ownDocLines(entry, pad) {
1618
+ function ownDocLines(entry, pad2) {
1619
1619
  switch (entry.kind) {
1620
1620
  case "note":
1621
- return [indent(entry.text, pad)];
1621
+ return [indent(entry.text, pad2)];
1622
1622
  case "tag":
1623
1623
  return [];
1624
1624
  // already on the scenario's tag line
1625
1625
  case "kv":
1626
- return [`${pad}${entry.label}: ${compact(entry.value)}`];
1626
+ return [`${pad2}${entry.label}: ${compact(entry.value)}`];
1627
1627
  case "code":
1628
- return [`${pad}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad + " ")];
1628
+ return [`${pad2}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad2 + " ")];
1629
1629
  case "table":
1630
1630
  return [
1631
- `${pad}table ${entry.label}: ${entry.columns.join(" | ")}`,
1632
- ...entry.rows.map((row) => `${pad} ${row.join(" | ")}`)
1631
+ `${pad2}table ${entry.label}: ${entry.columns.join(" | ")}`,
1632
+ ...entry.rows.map((row) => `${pad2} ${row.join(" | ")}`)
1633
1633
  ];
1634
1634
  case "link":
1635
- return [`${pad}link ${entry.label}: ${entry.url}`];
1635
+ return [`${pad2}link ${entry.label}: ${entry.url}`];
1636
1636
  case "section":
1637
- return [`${pad}section ${entry.title}:`, indent(entry.markdown, pad + " ")];
1637
+ return [`${pad2}section ${entry.title}:`, indent(entry.markdown, pad2 + " ")];
1638
1638
  case "mermaid":
1639
- return [`${pad}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad + " ")];
1639
+ return [`${pad2}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad2 + " ")];
1640
1640
  case "screenshot":
1641
- return [`${pad}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
1641
+ return [`${pad2}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
1642
1642
  case "video":
1643
- return [`${pad}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
1643
+ return [`${pad2}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
1644
1644
  case "html":
1645
- return [`${pad}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
1645
+ return [`${pad2}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
1646
1646
  case "state":
1647
- return [`${pad}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
1647
+ return [`${pad2}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
1648
1648
  case "custom":
1649
- return [`${pad}custom ${entry.type}: ${compact(entry.data)}`];
1649
+ return [`${pad2}custom ${entry.type}: ${compact(entry.data)}`];
1650
1650
  }
1651
1651
  }
1652
1652
  function compact(value) {
1653
1653
  return typeof value === "string" ? value : JSON.stringify(value);
1654
1654
  }
1655
- function indent(text2, pad) {
1656
- return text2.split("\n").map((line) => pad + line).join("\n");
1655
+ function indent(text2, pad2) {
1656
+ return text2.split("\n").map((line) => pad2 + line).join("\n");
1657
1657
  }
1658
1658
 
1659
1659
  // src/formatters/junit-xml.ts
@@ -2556,9 +2556,9 @@ var MAX_FUZZ = 2;
2556
2556
  var normalize = (line) => line.trim();
2557
2557
  var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
2558
2558
  function stripPathPrefix(raw) {
2559
- const path18 = raw.split(" ")[0].trim();
2560
- if (path18 === "/dev/null") return void 0;
2561
- return path18.replace(/^[ab]\//, "");
2559
+ const path20 = raw.split(" ")[0].trim();
2560
+ if (path20 === "/dev/null") return void 0;
2561
+ return path20.replace(/^[ab]\//, "");
2562
2562
  }
2563
2563
  function parseUnifiedDiff(patch) {
2564
2564
  const files = [];
@@ -2638,7 +2638,7 @@ function createAnchor(args) {
2638
2638
  };
2639
2639
  }
2640
2640
  function changedRunCandidates(anchor, file, fileIndex) {
2641
- const path18 = file.newPath ?? file.oldPath ?? "";
2641
+ const path20 = file.newPath ?? file.oldPath ?? "";
2642
2642
  const out = [];
2643
2643
  file.hunks.forEach((hunk, hunkIndex) => {
2644
2644
  outer: for (let i = 0; i + anchor.changed.length <= hunk.lines.length; i++) {
@@ -2649,7 +2649,7 @@ function changedRunCandidates(anchor, file, fileIndex) {
2649
2649
  continue outer;
2650
2650
  }
2651
2651
  }
2652
- out.push({ fileIndex, file: path18, hunkIndex, lineIndex: i, lines: hunk.lines });
2652
+ out.push({ fileIndex, file: path20, hunkIndex, lineIndex: i, lines: hunk.lines });
2653
2653
  }
2654
2654
  });
2655
2655
  return out;
@@ -2746,18 +2746,18 @@ function deriveChangeType(tags) {
2746
2746
  }
2747
2747
  return "unknown";
2748
2748
  }
2749
- function extensionOf(path18) {
2750
- const base = path18.split("/").pop() ?? path18;
2749
+ function extensionOf(path20) {
2750
+ const base = path20.split("/").pop() ?? path20;
2751
2751
  const dot = base.lastIndexOf(".");
2752
2752
  return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
2753
2753
  }
2754
- function isTestFile(path18) {
2755
- return TEST_INFIX.test(path18);
2754
+ function isTestFile(path20) {
2755
+ return TEST_INFIX.test(path20);
2756
2756
  }
2757
- function isReviewableSource(path18) {
2758
- if (isTestFile(path18)) return false;
2759
- if (path18.endsWith(".d.ts")) return false;
2760
- return CODE_EXTENSIONS.has(extensionOf(path18));
2757
+ function isReviewableSource(path20) {
2758
+ if (isTestFile(path20)) return false;
2759
+ if (path20.endsWith(".d.ts")) return false;
2760
+ return CODE_EXTENSIONS.has(extensionOf(path20));
2761
2761
  }
2762
2762
  function testBaseKey(testFile) {
2763
2763
  return testFile.replace(TEST_INFIX, "");
@@ -2861,7 +2861,7 @@ function toClaim(testCase, changedSourcePaths) {
2861
2861
  const { strength, reasons } = gradeEvidence(testCase, audience);
2862
2862
  const key = testBaseKey(testCase.sourceFile);
2863
2863
  const coversFiles = changedSourcePaths.filter(
2864
- (path18) => sourceBaseKey(path18) === key
2864
+ (path20) => sourceBaseKey(path20) === key
2865
2865
  );
2866
2866
  return {
2867
2867
  id: testCase.id,
@@ -3033,14 +3033,14 @@ var TraceabilityMatrixFormatter = class {
3033
3033
  lines.push("");
3034
3034
  lines.push(`Status: ${renderRequirementStatus(req.status)}`);
3035
3035
  if (req.covers.length > 0) {
3036
- lines.push(`Covers: ${req.covers.map((path18) => `\`${path18}\``).join(", ")}`);
3036
+ lines.push(`Covers: ${req.covers.map((path20) => `\`${path20}\``).join(", ")}`);
3037
3037
  }
3038
3038
  lines.push("");
3039
3039
  lines.push("| Status | Scenario | Source | Covers |");
3040
3040
  lines.push("| --- | --- | --- | --- |");
3041
3041
  for (const scenario of req.scenarios) {
3042
3042
  const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
3043
- const covers = scenario.covers.length > 0 ? scenario.covers.map((path18) => `\`${path18}\``).join(", ") : "";
3043
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path20) => `\`${path20}\``).join(", ") : "";
3044
3044
  lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
3045
3045
  }
3046
3046
  lines.push("");
@@ -3483,9 +3483,9 @@ function buildDataTable(table2, line) {
3483
3483
  const rowLine = line + 1 + r;
3484
3484
  rows.push({
3485
3485
  location: { line: rowLine },
3486
- cells: table2.rows[r].map((cell) => ({
3486
+ cells: table2.rows[r].map((cell2) => ({
3487
3487
  location: { line: rowLine },
3488
- value: cell
3488
+ value: cell2
3489
3489
  })),
3490
3490
  id: ""
3491
3491
  });
@@ -3586,7 +3586,7 @@ function buildPickleTable(table2) {
3586
3586
  });
3587
3587
  for (const row of table2.rows) {
3588
3588
  rows.push({
3589
- cells: row.map((cell) => ({ value: cell }))
3589
+ cells: row.map((cell2) => ({ value: cell2 }))
3590
3590
  });
3591
3591
  }
3592
3592
  return { rows };
@@ -3805,8 +3805,8 @@ function extractDocAttachments(step) {
3805
3805
  }
3806
3806
  return attachments;
3807
3807
  }
3808
- function guessMediaType(path18) {
3809
- const lower = path18.toLowerCase();
3808
+ function guessMediaType(path20) {
3809
+ const lower = path20.toLowerCase();
3810
3810
  if (lower.endsWith(".png")) return "image/png";
3811
3811
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
3812
3812
  if (lower.endsWith(".gif")) return "image/gif";
@@ -3947,11 +3947,11 @@ var CucumberHtmlFormatter = class {
3947
3947
  for (const envelope of envelopes) {
3948
3948
  const accepted = htmlStream.write(envelope);
3949
3949
  if (!accepted) {
3950
- await new Promise((resolve10) => htmlStream.once("drain", resolve10));
3950
+ await new Promise((resolve12) => htmlStream.once("drain", resolve12));
3951
3951
  }
3952
3952
  }
3953
- await new Promise((resolve10, reject) => {
3954
- collector.on("finish", resolve10);
3953
+ await new Promise((resolve12, reject) => {
3954
+ collector.on("finish", resolve12);
3955
3955
  collector.on("error", reject);
3956
3956
  htmlStream.end();
3957
3957
  });
@@ -4685,7 +4685,7 @@ function formatDocEntry(doc) {
4685
4685
  return `${escapeHtml2(doc.label)}${doc.lang ? ` (${escapeHtml2(doc.lang)})` : ""}: <code>${escapeHtml2(doc.content)}</code>`;
4686
4686
  case "table": {
4687
4687
  const header = `<tr>${doc.columns.map((c) => `<th>${escapeHtml2(c)}</th>`).join("")}</tr>`;
4688
- const rows = doc.rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml2(cell)}</td>`).join("")}</tr>`).join("");
4688
+ const rows = doc.rows.map((row) => `<tr>${row.map((cell2) => `<td>${escapeHtml2(cell2)}</td>`).join("")}</tr>`).join("");
4689
4689
  return `${escapeHtml2(doc.label)}<table>${header}${rows}</table>`;
4690
4690
  }
4691
4691
  case "link":
@@ -5722,7 +5722,7 @@ ${tc.errorStack}` : "");
5722
5722
  table([
5723
5723
  tableRow(entry.columns.map((c) => tableHeader(c))),
5724
5724
  ...entry.rows.map(
5725
- (row) => tableRow(row.map((cell) => tableCell(cell)))
5725
+ (row) => tableRow(row.map((cell2) => tableCell(cell2)))
5726
5726
  )
5727
5727
  ])
5728
5728
  );
@@ -6157,230 +6157,1611 @@ function replaceAssetRefInData(html, original, replacement) {
6157
6157
 
6158
6158
  // src/index.ts
6159
6159
  import { STORY_META_KEY } from "executable-stories-core/types/story";
6160
- import {
6161
- STORY_REPORT_SCHEMA_VERSION,
6162
- STORY_REPORT_SCHEMA_MAJOR
6163
- } from "executable-stories-core/types/story-report";
6164
- import { ES_THEME_TOKENS_CSS as ES_THEME_TOKENS_CSS2, ES_THEME_TOKEN_VALUES } from "executable-stories-core/theme/tokens";
6165
- import { canonicalizeRun as canonicalizeRun3 } from "executable-stories-core/converters/acl/index";
6166
- import { normalizeStatus } from "executable-stories-core/converters/acl/index";
6167
- import { generateTestCaseId } from "executable-stories-core/converters/acl/index";
6168
- import { generateRunId } from "executable-stories-core/converters/acl/index";
6169
- import { slugify as slugify2 } from "executable-stories-core/converters/acl/index";
6170
- import { deriveStepResults } from "executable-stories-core/converters/acl/index";
6171
- import { mergeStepResults } from "executable-stories-core/converters/acl/index";
6172
- import { resolveAttachment } from "executable-stories-core/converters/acl/index";
6173
- import { resolveAttachments } from "executable-stories-core/converters/acl/index";
6174
- import {
6175
- validateCanonicalRun,
6176
- assertValidRun
6177
- } from "executable-stories-core/converters/acl/validate";
6178
6160
 
6179
- // src/watch.ts
6161
+ // src/sync/engine.ts
6162
+ import { behaviourFingerprint as behaviourFingerprint2, behaviourSimilarity as behaviourSimilarity2 } from "executable-stories-core/converters/acl/ids";
6163
+
6164
+ // src/sync/lockfile.ts
6180
6165
  import * as fs4 from "fs";
6181
6166
  import * as path5 from "path";
6182
- import { canonicalizeRun } from "executable-stories-core/converters/acl/index";
6183
- import { synthesizeStories } from "executable-stories-core/converters/synthesize";
6184
- function toRun(data, inputType, synthesize) {
6185
- if (inputType === "canonical") return data;
6186
- let raw = data;
6187
- if (synthesize) raw = synthesizeStories(raw);
6188
- return canonicalizeRun(raw);
6189
- }
6190
- async function regenerateRun(options, deps = {}) {
6191
- const read = deps.readFile ?? ((filePath) => fs4.readFileSync(filePath, "utf8"));
6192
- const data = JSON.parse(read(path5.resolve(options.input)));
6193
- const run = toRun(data, options.inputType ?? "raw", options.synthesize !== false);
6194
- const generator = new ReportGenerator({
6195
- formats: options.formats,
6196
- outputDir: options.outputDir,
6197
- outputName: options.outputName
6167
+ import { createHash as createHash5 } from "crypto";
6168
+ var DEFAULT_LOCKFILE_PATH = ".executable-stories/sync.lock.json";
6169
+ var LOCKFILE_VERSION = 1;
6170
+ function emptyLockfile() {
6171
+ return { version: LOCKFILE_VERSION, providers: {} };
6172
+ }
6173
+ function hashCaseBody(body) {
6174
+ const canonical = JSON.stringify({
6175
+ title: body.title.trim(),
6176
+ steps: body.steps.map((s) => `${s.keyword.toLowerCase()}:${s.text.trim()}`),
6177
+ description: body.description.trim()
6198
6178
  });
6199
- const result = await generator.generate(run);
6200
- return { files: [...result.values()].flat(), run };
6201
- }
6202
- async function regenerateArtifacts(options, deps = {}) {
6203
- return (await regenerateRun(options, deps)).files;
6204
- }
6205
- function startWatch(options, deps = {}) {
6206
- const log = deps.log ?? ((message) => console.log(message));
6207
- const regenerate = deps.regenerate ?? ((input) => regenerateArtifacts({ ...options, input }, deps));
6208
- const watchFn = deps.watch ?? ((filePath, listener) => fs4.watch(filePath, listener));
6209
- const debounceMs = options.debounceMs ?? 150;
6210
- let timer;
6211
- let running = false;
6212
- let pending = false;
6213
- const run = async () => {
6214
- if (running) {
6215
- pending = true;
6216
- return;
6217
- }
6218
- running = true;
6219
- try {
6220
- const files = await regenerate(options.input);
6221
- log(`Regenerated ${files.length} artifact file(s) from ${options.input}`);
6222
- } catch (error) {
6223
- log(`Watch regeneration failed: ${error.message}`);
6224
- } finally {
6225
- running = false;
6226
- if (pending) {
6227
- pending = false;
6228
- trigger();
6229
- }
6230
- }
6231
- };
6232
- const trigger = () => {
6233
- if (timer) clearTimeout(timer);
6234
- timer = setTimeout(() => void run(), debounceMs);
6235
- };
6236
- trigger();
6237
- const watcher = watchFn(path5.resolve(options.input), trigger);
6238
- return {
6239
- close: () => {
6240
- if (timer) clearTimeout(timer);
6241
- watcher.close();
6242
- }
6243
- };
6179
+ return createHash5("sha1").update(canonical).digest("hex").slice(0, 16);
6244
6180
  }
6245
-
6246
- // src/index.ts
6247
- import { advanceState, initialRunState } from "executable-stories-core";
6248
- import { toStoryReport as toStoryReport6 } from "executable-stories-core/converters/story-report";
6249
-
6250
- // src/publishers/confluence.ts
6251
- function parseAdf(adf) {
6181
+ function parseLockfile(contents, label) {
6252
6182
  let parsed;
6253
6183
  try {
6254
- parsed = JSON.parse(adf);
6184
+ parsed = JSON.parse(contents);
6255
6185
  } catch (err) {
6256
6186
  throw new Error(
6257
- `ADF payload is not valid JSON: ${err.message}`
6187
+ `Sync lockfile at ${label} is not valid JSON: ${err.message}
6188
+ Fix or delete it \u2014 deleting orphans every existing case binding, so prefer fixing.`
6258
6189
  );
6259
6190
  }
6260
- if (!parsed || typeof parsed !== "object" || parsed.type !== "doc" || !Array.isArray(parsed.content)) {
6191
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
6192
+ throw new Error(`Sync lockfile at ${label} must contain an object.`);
6193
+ }
6194
+ const lock = parsed;
6195
+ if (lock.version !== LOCKFILE_VERSION) {
6261
6196
  throw new Error(
6262
- `ADF payload must be an object with { version, type: "doc", content: [...] }`
6197
+ `Sync lockfile at ${label} has version ${String(lock.version)}, expected ${LOCKFILE_VERSION}.`
6263
6198
  );
6264
6199
  }
6265
- return parsed;
6200
+ return { version: LOCKFILE_VERSION, providers: lock.providers ?? {} };
6266
6201
  }
6267
- function basicAuthHeader(auth) {
6268
- const raw = `${auth.email}:${auth.token}`;
6269
- const encoded = typeof Buffer !== "undefined" ? Buffer.from(raw, "utf8").toString("base64") : btoa(raw);
6270
- return `Basic ${encoded}`;
6202
+ function serializeLockfile(lock) {
6203
+ const providers = {};
6204
+ for (const provider of Object.keys(lock.providers).sort()) {
6205
+ const entries = lock.providers[provider] ?? {};
6206
+ const sorted = {};
6207
+ for (const key of Object.keys(entries).sort()) sorted[key] = entries[key];
6208
+ providers[provider] = sorted;
6209
+ }
6210
+ return `${JSON.stringify({ version: LOCKFILE_VERSION, providers }, null, 2)}
6211
+ `;
6271
6212
  }
6272
- async function parseErrorBody(response) {
6273
- try {
6274
- const body = await response.text();
6275
- return body ? body.slice(0, 800) : "";
6276
- } catch {
6277
- return "";
6213
+ function entriesFor(lock, provider) {
6214
+ return lock.providers[provider] ?? {};
6215
+ }
6216
+ function setEntry(lock, provider, fingerprint, entry) {
6217
+ lock.providers[provider] ??= {};
6218
+ lock.providers[provider][fingerprint] = entry;
6219
+ }
6220
+
6221
+ // src/sync/engine.ts
6222
+ var DEFAULT_DUPLICATE_THRESHOLD = 0.7;
6223
+ var PARTIAL_RUN_ORPHAN_RATIO = 0.25;
6224
+ function normalizeTitle(text2) {
6225
+ return text2.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
6226
+ }
6227
+ function renderDocs(docs, depth = 0) {
6228
+ if (!docs || docs.length === 0) return "";
6229
+ const lines = [];
6230
+ for (const doc of docs) {
6231
+ switch (doc.kind) {
6232
+ case "note":
6233
+ lines.push(doc.text);
6234
+ break;
6235
+ case "kv":
6236
+ lines.push(`**${doc.label}:** ${formatValue(doc.value)}`);
6237
+ break;
6238
+ case "state":
6239
+ lines.push(`**${doc.label ?? "State"}:** ${formatValue(doc.value)}`);
6240
+ break;
6241
+ case "code":
6242
+ lines.push(`**${doc.label}**`, "", "```" + (doc.lang ?? ""), doc.content, "```");
6243
+ break;
6244
+ case "table":
6245
+ lines.push(
6246
+ `**${doc.label}**`,
6247
+ "",
6248
+ `| ${doc.columns.join(" | ")} |`,
6249
+ `| ${doc.columns.map(() => "---").join(" | ")} |`,
6250
+ ...doc.rows.map((row) => `| ${row.join(" | ")} |`)
6251
+ );
6252
+ break;
6253
+ case "link":
6254
+ lines.push(`[${doc.label}](${doc.url})`);
6255
+ break;
6256
+ case "section":
6257
+ lines.push(`${"#".repeat(Math.min(6, depth + 3))} ${doc.title}`, "", doc.markdown);
6258
+ break;
6259
+ case "mermaid":
6260
+ lines.push(...doc.title ? [`**${doc.title}**`, ""] : [], "```mermaid", doc.code, "```");
6261
+ break;
6262
+ case "screenshot":
6263
+ lines.push(`_Screenshot: ${doc.alt ?? doc.path}_`);
6264
+ break;
6265
+ case "video":
6266
+ lines.push(`_Video: ${doc.caption ?? doc.path}_`);
6267
+ break;
6268
+ case "html":
6269
+ lines.push(`_Embedded: ${doc.title ?? doc.url ?? doc.path ?? "html"}_`);
6270
+ break;
6271
+ case "custom":
6272
+ lines.push(`_${doc.type}_: ${formatValue(doc.data)}`);
6273
+ break;
6274
+ case "tag":
6275
+ break;
6276
+ }
6277
+ const children = renderDocs(doc.children, depth + 1);
6278
+ if (children) lines.push(children);
6278
6279
  }
6280
+ return lines.join("\n");
6279
6281
  }
6280
- async function publishConfluencePage(args, deps) {
6281
- parseAdf(args.adf);
6282
- if (!args.pageId && !args.spaceId) {
6283
- throw new Error(
6284
- "publishConfluencePage requires either pageId (update) or spaceId (create)"
6282
+ function formatValue(value) {
6283
+ if (typeof value === "string") return value;
6284
+ return JSON.stringify(value);
6285
+ }
6286
+ function scenarioUrl(config, tc) {
6287
+ if (!config.reportUrl) return void 0;
6288
+ const base = config.reportUrl.replace(/\/$/, "");
6289
+ const anchor = config.scenarioAnchor?.(tc);
6290
+ return anchor ? `${base}#${anchor}` : base;
6291
+ }
6292
+ function toCaseBody(tc, config) {
6293
+ const sections = [];
6294
+ const docs = renderDocs(tc.story.docs);
6295
+ if (docs) sections.push(docs);
6296
+ const tickets = tc.story.tickets ?? [];
6297
+ if (tickets.length > 0) {
6298
+ sections.push(
6299
+ `**Requirements:** ${tickets.map((t) => t.url ? `[${t.id}](${t.url})` : t.id).join(", ")}`
6285
6300
  );
6286
6301
  }
6287
- if (!args.pageId && !args.title) {
6288
- throw new Error("Creating a new page requires a title");
6302
+ if (tc.story.covers?.length) {
6303
+ sections.push(`**Covers:** ${tc.story.covers.join(", ")}`);
6289
6304
  }
6290
- const base = args.baseUrl.replace(/\/$/, "");
6291
- const fetchFn = deps.fetch ?? globalThis.fetch;
6292
- if (!fetchFn) {
6293
- throw new Error("No fetch implementation available (Node >= 22 expected)");
6305
+ sections.push(`_Generated from ${tc.sourceFile}:${tc.sourceLine} by executable-stories. Edit the test, not this case._`);
6306
+ const links = [];
6307
+ const report = scenarioUrl(config, tc);
6308
+ if (report) links.push({ label: "Living documentation", url: report });
6309
+ for (const ticket of tickets) {
6310
+ if (ticket.url) links.push({ label: ticket.id, url: ticket.url });
6294
6311
  }
6295
- const headers = {
6296
- Authorization: basicAuthHeader(deps.auth),
6297
- Accept: "application/json",
6298
- "Content-Type": "application/json"
6312
+ return {
6313
+ title: tc.story.scenario,
6314
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
6315
+ description: sections.join("\n\n"),
6316
+ links
6299
6317
  };
6300
- if (args.pageId) {
6301
- return updatePage(args, base, headers, fetchFn);
6302
- }
6303
- return createPage(args, base, headers, fetchFn);
6304
6318
  }
6305
- async function updatePage(args, base, headers, fetchFn) {
6306
- const getUrl = `${base}/api/v2/pages/${encodeURIComponent(args.pageId)}`;
6307
- const getResp = await fetchFn(getUrl, { method: "GET", headers });
6308
- if (!getResp.ok) {
6309
- const body = await parseErrorBody(getResp);
6310
- throw new Error(
6311
- `GET ${getUrl} failed with ${getResp.status} ${getResp.statusText}${body ? `: ${body}` : ""}`
6312
- );
6313
- }
6314
- const current = await getResp.json();
6315
- const nextVersion = current.version.number + 1;
6316
- const title = args.title ?? current.title;
6317
- const putUrl = `${base}/api/v2/pages/${encodeURIComponent(args.pageId)}`;
6318
- const putResp = await fetchFn(putUrl, {
6319
- method: "PUT",
6320
- headers,
6321
- body: JSON.stringify({
6322
- id: args.pageId,
6323
- status: "current",
6324
- title,
6325
- body: {
6326
- representation: "atlas_doc_format",
6327
- value: args.adf
6328
- },
6329
- version: { number: nextVersion }
6319
+ function projectBehaviours(run, config) {
6320
+ const fingerprints = run.testCases.map(
6321
+ (tc) => behaviourFingerprint2({
6322
+ scenario: tc.story.scenario,
6323
+ sourceFile: tc.sourceFile,
6324
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
6325
+ covers: tc.story.covers
6330
6326
  })
6331
- });
6332
- if (!putResp.ok) {
6333
- const body = await parseErrorBody(putResp);
6334
- throw new Error(
6335
- `PUT ${putUrl} failed with ${putResp.status} ${putResp.statusText}${body ? `: ${body}` : ""}`
6336
- );
6327
+ );
6328
+ const counts = /* @__PURE__ */ new Map();
6329
+ for (const fp of fingerprints) {
6330
+ if (fp) counts.set(fp, (counts.get(fp) ?? 0) + 1);
6337
6331
  }
6338
- const updated = await putResp.json();
6339
- return {
6340
- id: updated.id,
6341
- title: updated.title,
6342
- version: updated.version.number,
6343
- url: buildPageUrl(base, updated._links?.webui, updated.id),
6344
- action: "updated"
6345
- };
6332
+ return run.testCases.map((tc, index) => {
6333
+ const fp = fingerprints[index];
6334
+ const unique = fp !== "" && counts.get(fp) === 1;
6335
+ return {
6336
+ fingerprint: unique ? fp : tc.id,
6337
+ testCase: tc,
6338
+ body: toCaseBody(tc, config)
6339
+ };
6340
+ });
6346
6341
  }
6347
- async function createPage(args, base, headers, fetchFn) {
6348
- const body = {
6349
- spaceId: args.spaceId,
6350
- status: "current",
6351
- title: args.title,
6352
- body: {
6353
- representation: "atlas_doc_format",
6354
- value: args.adf
6342
+ function roleFor(attachment) {
6343
+ const type = attachment.mediaType.toLowerCase();
6344
+ if (type.startsWith("image/")) return "screenshot";
6345
+ if (type.startsWith("video/")) return "video";
6346
+ if (attachment.name.toLowerCase().includes("trace")) return "trace";
6347
+ return "log";
6348
+ }
6349
+ function decode(attachment) {
6350
+ return attachment.contentEncoding === "BASE64" ? Uint8Array.from(Buffer.from(attachment.body, "base64")) : new TextEncoder().encode(attachment.body);
6351
+ }
6352
+ function collectAttachments(args) {
6353
+ const { testCase, policy, maxBytes } = args;
6354
+ if (policy === "none") return { attachments: [], oversized: [] };
6355
+ if (policy === "failed" && testCase.status !== "failed") return { attachments: [], oversized: [] };
6356
+ const attachments = [];
6357
+ const oversized = [];
6358
+ for (const raw of testCase.attachments) {
6359
+ const body = decode(raw);
6360
+ if (maxBytes !== void 0 && body.byteLength > maxBytes) {
6361
+ oversized.push({ filename: raw.name, bytes: body.byteLength, limit: maxBytes });
6362
+ continue;
6355
6363
  }
6356
- };
6357
- if (args.parentId) body.parentId = args.parentId;
6358
- const postUrl = `${base}/api/v2/pages`;
6359
- const resp = await fetchFn(postUrl, {
6360
- method: "POST",
6361
- headers,
6362
- body: JSON.stringify(body)
6363
- });
6364
- if (!resp.ok) {
6365
- const errBody = await parseErrorBody(resp);
6366
- throw new Error(
6367
- `POST ${postUrl} failed with ${resp.status} ${resp.statusText}${errBody ? `: ${errBody}` : ""}`
6368
- );
6364
+ attachments.push({
6365
+ filename: raw.name,
6366
+ mediaType: raw.mediaType,
6367
+ body,
6368
+ role: roleFor(raw)
6369
+ });
6369
6370
  }
6370
- const created = await resp.json();
6371
+ return { attachments, oversized };
6372
+ }
6373
+ function toCaseResult(args) {
6374
+ const { behaviour, caseId, provider, config } = args;
6375
+ const tc = behaviour.testCase;
6376
+ if (tc.status === "pending") return void 0;
6377
+ const { attachments, oversized } = collectAttachments({
6378
+ testCase: tc,
6379
+ policy: config.attach ?? "failed",
6380
+ maxBytes: provider.maxAttachmentBytes
6381
+ });
6371
6382
  return {
6372
- id: created.id,
6373
- title: created.title,
6374
- version: created.version.number,
6375
- url: buildPageUrl(base, created._links?.webui, created.id),
6376
- action: "created"
6383
+ result: {
6384
+ caseId,
6385
+ status: tc.status,
6386
+ durationMs: tc.durationMs,
6387
+ message: tc.errorMessage,
6388
+ url: scenarioUrl(config, tc),
6389
+ attachments: attachments.length > 0 ? attachments : void 0
6390
+ },
6391
+ oversized
6377
6392
  };
6378
6393
  }
6379
- function buildPageUrl(base, webui, id) {
6380
- if (webui) {
6381
- return webui.startsWith("http") ? webui : `${base}${webui}`;
6382
- }
6383
- return `${base}/pages/${id}`;
6394
+ function ticketBinding(tc, config) {
6395
+ const prefix = config.ticketPrefix;
6396
+ if (!prefix) return void 0;
6397
+ const match = (tc.story.tickets ?? []).find((t) => t.id.startsWith(prefix));
6398
+ if (!match) return void 0;
6399
+ return config.ticketPrefixStrip === false ? match.id : match.id.slice(prefix.length);
6400
+ }
6401
+ async function analyzeSync(args) {
6402
+ const { run, provider, lockfile, config } = args;
6403
+ const local = projectBehaviours(run, config);
6404
+ const remoteCases = await provider.listCases();
6405
+ const remoteById = new Map(remoteCases.map((c) => [c.id, c]));
6406
+ const locked = entriesFor(lockfile, provider.name);
6407
+ const create = [];
6408
+ const update = [];
6409
+ const unchanged = [];
6410
+ const adopted = [];
6411
+ const skipped = [];
6412
+ const results = [];
6413
+ const oversized = [];
6414
+ const boundCaseIds = /* @__PURE__ */ new Set();
6415
+ let driftUncheckable = 0;
6416
+ for (const behaviour of local) {
6417
+ const entry = locked[behaviour.fingerprint];
6418
+ const caseId = entry?.caseId ?? ticketBinding(behaviour.testCase, config);
6419
+ const remote2 = caseId ? remoteById.get(caseId) : void 0;
6420
+ if (!caseId) {
6421
+ create.push({
6422
+ fingerprint: behaviour.fingerprint,
6423
+ scenario: behaviour.body.title,
6424
+ body: behaviour.body
6425
+ });
6426
+ continue;
6427
+ }
6428
+ if (!remote2) {
6429
+ skipped.push({
6430
+ fingerprint: behaviour.fingerprint,
6431
+ caseId,
6432
+ url: entry?.url ?? "",
6433
+ title: entry?.title ?? behaviour.body.title,
6434
+ reason: "case-missing"
6435
+ });
6436
+ continue;
6437
+ }
6438
+ boundCaseIds.add(caseId);
6439
+ const pending = toCaseResult({ behaviour, caseId, provider, config });
6440
+ if (pending) {
6441
+ results.push(pending.result);
6442
+ oversized.push(...pending.oversized);
6443
+ }
6444
+ const planned = {
6445
+ fingerprint: behaviour.fingerprint,
6446
+ caseId,
6447
+ url: remote2.url,
6448
+ scenario: behaviour.body.title,
6449
+ body: behaviour.body
6450
+ };
6451
+ if (!entry?.owned) {
6452
+ adopted.push(planned);
6453
+ continue;
6454
+ }
6455
+ const remoteHash = remote2.body ? hashCaseBody(remote2.body) : void 0;
6456
+ if (remoteHash !== void 0 && remoteHash !== entry.hash) {
6457
+ skipped.push({
6458
+ fingerprint: behaviour.fingerprint,
6459
+ caseId,
6460
+ url: remote2.url,
6461
+ title: remote2.title,
6462
+ reason: "remote-edited"
6463
+ });
6464
+ continue;
6465
+ }
6466
+ const baseline = remoteHash ?? entry.hash;
6467
+ if (baseline !== "" && hashCaseBody(behaviour.body) === baseline) {
6468
+ unchanged.push(planned);
6469
+ } else {
6470
+ update.push(planned);
6471
+ }
6472
+ if (remoteHash === void 0) driftUncheckable += 1;
6473
+ }
6474
+ const byNormalizedTitle = /* @__PURE__ */ new Map();
6475
+ for (const behaviour of local) byNormalizedTitle.set(normalizeTitle(behaviour.body.title), behaviour);
6476
+ const threshold = config.duplicateThreshold ?? DEFAULT_DUPLICATE_THRESHOLD;
6477
+ const remote = remoteCases.map((remoteCase) => {
6478
+ if (boundCaseIds.has(remoteCase.id)) {
6479
+ return { case: remoteCase, classification: "automated" };
6480
+ }
6481
+ const titleMatch = byNormalizedTitle.get(normalizeTitle(remoteCase.title));
6482
+ if (titleMatch) {
6483
+ return {
6484
+ case: remoteCase,
6485
+ classification: "duplicated",
6486
+ resembles: titleMatch.body.title
6487
+ };
6488
+ }
6489
+ if (remoteCase.body && remoteCase.body.steps.length > 0) {
6490
+ let best;
6491
+ for (const behaviour of local) {
6492
+ const score = behaviourSimilarity2(
6493
+ {
6494
+ scenario: remoteCase.title,
6495
+ sourceFile: "",
6496
+ steps: remoteCase.body.steps
6497
+ },
6498
+ {
6499
+ scenario: behaviour.body.title,
6500
+ sourceFile: behaviour.testCase.sourceFile,
6501
+ steps: behaviour.body.steps
6502
+ }
6503
+ );
6504
+ if (!best || score > best.score) best = { behaviour, score };
6505
+ }
6506
+ if (best && best.score >= threshold) {
6507
+ return {
6508
+ case: remoteCase,
6509
+ classification: "possible-duplicate",
6510
+ resembles: best.behaviour.body.title,
6511
+ similarity: Number(best.score.toFixed(2))
6512
+ };
6513
+ }
6514
+ }
6515
+ return { case: remoteCase, classification: "manual-only" };
6516
+ });
6517
+ const localFingerprints = new Set(local.map((b) => b.fingerprint));
6518
+ const orphaned = Object.entries(locked).filter(([fingerprint]) => !localFingerprints.has(fingerprint)).map(([fingerprint, entry]) => ({
6519
+ fingerprint,
6520
+ caseId: entry.caseId,
6521
+ url: entry.url,
6522
+ title: entry.title
6523
+ }));
6524
+ const lockedCount = Object.keys(locked).length;
6525
+ const partialRunWarning = lockedCount > 0 && orphaned.length / lockedCount > PARTIAL_RUN_ORPHAN_RATIO ? `${orphaned.length} of ${lockedCount} bindings have no matching story. If this run was filtered (a -t/--grep flag, a single file), those are not deletions. Nothing is removed either way.` : void 0;
6526
+ const unsupported = [];
6527
+ if (create.length > 0 && !provider.createCase) unsupported.push("createCase");
6528
+ if (update.length > 0 && !provider.updateCase) unsupported.push("updateCase");
6529
+ if (results.length > 0 && !provider.recordResults) unsupported.push("recordResults");
6530
+ const byRole = {};
6531
+ let files = 0;
6532
+ let bytes = 0;
6533
+ for (const result of results) {
6534
+ for (const attachment of result.attachments ?? []) {
6535
+ files += 1;
6536
+ bytes += attachment.body.byteLength;
6537
+ const role = attachment.role ?? "log";
6538
+ byRole[role] = (byRole[role] ?? 0) + 1;
6539
+ }
6540
+ }
6541
+ return {
6542
+ provider: provider.name,
6543
+ target: provider.describeTarget?.(),
6544
+ local,
6545
+ remote,
6546
+ create,
6547
+ update,
6548
+ unchanged,
6549
+ adopted,
6550
+ skipped,
6551
+ orphaned,
6552
+ results,
6553
+ attachments: { files, bytes, oversized, byRole },
6554
+ unsupported,
6555
+ driftUncheckable,
6556
+ partialRunWarning
6557
+ };
6558
+ }
6559
+ async function applySync(args, deps) {
6560
+ const { analysis, provider, lockfile, config } = args;
6561
+ const result = {
6562
+ created: [],
6563
+ updated: [],
6564
+ resultsRecorded: 0,
6565
+ resultsSkipped: [],
6566
+ attachmentsUploaded: 0,
6567
+ errors: []
6568
+ };
6569
+ const byFingerprint = new Map(analysis.local.map((b) => [b.fingerprint, b]));
6570
+ const results = [...analysis.results];
6571
+ if (analysis.create.length > 0 && provider.createCase) {
6572
+ for (const planned of analysis.create) {
6573
+ try {
6574
+ const created = await provider.createCase(planned.body);
6575
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6576
+ caseId: created.id,
6577
+ url: created.url,
6578
+ hash: hashCaseBody(created.body ?? planned.body),
6579
+ title: created.title,
6580
+ owned: true
6581
+ });
6582
+ result.created.push({ scenario: planned.scenario, caseId: created.id, url: created.url });
6583
+ const behaviour = byFingerprint.get(planned.fingerprint);
6584
+ if (behaviour) {
6585
+ const pending = toCaseResult({ behaviour, caseId: created.id, provider, config });
6586
+ if (pending) results.push(pending.result);
6587
+ }
6588
+ } catch (err) {
6589
+ result.errors.push(`create "${planned.scenario}": ${err.message}`);
6590
+ }
6591
+ }
6592
+ }
6593
+ if (analysis.update.length > 0 && provider.updateCase) {
6594
+ for (const planned of analysis.update) {
6595
+ try {
6596
+ const updated = await provider.updateCase(planned.caseId, planned.body);
6597
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6598
+ caseId: updated.id,
6599
+ url: updated.url,
6600
+ hash: hashCaseBody(updated.body ?? planned.body),
6601
+ title: updated.title,
6602
+ owned: true
6603
+ });
6604
+ result.updated.push({ scenario: planned.scenario, caseId: updated.id, url: updated.url });
6605
+ } catch (err) {
6606
+ result.errors.push(`update "${planned.scenario}" (${planned.caseId}): ${err.message}`);
6607
+ }
6608
+ }
6609
+ }
6610
+ for (const planned of analysis.unchanged) {
6611
+ const existing = entriesFor(lockfile, provider.name)[planned.fingerprint];
6612
+ if (existing) {
6613
+ setEntry(lockfile, provider.name, planned.fingerprint, { ...existing, url: planned.url });
6614
+ }
6615
+ }
6616
+ for (const planned of analysis.adopted) {
6617
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6618
+ caseId: planned.caseId,
6619
+ url: planned.url,
6620
+ hash: "",
6621
+ title: planned.scenario,
6622
+ owned: false
6623
+ });
6624
+ }
6625
+ if (results.length > 0 && provider.recordResults) {
6626
+ try {
6627
+ const summary = await provider.recordResults(results);
6628
+ result.resultsRecorded = summary.recorded;
6629
+ result.resultsSkipped = summary.skipped;
6630
+ result.attachmentsUploaded = summary.attachmentsUploaded;
6631
+ result.runUrl = summary.runUrl;
6632
+ } catch (err) {
6633
+ result.errors.push(`record results: ${err.message}`);
6634
+ }
6635
+ } else if (results.length > 0) {
6636
+ deps.logger.warn(
6637
+ `${provider.name} does not support recording results \u2014 ${results.length} execution(s) not pushed.`
6638
+ );
6639
+ }
6640
+ return result;
6641
+ }
6642
+
6643
+ // src/sync/report.ts
6644
+ var MANUAL_ONLY_LIMIT = 50;
6645
+ function summarize2(analysis) {
6646
+ const count2 = (kind) => analysis.remote.filter((c) => c.classification === kind).length;
6647
+ return {
6648
+ provider: analysis.provider,
6649
+ target: analysis.target,
6650
+ totalCases: analysis.remote.length,
6651
+ automated: count2("automated"),
6652
+ duplicated: count2("duplicated"),
6653
+ possibleDuplicate: count2("possible-duplicate"),
6654
+ manualOnly: count2("manual-only"),
6655
+ untracked: analysis.create.length,
6656
+ adopted: analysis.adopted.length
6657
+ };
6658
+ }
6659
+ function sectionBreakdown(analysis) {
6660
+ const sections = /* @__PURE__ */ new Map();
6661
+ for (const entry of analysis.remote) {
6662
+ const name = entry.case.section ?? "(no section)";
6663
+ const bucket2 = sections.get(name) ?? { total: 0, automated: 0 };
6664
+ bucket2.total += 1;
6665
+ if (entry.classification === "automated") bucket2.automated += 1;
6666
+ sections.set(name, bucket2);
6667
+ }
6668
+ return [...sections.entries()].map(([name, value]) => ({ name, ...value })).sort((a, b) => b.automated - a.automated || b.total - a.total);
6669
+ }
6670
+ function renderCoverageText(analysis) {
6671
+ const summary = summarize2(analysis);
6672
+ const lines = [];
6673
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"} (${summary.totalCases} cases)`);
6674
+ lines.push("");
6675
+ lines.push(` ${pad(summary.automated)} automated already covered by a story`);
6676
+ lines.push(` ${pad(summary.duplicated)} duplicated manual case duplicates an automated story`);
6677
+ if (summary.possibleDuplicate > 0) {
6678
+ lines.push(` ${pad(summary.possibleDuplicate)} possible dupe similar to a story, needs a human to confirm`);
6679
+ }
6680
+ lines.push(` ${pad(summary.manualOnly)} manual only no automated equivalent`);
6681
+ lines.push(` ${pad(summary.untracked)} untracked story with no case`);
6682
+ if (summary.adopted > 0) {
6683
+ lines.push(` ${pad(summary.adopted)} linked story bound to a hand-authored case`);
6684
+ }
6685
+ const sections = sectionBreakdown(analysis).filter((s) => s.automated > 0);
6686
+ if (sections.length > 0) {
6687
+ const top = sections[0];
6688
+ lines.push("");
6689
+ lines.push(`Biggest overlap: "${top.name}" section, ${top.automated} of ${top.total} automated.`);
6690
+ }
6691
+ if (analysis.orphaned.length > 0) {
6692
+ lines.push("");
6693
+ lines.push(`${analysis.orphaned.length} case(s) bound to a story that no longer exists. Nothing was removed.`);
6694
+ }
6695
+ if (analysis.partialRunWarning) {
6696
+ lines.push("");
6697
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6698
+ }
6699
+ return lines.join("\n");
6700
+ }
6701
+ function pad(value) {
6702
+ return String(value).padStart(4, " ");
6703
+ }
6704
+ function renderCoverageMarkdown(analysis) {
6705
+ const summary = summarize2(analysis);
6706
+ const lines = [];
6707
+ lines.push(`# Test coverage vs ${analysis.provider}`);
6708
+ lines.push("");
6709
+ if (analysis.target) lines.push(`**Target:** ${analysis.target}`, "");
6710
+ lines.push("| Cases | Count | Meaning |");
6711
+ lines.push("| --- | ---: | --- |");
6712
+ lines.push(`| Automated | ${summary.automated} | Already covered by a story |`);
6713
+ lines.push(`| Duplicated | ${summary.duplicated} | Manual case duplicates an automated story |`);
6714
+ lines.push(`| Possible duplicate | ${summary.possibleDuplicate} | Similar to a story, needs review |`);
6715
+ lines.push(`| Manual only | ${summary.manualOnly} | No automated equivalent |`);
6716
+ lines.push(`| Untracked stories | ${summary.untracked} | Story with no case |`);
6717
+ lines.push("");
6718
+ const duplicates = analysis.remote.filter(
6719
+ (c) => c.classification === "duplicated" || c.classification === "possible-duplicate"
6720
+ );
6721
+ if (duplicates.length > 0) {
6722
+ lines.push("## Retire these first");
6723
+ lines.push("");
6724
+ lines.push("Manual cases an automated story already covers.");
6725
+ lines.push("");
6726
+ lines.push("| Case | Title | Covered by | Confidence |");
6727
+ lines.push("| --- | --- | --- | --- |");
6728
+ for (const entry of duplicates) {
6729
+ const confidence = entry.classification === "duplicated" ? "exact title" : `similarity ${entry.similarity}`;
6730
+ lines.push(
6731
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.resembles ?? "")} | ${confidence} |`
6732
+ );
6733
+ }
6734
+ lines.push("");
6735
+ }
6736
+ const manualOnly = analysis.remote.filter((c) => c.classification === "manual-only");
6737
+ if (manualOnly.length > 0) {
6738
+ lines.push("## Not automated yet");
6739
+ lines.push("");
6740
+ lines.push("Cases with no automated equivalent. This is the backlog.");
6741
+ lines.push("");
6742
+ lines.push("| Case | Title | Section |");
6743
+ lines.push("| --- | --- | --- |");
6744
+ for (const entry of manualOnly.slice(0, MANUAL_ONLY_LIMIT)) {
6745
+ lines.push(
6746
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.case.section ?? "")} |`
6747
+ );
6748
+ }
6749
+ if (manualOnly.length > MANUAL_ONLY_LIMIT) {
6750
+ lines.push("");
6751
+ lines.push(
6752
+ `_${manualOnly.length - MANUAL_ONLY_LIMIT} more not listed here. The JSON artifact has all ${manualOnly.length}._`
6753
+ );
6754
+ }
6755
+ lines.push("");
6756
+ }
6757
+ if (analysis.create.length > 0) {
6758
+ lines.push("## Stories with no case");
6759
+ lines.push("");
6760
+ for (const planned of analysis.create) lines.push(`- ${planned.scenario}`);
6761
+ lines.push("");
6762
+ }
6763
+ if (analysis.orphaned.length > 0) {
6764
+ lines.push("## Cases with no story");
6765
+ lines.push("");
6766
+ lines.push("Bound to a story that has since been deleted. Nothing was removed automatically.");
6767
+ lines.push("");
6768
+ for (const orphan of analysis.orphaned) {
6769
+ lines.push(`- [${orphan.caseId}](${orphan.url}) ${escapeCell2(orphan.title)}`);
6770
+ }
6771
+ lines.push("");
6772
+ }
6773
+ const sections = sectionBreakdown(analysis);
6774
+ if (sections.length > 1) {
6775
+ lines.push("## By section");
6776
+ lines.push("");
6777
+ lines.push("| Section | Automated | Total |");
6778
+ lines.push("| --- | ---: | ---: |");
6779
+ for (const section of sections) {
6780
+ lines.push(`| ${escapeCell2(section.name)} | ${section.automated} | ${section.total} |`);
6781
+ }
6782
+ lines.push("");
6783
+ }
6784
+ if (analysis.partialRunWarning) {
6785
+ lines.push(`> ${analysis.partialRunWarning}`, "");
6786
+ }
6787
+ return lines.join("\n");
6788
+ }
6789
+ function escapeCell2(text2) {
6790
+ return text2.replace(/\|/g, "\\|");
6791
+ }
6792
+ function buildCoverageJson(analysis) {
6793
+ return {
6794
+ schema: "executable-stories/sync-coverage/v1",
6795
+ ...summarize2(analysis),
6796
+ cases: analysis.remote.map((entry) => ({
6797
+ id: entry.case.id,
6798
+ url: entry.case.url,
6799
+ title: entry.case.title,
6800
+ section: entry.case.section,
6801
+ classification: entry.classification,
6802
+ resembles: entry.resembles,
6803
+ similarity: entry.similarity
6804
+ })),
6805
+ untrackedScenarios: analysis.create.map((c) => c.scenario),
6806
+ orphaned: analysis.orphaned.map((o) => ({ caseId: o.caseId, url: o.url, title: o.title })),
6807
+ sections: sectionBreakdown(analysis)
6808
+ };
6809
+ }
6810
+ function formatBytes(bytes) {
6811
+ if (bytes < 1024) return `${bytes} B`;
6812
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6813
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
6814
+ }
6815
+ function renderPlan(analysis, opts) {
6816
+ const lines = [];
6817
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"}`);
6818
+ lines.push("");
6819
+ lines.push(` + create ${pad(analysis.create.length)} cases`);
6820
+ lines.push(` ~ update ${pad(analysis.update.length)} cases`);
6821
+ lines.push(` = unchanged ${pad(analysis.unchanged.length)} cases`);
6822
+ if (analysis.adopted.length > 0) {
6823
+ lines.push(` \xB7 linked ${pad(analysis.adopted.length)} cases (hand-authored, results only)`);
6824
+ }
6825
+ if (analysis.skipped.length > 0) {
6826
+ const edited = analysis.skipped.filter((s) => s.reason === "remote-edited").length;
6827
+ const missing = analysis.skipped.filter((s) => s.reason === "case-missing").length;
6828
+ if (edited > 0) {
6829
+ lines.push(` ! skipped ${pad(edited)} cases (edited in ${analysis.provider} since last sync)`);
6830
+ }
6831
+ if (missing > 0) {
6832
+ lines.push(` ! skipped ${pad(missing)} cases (bound case no longer exists)`);
6833
+ }
6834
+ }
6835
+ if (analysis.orphaned.length > 0) {
6836
+ lines.push(` ? orphaned ${pad(analysis.orphaned.length)} cases (story deleted from codebase, never removed)`);
6837
+ }
6838
+ lines.push(` \u2192 results ${pad(analysis.results.length)} executions`);
6839
+ if (analysis.attachments.files > 0) {
6840
+ const roles = Object.entries(analysis.attachments.byRole).map(([role, count2]) => `${count2} ${role}${count2 === 1 ? "" : "s"}`).join(", ");
6841
+ lines.push(
6842
+ ` \u2191 upload ${pad(analysis.attachments.files)} attachments (${roles}, ${formatBytes(analysis.attachments.bytes)})`
6843
+ );
6844
+ }
6845
+ for (const oversized of analysis.attachments.oversized) {
6846
+ lines.push(
6847
+ ` ! oversized ${oversized.filename} (${formatBytes(oversized.bytes)}, provider limit ${formatBytes(oversized.limit)})`
6848
+ );
6849
+ }
6850
+ for (const capability of analysis.unsupported) {
6851
+ lines.push(` ! ${analysis.provider} does not support ${capability} \u2014 those changes are not applied`);
6852
+ }
6853
+ if (analysis.driftUncheckable > 0) {
6854
+ lines.push(
6855
+ ` ! ${analysis.driftUncheckable} case(s): ${analysis.provider} returned no body, so a hand edit to them cannot be detected`
6856
+ );
6857
+ }
6858
+ if (analysis.partialRunWarning) {
6859
+ lines.push("");
6860
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6861
+ }
6862
+ if (analysis.skipped.some((s) => s.reason === "remote-edited")) {
6863
+ lines.push("");
6864
+ lines.push("Skipped cases were edited by hand after we last wrote them. Nothing overwrites them.");
6865
+ for (const skip of analysis.skipped.filter((s) => s.reason === "remote-edited")) {
6866
+ lines.push(` ${skip.caseId} ${skip.title} ${skip.url}`);
6867
+ }
6868
+ }
6869
+ if (opts.dryRun && hasWork(analysis)) {
6870
+ lines.push("");
6871
+ lines.push("Nothing was written. Run the same command with --apply to make these changes.");
6872
+ }
6873
+ return lines.join("\n");
6874
+ }
6875
+ function hasWork(analysis) {
6876
+ return analysis.create.length > 0 || analysis.update.length > 0 || analysis.results.length > 0;
6877
+ }
6878
+ function renderApplyResult(result) {
6879
+ const lines = [];
6880
+ for (const created of result.created) {
6881
+ lines.push(` + ${created.caseId} ${created.scenario} ${created.url}`);
6882
+ }
6883
+ for (const updated of result.updated) {
6884
+ lines.push(` ~ ${updated.caseId} ${updated.scenario}`);
6885
+ }
6886
+ lines.push("");
6887
+ lines.push(
6888
+ `Created ${result.created.length}, updated ${result.updated.length}, recorded ${result.resultsRecorded} execution(s), uploaded ${result.attachmentsUploaded} attachment(s).`
6889
+ );
6890
+ if (result.runUrl) lines.push(`Run: ${result.runUrl}`);
6891
+ for (const skipped of result.resultsSkipped) {
6892
+ lines.push(` ! result for case ${skipped.caseId} not recorded: ${skipped.reason}`);
6893
+ }
6894
+ for (const error of result.errors) {
6895
+ lines.push(` \u2717 ${error}`);
6896
+ }
6897
+ return lines.join("\n");
6898
+ }
6899
+
6900
+ // src/sync/adapters/case-text.ts
6901
+ var BDD_KEYWORDS = ["Given", "When", "Then", "And", "But"];
6902
+ function encodeStepText(step) {
6903
+ const keyword = step.keyword.trim();
6904
+ return keyword ? `${keyword} ${step.text}` : step.text;
6905
+ }
6906
+ function decodeStepText(content) {
6907
+ const trimmed = content.trim();
6908
+ const firstSpace = trimmed.indexOf(" ");
6909
+ if (firstSpace > 0) {
6910
+ const head = trimmed.slice(0, firstSpace);
6911
+ if (BDD_KEYWORDS.some((keyword) => keyword.toLowerCase() === head.toLowerCase())) {
6912
+ return { keyword: head, text: trimmed.slice(firstSpace + 1) };
6913
+ }
6914
+ }
6915
+ return { keyword: "", text: trimmed };
6916
+ }
6917
+ function encodeDescription(body) {
6918
+ if (body.links.length === 0) return body.description;
6919
+ const links = body.links.map((link2) => `- [${link2.label}](${link2.url})`).join("\n");
6920
+ return `${body.description}
6921
+
6922
+ ${links}`;
6923
+ }
6924
+ function decodeDescription(raw) {
6925
+ const links = [];
6926
+ const lines = raw.split("\n");
6927
+ let cut = lines.length;
6928
+ for (let index = lines.length - 1; index >= 0; index--) {
6929
+ const line = lines[index].trim();
6930
+ if (line === "") continue;
6931
+ const match = /^- \[([^\]]+)\]\(([^)]+)\)$/.exec(line);
6932
+ if (!match) break;
6933
+ links.unshift({ label: match[1], url: match[2] });
6934
+ cut = index;
6935
+ }
6936
+ return { description: lines.slice(0, cut).join("\n").trim(), links };
6937
+ }
6938
+
6939
+ // src/sync/adapters/testrail.ts
6940
+ var DEFAULT_STEPS_FIELD = "custom_steps_separated";
6941
+ var DEFAULT_DESCRIPTION_FIELD = "custom_preconds";
6942
+ var DEFAULT_MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
6943
+ var PAGE_LIMIT = 250;
6944
+ var MAX_ATTEMPTS = 3;
6945
+ function unwrapList(payload, key) {
6946
+ if (Array.isArray(payload)) return payload;
6947
+ if (payload && typeof payload === "object") {
6948
+ const list = payload[key];
6949
+ if (Array.isArray(list)) return list;
6950
+ }
6951
+ return [];
6952
+ }
6953
+ function encodeElapsed(durationMs) {
6954
+ const seconds = Math.floor(durationMs / 1e3);
6955
+ if (seconds < 1) return void 0;
6956
+ if (seconds < 60) return `${seconds}s`;
6957
+ const minutes = Math.floor(seconds / 60);
6958
+ const rest = seconds % 60;
6959
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
6960
+ }
6961
+ function authHint(status) {
6962
+ if (status === 401) {
6963
+ return "\n TESTRAIL_USERNAME must be the login email and TESTRAIL_API_KEY an API key from My Settings -> API Keys. A password fails here when the instance enforces API keys.";
6964
+ }
6965
+ if (status === 403) {
6966
+ return "\n The credentials were accepted but the request was refused. Usually the API is disabled: an admin enables it under Administration -> Site Settings -> API.";
6967
+ }
6968
+ return "";
6969
+ }
6970
+ function createTestRailProvider(config, auth, deps) {
6971
+ const base = config.url.replace(/\/$/, "");
6972
+ const stepsField = config.fields?.steps ?? DEFAULT_STEPS_FIELD;
6973
+ const descriptionField = config.fields?.description ?? DEFAULT_DESCRIPTION_FIELD;
6974
+ const basicAuth = Buffer.from(`${auth.username}:${auth.apiKey}`).toString("base64");
6975
+ let projectName;
6976
+ let suiteName;
6977
+ const sectionNames = /* @__PURE__ */ new Map();
6978
+ async function api(method, init) {
6979
+ const url = `${base}/index.php?/api/v2/${method}`;
6980
+ const headers = {
6981
+ Authorization: `Basic ${basicAuth}`
6982
+ };
6983
+ let body;
6984
+ if (init?.form) {
6985
+ body = init.form;
6986
+ } else if (init?.body !== void 0) {
6987
+ headers["Content-Type"] = "application/json";
6988
+ body = JSON.stringify(init.body);
6989
+ }
6990
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
6991
+ const response = await deps.fetch(url, {
6992
+ method: body === void 0 ? "GET" : "POST",
6993
+ headers,
6994
+ body
6995
+ });
6996
+ if (response.status === 429 && attempt < MAX_ATTEMPTS - 1) {
6997
+ const retryAfter = Number(response.headers.get("retry-after") ?? "1");
6998
+ deps.logger.warn(`TestRail rate limit hit, retrying in ${retryAfter}s`);
6999
+ await new Promise((resolve12) => setTimeout(resolve12, Math.max(1, retryAfter) * 1e3));
7000
+ continue;
7001
+ }
7002
+ const text2 = await response.text();
7003
+ if (text2.length === 0) {
7004
+ if (response.ok) return void 0;
7005
+ throw new Error(
7006
+ `TestRail ${method} failed (${response.status}) with an empty response${authHint(response.status)}`
7007
+ );
7008
+ }
7009
+ let parsed;
7010
+ try {
7011
+ parsed = JSON.parse(text2);
7012
+ } catch {
7013
+ throw new Error(
7014
+ `TestRail ${method} returned HTML rather than JSON (status ${response.status}).
7015
+ Check that sync.testrail.url is the instance root, e.g. https://acme.testrail.io, with no path after it.`
7016
+ );
7017
+ }
7018
+ if (!response.ok) {
7019
+ const detail = parsed.error ?? text2;
7020
+ throw new Error(
7021
+ `TestRail ${method} failed (${response.status}): ${detail}${authHint(response.status)}`
7022
+ );
7023
+ }
7024
+ return parsed;
7025
+ }
7026
+ throw new Error(`TestRail ${method} failed: rate limited after ${MAX_ATTEMPTS} attempts`);
7027
+ }
7028
+ async function paginate(method, key) {
7029
+ const all = [];
7030
+ for (let offset = 0; ; offset += PAGE_LIMIT) {
7031
+ const page = await api(`${method}&limit=${PAGE_LIMIT}&offset=${offset}`);
7032
+ const items = unwrapList(page, key);
7033
+ all.push(...items);
7034
+ if (items.length < PAGE_LIMIT) return all;
7035
+ }
7036
+ }
7037
+ function caseUrl(id) {
7038
+ return `${base}/index.php?/cases/view/${id}`;
7039
+ }
7040
+ function toRemoteCase(raw, context) {
7041
+ if (raw?.id === void 0) {
7042
+ throw new Error(
7043
+ `TestRail ${context} returned no case id. The response was: ${JSON.stringify(raw)?.slice(0, 200)}`
7044
+ );
7045
+ }
7046
+ const title = typeof raw.title === "string" ? raw.title : "";
7047
+ const steps = Array.isArray(raw[stepsField]) ? raw[stepsField] : [];
7048
+ const rawDescription = typeof raw[descriptionField] === "string" ? raw[descriptionField] : "";
7049
+ const { description, links } = decodeDescription(rawDescription);
7050
+ return {
7051
+ id: String(raw.id),
7052
+ url: caseUrl(raw.id),
7053
+ title,
7054
+ section: raw.section_id === void 0 ? void 0 : sectionNames.get(raw.section_id),
7055
+ body: {
7056
+ title,
7057
+ steps: steps.map((step) => decodeStepText(step.content ?? "")),
7058
+ description,
7059
+ links
7060
+ }
7061
+ };
7062
+ }
7063
+ function bodyToPayload(body) {
7064
+ const payload = {
7065
+ title: body.title,
7066
+ [stepsField]: body.steps.map((step) => ({ content: encodeStepText(step), expected: "" })),
7067
+ [descriptionField]: encodeDescription(body)
7068
+ };
7069
+ if (config.templateId !== void 0) payload["template_id"] = config.templateId;
7070
+ return payload;
7071
+ }
7072
+ const suiteQuery = config.suiteId === void 0 ? "" : `&suite_id=${config.suiteId}`;
7073
+ return {
7074
+ name: "testrail",
7075
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
7076
+ describeTarget() {
7077
+ const project = projectName ?? `project ${config.projectId}`;
7078
+ const suite = suiteName ?? (config.suiteId === void 0 ? void 0 : `suite ${config.suiteId}`);
7079
+ return suite ? `${project} / ${suite}` : project;
7080
+ },
7081
+ async listCases() {
7082
+ try {
7083
+ const project = await api(`get_project/${config.projectId}`);
7084
+ projectName = project?.name;
7085
+ if (config.suiteId !== void 0) {
7086
+ const suite = await api(`get_suite/${config.suiteId}`);
7087
+ suiteName = suite?.name;
7088
+ }
7089
+ const sections = await paginate(
7090
+ `get_sections/${config.projectId}${suiteQuery}`,
7091
+ "sections"
7092
+ );
7093
+ for (const section of sections) sectionNames.set(section.id, section.name);
7094
+ } catch (err) {
7095
+ deps.logger.warn(`TestRail metadata lookup failed, continuing without names: ${err.message}`);
7096
+ }
7097
+ const cases = await paginate(`get_cases/${config.projectId}${suiteQuery}`, "cases");
7098
+ return cases.map((raw) => toRemoteCase(raw, "get_cases"));
7099
+ },
7100
+ async createCase(body) {
7101
+ if (config.sectionId === void 0) {
7102
+ throw new Error(
7103
+ "TestRail needs a sectionId to create cases. Set sync.testrail.sectionId to the section new cases should land in."
7104
+ );
7105
+ }
7106
+ const created = await api(`add_case/${config.sectionId}`, {
7107
+ body: bodyToPayload(body)
7108
+ });
7109
+ return toRemoteCase(created, `add_case/${config.sectionId}`);
7110
+ },
7111
+ async updateCase(id, body) {
7112
+ const updated = await api(`update_case/${id}`, { body: bodyToPayload(body) });
7113
+ return toRemoteCase(updated, `update_case/${id}`);
7114
+ },
7115
+ async recordResults(results) {
7116
+ const statusIds = {
7117
+ passed: config.statusIds?.passed ?? 1,
7118
+ failed: config.statusIds?.failed ?? 5,
7119
+ skipped: config.statusIds?.skipped
7120
+ };
7121
+ const skipped = [];
7122
+ const sendable = [];
7123
+ for (const result of results) {
7124
+ const caseId = Number(result.caseId);
7125
+ if (!Number.isFinite(caseId)) {
7126
+ skipped.push({ caseId: result.caseId, reason: "case id is not numeric" });
7127
+ continue;
7128
+ }
7129
+ const statusId = result.status === "skipped" ? statusIds.skipped : statusIds[result.status];
7130
+ if (statusId === void 0) {
7131
+ skipped.push({
7132
+ caseId: result.caseId,
7133
+ reason: "no TestRail status id configured for skipped (set sync.testrail.statusIds.skipped)"
7134
+ });
7135
+ continue;
7136
+ }
7137
+ sendable.push({ result, caseId, statusId });
7138
+ }
7139
+ if (sendable.length === 0) {
7140
+ return { recorded: 0, skipped, attachmentsUploaded: 0 };
7141
+ }
7142
+ let runId = config.runId;
7143
+ if (runId === void 0) {
7144
+ const name = `${config.runName ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`;
7145
+ const run = await api(`add_run/${config.projectId}`, {
7146
+ body: {
7147
+ ...config.suiteId === void 0 ? {} : { suite_id: config.suiteId },
7148
+ name,
7149
+ include_all: false,
7150
+ // Deduplicated: two stories can carry the same ticket id, and
7151
+ // TestRail rejects a run whose case list repeats one.
7152
+ case_ids: [...new Set(sendable.map((s) => s.caseId))]
7153
+ }
7154
+ });
7155
+ runId = run.id;
7156
+ }
7157
+ const runUrl = `${base}/index.php?/runs/view/${runId}`;
7158
+ const payload = sendable.map(({ result, caseId, statusId }) => {
7159
+ const elapsed = encodeElapsed(result.durationMs);
7160
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
7161
+ return {
7162
+ case_id: caseId,
7163
+ status_id: statusId,
7164
+ ...comment ? { comment } : {},
7165
+ ...elapsed ? { elapsed } : {}
7166
+ };
7167
+ });
7168
+ const recorded = unwrapList(
7169
+ await api(`add_results_for_cases/${runId}`, { body: { results: payload } }),
7170
+ "results"
7171
+ );
7172
+ let attachmentsUploaded = 0;
7173
+ for (const [index, entry] of recorded.entries()) {
7174
+ const source = sendable[index]?.result;
7175
+ for (const attachment of source?.attachments ?? []) {
7176
+ try {
7177
+ const form = new FormData();
7178
+ const bytes = attachment.body.buffer.slice(
7179
+ attachment.body.byteOffset,
7180
+ attachment.body.byteOffset + attachment.body.byteLength
7181
+ );
7182
+ form.append(
7183
+ "attachment",
7184
+ new Blob([bytes], { type: attachment.mediaType }),
7185
+ attachment.filename
7186
+ );
7187
+ await api(`add_attachment_to_result/${entry.id}`, { form });
7188
+ attachmentsUploaded += 1;
7189
+ } catch (err) {
7190
+ deps.logger.warn(
7191
+ `TestRail attachment "${attachment.filename}" failed: ${err.message}`
7192
+ );
7193
+ }
7194
+ }
7195
+ }
7196
+ if (config.closeRun && config.runId === void 0) {
7197
+ await api(`close_run/${runId}`, { body: {} });
7198
+ }
7199
+ return {
7200
+ runId: String(runId),
7201
+ runUrl,
7202
+ recorded: recorded.length,
7203
+ skipped,
7204
+ attachmentsUploaded
7205
+ };
7206
+ }
7207
+ };
7208
+ }
7209
+
7210
+ // src/sync/adapters/xray.ts
7211
+ var DEFAULT_XRAY_BASE = "https://xray.cloud.getxray.app";
7212
+ var DEFAULT_TEST_TYPE = "Manual";
7213
+ var DEFAULT_MAX_ATTACHMENT_BYTES2 = 32 * 1024 * 1024;
7214
+ var PAGE_LIMIT2 = 100;
7215
+ function toAdf(text2) {
7216
+ const paragraphs = text2.split("\n\n").filter((block) => block.trim() !== "");
7217
+ return {
7218
+ version: 1,
7219
+ type: "doc",
7220
+ content: paragraphs.length === 0 ? [{ type: "paragraph", content: [] }] : paragraphs.map((block) => ({
7221
+ type: "paragraph",
7222
+ content: [{ type: "text", text: block }]
7223
+ }))
7224
+ };
7225
+ }
7226
+ function fromAdf(value) {
7227
+ if (typeof value === "string") return value;
7228
+ if (!value || typeof value !== "object") return "";
7229
+ const blocks = [];
7230
+ const walk = (node, collected) => {
7231
+ if (!node || typeof node !== "object") return;
7232
+ const typed = node;
7233
+ if (typed.type === "text" && typeof typed.text === "string") collected.push(typed.text);
7234
+ for (const child of typed.content ?? []) walk(child, collected);
7235
+ };
7236
+ for (const node of value.content ?? []) {
7237
+ const collected = [];
7238
+ walk(node, collected);
7239
+ blocks.push(collected.join(""));
7240
+ }
7241
+ return blocks.join("\n\n").trim();
7242
+ }
7243
+ function createXrayProvider(config, auth, deps) {
7244
+ const xrayBase = (config.xrayBaseUrl ?? DEFAULT_XRAY_BASE).replace(/\/$/, "");
7245
+ const jiraBase = config.jiraBaseUrl.replace(/\/$/, "");
7246
+ const jql = config.jql ?? `project = "${config.projectKey}" AND issuetype = Test`;
7247
+ const issueIds = /* @__PURE__ */ new Map();
7248
+ let token;
7249
+ async function authenticate() {
7250
+ if (token) return token;
7251
+ const response = await deps.fetch(`${xrayBase}/api/v2/authenticate`, {
7252
+ method: "POST",
7253
+ headers: { "Content-Type": "application/json" },
7254
+ body: JSON.stringify({ client_id: auth.clientId, client_secret: auth.clientSecret })
7255
+ });
7256
+ const text2 = await response.text();
7257
+ if (!response.ok) {
7258
+ throw new Error(
7259
+ `Xray authentication failed (${response.status}): ${text2}
7260
+ XRAY_CLIENT_ID and XRAY_CLIENT_SECRET come from Jira -> Apps -> Xray -> API Keys. A Jira API token is a different credential and is rejected here.`
7261
+ );
7262
+ }
7263
+ token = JSON.parse(text2).replace(/^"|"$/g, "");
7264
+ return token;
7265
+ }
7266
+ async function graphql(query, variables) {
7267
+ const bearer = await authenticate();
7268
+ const response = await deps.fetch(`${xrayBase}/api/v2/graphql`, {
7269
+ method: "POST",
7270
+ headers: {
7271
+ Authorization: `Bearer ${bearer}`,
7272
+ "Content-Type": "application/json"
7273
+ },
7274
+ body: JSON.stringify({ query, variables })
7275
+ });
7276
+ const text2 = await response.text();
7277
+ if (!response.ok) throw new Error(`Xray GraphQL failed (${response.status}): ${text2}`);
7278
+ const payload = JSON.parse(text2);
7279
+ if (payload.errors?.length) {
7280
+ throw new Error(`Xray GraphQL error: ${payload.errors.map((e) => e.message).join("; ")}`);
7281
+ }
7282
+ return payload.data;
7283
+ }
7284
+ function issueUrl(key) {
7285
+ return `${jiraBase}/browse/${key}`;
7286
+ }
7287
+ function toRemoteCase(test) {
7288
+ const key = test.jira?.key ?? test.issueId;
7289
+ if (!key) {
7290
+ throw new Error(
7291
+ `Xray returned a test with neither a Jira key nor an issue id: ${JSON.stringify(test)?.slice(0, 200)}`
7292
+ );
7293
+ }
7294
+ if (test.jira?.key) issueIds.set(test.jira.key, test.issueId);
7295
+ const { description, links } = decodeDescription(fromAdf(test.jira?.description));
7296
+ return {
7297
+ id: key,
7298
+ url: issueUrl(key),
7299
+ title: test.jira?.summary ?? key,
7300
+ body: {
7301
+ title: test.jira?.summary ?? key,
7302
+ steps: (test.steps ?? []).map((step) => decodeStepText(step.action ?? "")),
7303
+ description,
7304
+ links
7305
+ }
7306
+ };
7307
+ }
7308
+ async function jiraUpdate(key, fields) {
7309
+ if (!auth.jiraEmail || !auth.jiraToken) {
7310
+ deps.logger.warn(
7311
+ `Xray: summary/description for ${key} left unchanged. Set JIRA_EMAIL and JIRA_TOKEN to update Jira fields (steps are updated either way).`
7312
+ );
7313
+ return;
7314
+ }
7315
+ const basic = Buffer.from(`${auth.jiraEmail}:${auth.jiraToken}`).toString("base64");
7316
+ const response = await deps.fetch(`${jiraBase}/rest/api/3/issue/${key}`, {
7317
+ method: "PUT",
7318
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
7319
+ body: JSON.stringify({ fields })
7320
+ });
7321
+ if (!response.ok) {
7322
+ throw new Error(`Jira update of ${key} failed (${response.status}): ${await response.text()}`);
7323
+ }
7324
+ }
7325
+ return {
7326
+ name: "xray",
7327
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES2,
7328
+ describeTarget() {
7329
+ return `${config.projectKey} (${jiraBase})`;
7330
+ },
7331
+ async listCases() {
7332
+ const tests = [];
7333
+ for (let start = 0; ; start += PAGE_LIMIT2) {
7334
+ const data = await graphql(
7335
+ `query($jql: String!, $limit: Int!, $start: Int!) {
7336
+ getTests(jql: $jql, limit: $limit, start: $start) {
7337
+ total
7338
+ results {
7339
+ issueId
7340
+ jira(fields: ["key", "summary", "description"])
7341
+ steps { id action data result }
7342
+ }
7343
+ }
7344
+ }`,
7345
+ { jql, limit: PAGE_LIMIT2, start }
7346
+ );
7347
+ const page = data.getTests?.results ?? [];
7348
+ tests.push(...page);
7349
+ if (page.length < PAGE_LIMIT2) break;
7350
+ }
7351
+ return tests.map(toRemoteCase);
7352
+ },
7353
+ async createCase(body) {
7354
+ const data = await graphql(
7355
+ `mutation($testType: UpdateTestTypeInput!, $steps: [CreateStepInput], $jira: JSON!) {
7356
+ createTest(testType: $testType, steps: $steps, jira: $jira) {
7357
+ test { issueId jira(fields: ["key", "summary", "description"]) }
7358
+ warnings
7359
+ }
7360
+ }`,
7361
+ {
7362
+ testType: { name: config.testType ?? DEFAULT_TEST_TYPE },
7363
+ steps: body.steps.map((step) => ({ action: encodeStepText(step), result: "" })),
7364
+ jira: {
7365
+ fields: {
7366
+ summary: body.title,
7367
+ description: toAdf(encodeDescription(body)),
7368
+ project: { key: config.projectKey }
7369
+ }
7370
+ }
7371
+ }
7372
+ );
7373
+ const created = data.createTest?.test;
7374
+ if (!created) throw new Error(`Xray createTest returned no test for "${body.title}"`);
7375
+ for (const warning of data.createTest?.warnings ?? []) deps.logger.warn(`Xray: ${warning}`);
7376
+ return toRemoteCase(created);
7377
+ },
7378
+ async updateCase(id, body) {
7379
+ const issueId = issueIds.get(id);
7380
+ if (!issueId) {
7381
+ throw new Error(`Xray: no internal issue id cached for ${id}. Run listCases first.`);
7382
+ }
7383
+ const current = await graphql(
7384
+ `query($issueId: String!) { getTest(issueId: $issueId) { steps { id } } }`,
7385
+ { issueId }
7386
+ );
7387
+ const existing = current.getTest?.steps ?? [];
7388
+ for (const [index, step] of body.steps.entries()) {
7389
+ const action = encodeStepText(step);
7390
+ const target = existing[index];
7391
+ if (target) {
7392
+ await graphql(
7393
+ `mutation($stepId: String!, $step: UpdateStepInput!) {
7394
+ updateTestStep(stepId: $stepId, step: $step)
7395
+ }`,
7396
+ { stepId: target.id, step: { action, result: "" } }
7397
+ );
7398
+ } else {
7399
+ await graphql(
7400
+ `mutation($issueId: String!, $step: CreateStepInput!) {
7401
+ addTestStep(issueId: $issueId, step: $step) { id }
7402
+ }`,
7403
+ { issueId, step: { action, result: "" } }
7404
+ );
7405
+ }
7406
+ }
7407
+ for (const surplus of existing.slice(body.steps.length).reverse()) {
7408
+ await graphql(`mutation($stepId: String!) { removeTestStep(stepId: $stepId) }`, {
7409
+ stepId: surplus.id
7410
+ });
7411
+ }
7412
+ await jiraUpdate(id, {
7413
+ summary: body.title,
7414
+ description: toAdf(encodeDescription(body))
7415
+ });
7416
+ return {
7417
+ id,
7418
+ url: issueUrl(id),
7419
+ title: body.title,
7420
+ body
7421
+ };
7422
+ },
7423
+ async recordResults(results) {
7424
+ const statuses = {
7425
+ passed: config.statuses?.passed ?? "PASSED",
7426
+ failed: config.statuses?.failed ?? "FAILED",
7427
+ skipped: config.statuses?.skipped ?? "TODO"
7428
+ };
7429
+ let attachmentsUploaded = 0;
7430
+ const tests = results.map((result) => {
7431
+ const evidence = (result.attachments ?? []).map((attachment) => {
7432
+ attachmentsUploaded += 1;
7433
+ return {
7434
+ data: Buffer.from(attachment.body).toString("base64"),
7435
+ filename: attachment.filename,
7436
+ contentType: attachment.mediaType
7437
+ };
7438
+ });
7439
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
7440
+ return {
7441
+ testKey: result.caseId,
7442
+ status: statuses[result.status],
7443
+ ...comment ? { comment } : {},
7444
+ ...evidence.length > 0 ? { evidence } : {}
7445
+ };
7446
+ });
7447
+ const bearer = await authenticate();
7448
+ const payload = {
7449
+ ...config.testExecutionKey ? { testExecutionKey: config.testExecutionKey } : {},
7450
+ info: {
7451
+ summary: `${config.executionSummary ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`,
7452
+ project: config.projectKey,
7453
+ ...config.testPlanKey ? { testPlanKey: config.testPlanKey } : {}
7454
+ },
7455
+ tests
7456
+ };
7457
+ const response = await deps.fetch(`${xrayBase}/api/v2/import/execution`, {
7458
+ method: "POST",
7459
+ headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
7460
+ body: JSON.stringify(payload)
7461
+ });
7462
+ const text2 = await response.text();
7463
+ if (!response.ok) {
7464
+ throw new Error(`Xray import execution failed (${response.status}): ${text2}`);
7465
+ }
7466
+ const imported = JSON.parse(text2);
7467
+ return {
7468
+ runId: imported.key,
7469
+ runUrl: imported.key ? issueUrl(imported.key) : void 0,
7470
+ recorded: tests.length,
7471
+ skipped: [],
7472
+ attachmentsUploaded
7473
+ };
7474
+ }
7475
+ };
7476
+ }
7477
+
7478
+ // src/sync/adapters/index.ts
7479
+ var PROVIDER_NAMES = ["testrail", "xray"];
7480
+ function isProviderName(value) {
7481
+ return PROVIDER_NAMES.includes(value);
7482
+ }
7483
+ function required(env, name, hint) {
7484
+ const value = env[name];
7485
+ if (!value) throw new Error(`Missing ${name}. ${hint}`);
7486
+ return value;
7487
+ }
7488
+ function buildProvider(args, deps) {
7489
+ const { name, targets, env } = args;
7490
+ if (name === "testrail") {
7491
+ const config2 = targets.testrail;
7492
+ if (!config2) {
7493
+ throw new Error(
7494
+ 'No TestRail config found. Add a `sync: { testrail: { url, projectId } }` block to executable-stories.config.mjs, or run "executable-stories sync testrail --init".'
7495
+ );
7496
+ }
7497
+ const provider2 = createTestRailProvider(
7498
+ config2,
7499
+ {
7500
+ username: required(env, "TESTRAIL_USERNAME", "This is the login email of the TestRail account."),
7501
+ apiKey: required(
7502
+ env,
7503
+ "TESTRAIL_API_KEY",
7504
+ "Generate one in TestRail under My Settings -> API Keys. A password will not work when the instance requires API keys."
7505
+ )
7506
+ },
7507
+ deps
7508
+ );
7509
+ return {
7510
+ provider: provider2,
7511
+ // TestRail ids are numeric and conventionally written "C1234" in a ticket,
7512
+ // so the prefix is decoration and gets stripped.
7513
+ engineDefaults: { ticketPrefix: "C", ticketPrefixStrip: true }
7514
+ };
7515
+ }
7516
+ const config = targets.xray;
7517
+ if (!config) {
7518
+ throw new Error(
7519
+ 'No Xray config found. Add a `sync: { xray: { jiraBaseUrl, projectKey } }` block to executable-stories.config.mjs, or run "executable-stories sync xray --init".'
7520
+ );
7521
+ }
7522
+ const provider = createXrayProvider(
7523
+ config,
7524
+ {
7525
+ clientId: required(env, "XRAY_CLIENT_ID", "Create an API key pair in Jira under Apps -> Xray -> API Keys."),
7526
+ clientSecret: required(env, "XRAY_CLIENT_SECRET", "This is the secret half of the Xray API key pair."),
7527
+ jiraEmail: env["JIRA_EMAIL"],
7528
+ jiraToken: env["JIRA_TOKEN"]
7529
+ },
7530
+ deps
7531
+ );
7532
+ return {
7533
+ provider,
7534
+ // An Xray case id is a Jira issue key, so the project prefix is part of the
7535
+ // id and must survive.
7536
+ engineDefaults: { ticketPrefix: `${config.projectKey}-`, ticketPrefixStrip: false }
7537
+ };
7538
+ }
7539
+
7540
+ // src/index.ts
7541
+ import {
7542
+ STORY_REPORT_SCHEMA_VERSION,
7543
+ STORY_REPORT_SCHEMA_MAJOR
7544
+ } from "executable-stories-core/types/story-report";
7545
+ import { ES_THEME_TOKENS_CSS as ES_THEME_TOKENS_CSS2, ES_THEME_TOKEN_VALUES } from "executable-stories-core/theme/tokens";
7546
+ import { canonicalizeRun as canonicalizeRun3 } from "executable-stories-core/converters/acl/index";
7547
+ import { normalizeStatus } from "executable-stories-core/converters/acl/index";
7548
+ import { generateTestCaseId } from "executable-stories-core/converters/acl/index";
7549
+ import { generateRunId } from "executable-stories-core/converters/acl/index";
7550
+ import { slugify as slugify2 } from "executable-stories-core/converters/acl/index";
7551
+ import { deriveStepResults } from "executable-stories-core/converters/acl/index";
7552
+ import { mergeStepResults } from "executable-stories-core/converters/acl/index";
7553
+ import { resolveAttachment } from "executable-stories-core/converters/acl/index";
7554
+ import { resolveAttachments } from "executable-stories-core/converters/acl/index";
7555
+ import {
7556
+ validateCanonicalRun,
7557
+ assertValidRun
7558
+ } from "executable-stories-core/converters/acl/validate";
7559
+
7560
+ // src/watch.ts
7561
+ import * as fs5 from "fs";
7562
+ import * as path6 from "path";
7563
+ import { canonicalizeRun } from "executable-stories-core/converters/acl/index";
7564
+ import { synthesizeStories } from "executable-stories-core/converters/synthesize";
7565
+ function toRun(data, inputType, synthesize) {
7566
+ if (inputType === "canonical") return data;
7567
+ let raw = data;
7568
+ if (synthesize) raw = synthesizeStories(raw);
7569
+ return canonicalizeRun(raw);
7570
+ }
7571
+ async function regenerateRun(options, deps = {}) {
7572
+ const read = deps.readFile ?? ((filePath) => fs5.readFileSync(filePath, "utf8"));
7573
+ const data = JSON.parse(read(path6.resolve(options.input)));
7574
+ const run = toRun(data, options.inputType ?? "raw", options.synthesize !== false);
7575
+ const generator = new ReportGenerator({
7576
+ formats: options.formats,
7577
+ outputDir: options.outputDir,
7578
+ outputName: options.outputName
7579
+ });
7580
+ const result = await generator.generate(run);
7581
+ return { files: [...result.values()].flat(), run };
7582
+ }
7583
+ async function regenerateArtifacts(options, deps = {}) {
7584
+ return (await regenerateRun(options, deps)).files;
7585
+ }
7586
+ function startWatch(options, deps = {}) {
7587
+ const log = deps.log ?? ((message) => console.log(message));
7588
+ const regenerate = deps.regenerate ?? ((input) => regenerateArtifacts({ ...options, input }, deps));
7589
+ const watchFn = deps.watch ?? ((filePath, listener) => fs5.watch(filePath, listener));
7590
+ const debounceMs = options.debounceMs ?? 150;
7591
+ let timer;
7592
+ let running = false;
7593
+ let pending = false;
7594
+ const run = async () => {
7595
+ if (running) {
7596
+ pending = true;
7597
+ return;
7598
+ }
7599
+ running = true;
7600
+ try {
7601
+ const files = await regenerate(options.input);
7602
+ log(`Regenerated ${files.length} artifact file(s) from ${options.input}`);
7603
+ } catch (error) {
7604
+ log(`Watch regeneration failed: ${error.message}`);
7605
+ } finally {
7606
+ running = false;
7607
+ if (pending) {
7608
+ pending = false;
7609
+ trigger();
7610
+ }
7611
+ }
7612
+ };
7613
+ const trigger = () => {
7614
+ if (timer) clearTimeout(timer);
7615
+ timer = setTimeout(() => void run(), debounceMs);
7616
+ };
7617
+ trigger();
7618
+ const watcher = watchFn(path6.resolve(options.input), trigger);
7619
+ return {
7620
+ close: () => {
7621
+ if (timer) clearTimeout(timer);
7622
+ watcher.close();
7623
+ }
7624
+ };
7625
+ }
7626
+
7627
+ // src/index.ts
7628
+ import { advanceState, initialRunState } from "executable-stories-core";
7629
+ import { toStoryReport as toStoryReport6 } from "executable-stories-core/converters/story-report";
7630
+
7631
+ // src/publishers/confluence.ts
7632
+ function parseAdf(adf) {
7633
+ let parsed;
7634
+ try {
7635
+ parsed = JSON.parse(adf);
7636
+ } catch (err) {
7637
+ throw new Error(
7638
+ `ADF payload is not valid JSON: ${err.message}`
7639
+ );
7640
+ }
7641
+ if (!parsed || typeof parsed !== "object" || parsed.type !== "doc" || !Array.isArray(parsed.content)) {
7642
+ throw new Error(
7643
+ `ADF payload must be an object with { version, type: "doc", content: [...] }`
7644
+ );
7645
+ }
7646
+ return parsed;
7647
+ }
7648
+ function basicAuthHeader(auth) {
7649
+ const raw = `${auth.email}:${auth.token}`;
7650
+ const encoded = typeof Buffer !== "undefined" ? Buffer.from(raw, "utf8").toString("base64") : btoa(raw);
7651
+ return `Basic ${encoded}`;
7652
+ }
7653
+ async function parseErrorBody(response) {
7654
+ try {
7655
+ const body = await response.text();
7656
+ return body ? body.slice(0, 800) : "";
7657
+ } catch {
7658
+ return "";
7659
+ }
7660
+ }
7661
+ async function publishConfluencePage(args, deps) {
7662
+ parseAdf(args.adf);
7663
+ if (!args.pageId && !args.spaceId) {
7664
+ throw new Error(
7665
+ "publishConfluencePage requires either pageId (update) or spaceId (create)"
7666
+ );
7667
+ }
7668
+ if (!args.pageId && !args.title) {
7669
+ throw new Error("Creating a new page requires a title");
7670
+ }
7671
+ const base = args.baseUrl.replace(/\/$/, "");
7672
+ const fetchFn = deps.fetch ?? globalThis.fetch;
7673
+ if (!fetchFn) {
7674
+ throw new Error("No fetch implementation available (Node >= 22 expected)");
7675
+ }
7676
+ const headers = {
7677
+ Authorization: basicAuthHeader(deps.auth),
7678
+ Accept: "application/json",
7679
+ "Content-Type": "application/json"
7680
+ };
7681
+ if (args.pageId) {
7682
+ return updatePage(args, base, headers, fetchFn);
7683
+ }
7684
+ return createPage(args, base, headers, fetchFn);
7685
+ }
7686
+ async function updatePage(args, base, headers, fetchFn) {
7687
+ const getUrl = `${base}/api/v2/pages/${encodeURIComponent(args.pageId)}`;
7688
+ const getResp = await fetchFn(getUrl, { method: "GET", headers });
7689
+ if (!getResp.ok) {
7690
+ const body = await parseErrorBody(getResp);
7691
+ throw new Error(
7692
+ `GET ${getUrl} failed with ${getResp.status} ${getResp.statusText}${body ? `: ${body}` : ""}`
7693
+ );
7694
+ }
7695
+ const current = await getResp.json();
7696
+ const nextVersion = current.version.number + 1;
7697
+ const title = args.title ?? current.title;
7698
+ const putUrl = `${base}/api/v2/pages/${encodeURIComponent(args.pageId)}`;
7699
+ const putResp = await fetchFn(putUrl, {
7700
+ method: "PUT",
7701
+ headers,
7702
+ body: JSON.stringify({
7703
+ id: args.pageId,
7704
+ status: "current",
7705
+ title,
7706
+ body: {
7707
+ representation: "atlas_doc_format",
7708
+ value: args.adf
7709
+ },
7710
+ version: { number: nextVersion }
7711
+ })
7712
+ });
7713
+ if (!putResp.ok) {
7714
+ const body = await parseErrorBody(putResp);
7715
+ throw new Error(
7716
+ `PUT ${putUrl} failed with ${putResp.status} ${putResp.statusText}${body ? `: ${body}` : ""}`
7717
+ );
7718
+ }
7719
+ const updated = await putResp.json();
7720
+ return {
7721
+ id: updated.id,
7722
+ title: updated.title,
7723
+ version: updated.version.number,
7724
+ url: buildPageUrl(base, updated._links?.webui, updated.id),
7725
+ action: "updated"
7726
+ };
7727
+ }
7728
+ async function createPage(args, base, headers, fetchFn) {
7729
+ const body = {
7730
+ spaceId: args.spaceId,
7731
+ status: "current",
7732
+ title: args.title,
7733
+ body: {
7734
+ representation: "atlas_doc_format",
7735
+ value: args.adf
7736
+ }
7737
+ };
7738
+ if (args.parentId) body.parentId = args.parentId;
7739
+ const postUrl = `${base}/api/v2/pages`;
7740
+ const resp = await fetchFn(postUrl, {
7741
+ method: "POST",
7742
+ headers,
7743
+ body: JSON.stringify(body)
7744
+ });
7745
+ if (!resp.ok) {
7746
+ const errBody = await parseErrorBody(resp);
7747
+ throw new Error(
7748
+ `POST ${postUrl} failed with ${resp.status} ${resp.statusText}${errBody ? `: ${errBody}` : ""}`
7749
+ );
7750
+ }
7751
+ const created = await resp.json();
7752
+ return {
7753
+ id: created.id,
7754
+ title: created.title,
7755
+ version: created.version.number,
7756
+ url: buildPageUrl(base, created._links?.webui, created.id),
7757
+ action: "created"
7758
+ };
7759
+ }
7760
+ function buildPageUrl(base, webui, id) {
7761
+ if (webui) {
7762
+ return webui.startsWith("http") ? webui : `${base}${webui}`;
7763
+ }
7764
+ return `${base}/pages/${id}`;
6384
7765
  }
6385
7766
 
6386
7767
  // src/publishers/jira.ts
@@ -7513,7 +8894,7 @@ function statusIcon2(status) {
7513
8894
  return "\u2022";
7514
8895
  }
7515
8896
  }
7516
- function escapeCell2(value) {
8897
+ function escapeCell3(value) {
7517
8898
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
7518
8899
  }
7519
8900
  function intentSummary(intent) {
@@ -7542,7 +8923,7 @@ function renderWeakBand(lines, files) {
7542
8923
  lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
7543
8924
  lines.push("");
7544
8925
  for (const file of weak) {
7545
- const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
8926
+ const covered = file.claims.map((c) => `${escapeCell3(c.scenario)} (${c.strength})`).join(", ");
7546
8927
  lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
7547
8928
  }
7548
8929
  lines.push("");
@@ -7567,7 +8948,7 @@ function renderClaim(lines, claim) {
7567
8948
  );
7568
8949
  }
7569
8950
  if (claim.intent) {
7570
- lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
8951
+ lines.push(`- Why: ${escapeCell3(intentSummary(claim.intent))}`);
7571
8952
  }
7572
8953
  lines.push("");
7573
8954
  }
@@ -7624,7 +9005,7 @@ function renderCodeDiff(lines, evidence) {
7624
9005
  } else {
7625
9006
  for (const ref of annotation.scenarios) {
7626
9007
  lines.push(
7627
- ref.resolved && ref.status ? `- ${statusIcon2(ref.status)} ${escapeCell2(ref.scenario ?? ref.id)} (\`${ref.id}\`)` : `- \u26A0\uFE0F \`${ref.id}\` \u2014 unverified reference (scenario not in this run)`
9008
+ ref.resolved && ref.status ? `- ${statusIcon2(ref.status)} ${escapeCell3(ref.scenario ?? ref.id)} (\`${ref.id}\`)` : `- \u26A0\uFE0F \`${ref.id}\` \u2014 unverified reference (scenario not in this run)`
7628
9009
  );
7629
9010
  }
7630
9011
  }
@@ -7843,9 +9224,9 @@ function renderDiffHunk(file, hunk, anchoredStart, anchoredCount) {
7843
9224
  const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
7844
9225
  return `<tr class="${cls}"><td class="diff-ln">${o}</td><td class="diff-ln">${n}</td><td class="diff-sign">${sign}</td><td class="diff-code">${escapeHtml3(line.text)}</td></tr>`;
7845
9226
  });
7846
- const path18 = file.newPath ?? file.oldPath ?? "";
9227
+ const path20 = file.newPath ?? file.oldPath ?? "";
7847
9228
  return `<div class="diff-hunk">
7848
- <div class="diff-file-header"><code>${escapeHtml3(path18)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
9229
+ <div class="diff-file-header"><code>${escapeHtml3(path20)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
7849
9230
  <table class="diff-table"><tbody>${rows.join("")}</tbody></table>
7850
9231
  </div>`;
7851
9232
  }
@@ -8080,8 +9461,8 @@ applyTheme(getEffectiveTheme());` : "";
8080
9461
  };
8081
9462
 
8082
9463
  // src/deploy/ledger.ts
8083
- import * as fs5 from "fs";
8084
- import * as path6 from "path";
9464
+ import * as fs6 from "fs";
9465
+ import * as path7 from "path";
8085
9466
  function createEmptyLedger() {
8086
9467
  return {
8087
9468
  deployments: [],
@@ -8089,12 +9470,12 @@ function createEmptyLedger() {
8089
9470
  };
8090
9471
  }
8091
9472
  function loadLedger(ledgerPath) {
8092
- const resolved = path6.resolve(ledgerPath);
8093
- if (!fs5.existsSync(resolved)) {
9473
+ const resolved = path7.resolve(ledgerPath);
9474
+ if (!fs6.existsSync(resolved)) {
8094
9475
  return createEmptyLedger();
8095
9476
  }
8096
9477
  try {
8097
- const raw = JSON.parse(fs5.readFileSync(resolved, "utf8"));
9478
+ const raw = JSON.parse(fs6.readFileSync(resolved, "utf8"));
8098
9479
  if (raw.schemaVersion !== 1) {
8099
9480
  throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
8100
9481
  }
@@ -8105,10 +9486,10 @@ function loadLedger(ledgerPath) {
8105
9486
  }
8106
9487
  }
8107
9488
  function saveLedger(ledger, ledgerPath) {
8108
- const resolved = path6.resolve(ledgerPath);
8109
- const dir = path6.dirname(resolved);
8110
- fs5.mkdirSync(dir, { recursive: true });
8111
- fs5.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
9489
+ const resolved = path7.resolve(ledgerPath);
9490
+ const dir = path7.dirname(resolved);
9491
+ fs6.mkdirSync(dir, { recursive: true });
9492
+ fs6.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
8112
9493
  }
8113
9494
  function getLatestDeployment(ledger, environment) {
8114
9495
  return [...ledger.deployments].reverse().find((d) => d.environment === environment);
@@ -8235,11 +9616,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
8235
9616
  const ext = FORMAT_EXTENSIONS[format];
8236
9617
  const effectiveName = outputName + (outputNameSuffix ?? "");
8237
9618
  if (mode === "aggregated") {
8238
- return toPosix(path7.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
9619
+ return toPosix(path8.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
8239
9620
  }
8240
9621
  const normalizedSource = toPosix(sourceFile);
8241
- const dirOfSource = path7.posix.dirname(normalizedSource);
8242
- let baseName = path7.posix.basename(normalizedSource);
9622
+ const dirOfSource = path8.posix.dirname(normalizedSource);
9623
+ let baseName = path8.posix.basename(normalizedSource);
8243
9624
  for (const testExt of TEST_EXTENSIONS) {
8244
9625
  if (baseName.endsWith(testExt)) {
8245
9626
  baseName = baseName.slice(0, -testExt.length);
@@ -8248,12 +9629,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
8248
9629
  }
8249
9630
  const fileName = `${baseName}.${effectiveName}${ext}`;
8250
9631
  if (colocatedStyle === "adjacent") {
8251
- return toPosix(path7.posix.join(dirOfSource, fileName));
9632
+ return toPosix(path8.posix.join(dirOfSource, fileName));
8252
9633
  }
8253
9634
  if (colocatedStyle === "flat") {
8254
- return toPosix(path7.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
9635
+ return toPosix(path8.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
8255
9636
  }
8256
- return toPosix(path7.posix.join(baseOutputDir, dirOfSource, fileName));
9637
+ return toPosix(path8.posix.join(baseOutputDir, dirOfSource, fileName));
8257
9638
  }
8258
9639
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
8259
9640
  const groups = /* @__PURE__ */ new Map();
@@ -8473,8 +9854,8 @@ var ReportGenerator = class {
8473
9854
  if (astroPaths) {
8474
9855
  for (const mdPath of astroPaths) {
8475
9856
  const content = await fsPromises.readFile(mdPath, "utf8");
8476
- const mdDir = path7.dirname(mdPath);
8477
- const assetsDir = path7.resolve(this.options.astro.assetsDir);
9857
+ const mdDir = path8.dirname(mdPath);
9858
+ const assetsDir = path8.resolve(this.options.astro.assetsDir);
8478
9859
  const result = copyMarkdownAssets({
8479
9860
  markdown: content,
8480
9861
  markdownDir: mdDir,
@@ -8522,16 +9903,16 @@ var ReportGenerator = class {
8522
9903
  bySourceFile.set(sourceFile, outputPath);
8523
9904
  }
8524
9905
  if (bySourceFile.size === 0) return void 0;
8525
- const indexPath = toPosix(path7.join(this.options.outputDir, "index.html"));
9906
+ const indexPath = toPosix(path8.join(this.options.outputDir, "index.html"));
8526
9907
  if (htmlPaths.some((p) => toPosix(p) === indexPath)) {
8527
9908
  this.deps.logger.warn?.(
8528
9909
  `Skipping colocated index: a report already occupies ${indexPath}.`
8529
9910
  );
8530
9911
  return void 0;
8531
9912
  }
8532
- const entries = buildIndexEntries(run, bySourceFile, path7.dirname(indexPath));
9913
+ const entries = buildIndexEntries(run, bySourceFile, path8.dirname(indexPath));
8533
9914
  const html = renderColocatedIndex(entries, this.options.html.title);
8534
- await fsPromises.mkdir(path7.dirname(indexPath), { recursive: true });
9915
+ await fsPromises.mkdir(path8.dirname(indexPath), { recursive: true });
8535
9916
  await this.deps.writeFile(indexPath, html);
8536
9917
  return indexPath;
8537
9918
  }
@@ -8550,9 +9931,9 @@ var ReportGenerator = class {
8550
9931
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
8551
9932
  const ext = FORMAT_EXTENSIONS[format];
8552
9933
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
8553
- const outputPath = toPosix(path7.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
9934
+ const outputPath = toPosix(path8.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
8554
9935
  const content = await this.formatContent(run, format);
8555
- const dir = path7.dirname(outputPath);
9936
+ const dir = path8.dirname(outputPath);
8556
9937
  await fsPromises.mkdir(dir, { recursive: true });
8557
9938
  await this.deps.writeFile(outputPath, content);
8558
9939
  return [outputPath];
@@ -8564,7 +9945,7 @@ var ReportGenerator = class {
8564
9945
  testCases
8565
9946
  };
8566
9947
  const content = await this.formatContent(groupRun, format);
8567
- const dir = path7.dirname(outputPath);
9948
+ const dir = path8.dirname(outputPath);
8568
9949
  await fsPromises.mkdir(dir, { recursive: true });
8569
9950
  await this.deps.writeFile(outputPath, content);
8570
9951
  writtenPaths.push(outputPath);
@@ -8752,7 +10133,7 @@ async function generateRunComparison(args) {
8752
10133
  await fsPromises.mkdir(outputDir, { recursive: true });
8753
10134
  for (const format of args.formats) {
8754
10135
  const ext = format === "html" ? ".html" : format === "changelog" ? ".changelog.md" : ".md";
8755
- const outputPath = toPosix(path7.join(outputDir, `${outputName}${ext}`));
10136
+ const outputPath = toPosix(path8.join(outputDir, `${outputName}${ext}`));
8756
10137
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : format === "changelog" ? new RunDiffChangelogFormatter().format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
8757
10138
  await fsPromises.writeFile(outputPath, content, "utf8");
8758
10139
  files.push(outputPath);
@@ -8765,8 +10146,8 @@ import { parseNdjson as parseNdjson2 } from "executable-stories-core/converters/
8765
10146
  import { toCIInfo as toCIInfo2 } from "executable-stories-core/types/ci";
8766
10147
 
8767
10148
  // src/artifacts-readme.ts
8768
- import * as fs6 from "fs";
8769
- import * as path8 from "path";
10149
+ import * as fs7 from "fs";
10150
+ import * as path9 from "path";
8770
10151
  var README = `# executable-stories artifacts
8771
10152
 
8772
10153
  Generated by your test reporter and the \`executable-stories\` CLI. Safe to
@@ -8792,10 +10173,10 @@ Useful commands (all take \`raw-run.json\`):
8792
10173
  `;
8793
10174
  function writeArtifactsReadme(outputDir) {
8794
10175
  try {
8795
- const target = path8.join(outputDir, "README.md");
8796
- if (fs6.existsSync(target)) return false;
8797
- fs6.mkdirSync(outputDir, { recursive: true });
8798
- fs6.writeFileSync(target, README, "utf8");
10176
+ const target = path9.join(outputDir, "README.md");
10177
+ if (fs7.existsSync(target)) return false;
10178
+ fs7.mkdirSync(outputDir, { recursive: true });
10179
+ fs7.writeFileSync(target, README, "utf8");
8799
10180
  return true;
8800
10181
  } catch {
8801
10182
  return false;
@@ -8803,8 +10184,8 @@ function writeArtifactsReadme(outputDir) {
8803
10184
  }
8804
10185
 
8805
10186
  // src/explainers.ts
8806
- import * as fs8 from "fs";
8807
- import * as path10 from "path";
10187
+ import * as fs9 from "fs";
10188
+ import * as path11 from "path";
8808
10189
  import Ajv2 from "ajv/dist/2020.js";
8809
10190
  import { parse as parseYaml } from "yaml";
8810
10191
  import {
@@ -8863,16 +10244,16 @@ var explainer_v1_default = {
8863
10244
  };
8864
10245
 
8865
10246
  // src/utils/markdown-files.ts
8866
- import * as fs7 from "fs";
8867
- import * as path9 from "path";
10247
+ import * as fs8 from "fs";
10248
+ import * as path10 from "path";
8868
10249
  function collectMarkdownFiles(target) {
8869
- if (!fs7.existsSync(target)) return [];
8870
- if (fs7.statSync(target).isFile()) return [target];
10250
+ if (!fs8.existsSync(target)) return [];
10251
+ if (fs8.statSync(target).isFile()) return [target];
8871
10252
  const out = [];
8872
10253
  const walk = (dir) => {
8873
- for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
10254
+ for (const entry of fs8.readdirSync(dir, { withFileTypes: true })) {
8874
10255
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
8875
- const full = path9.join(dir, entry.name);
10256
+ const full = path10.join(dir, entry.name);
8876
10257
  if (entry.isDirectory()) walk(full);
8877
10258
  else if (/\.mdx?$/u.test(entry.name)) out.push(full);
8878
10259
  }
@@ -8924,9 +10305,9 @@ function buildExplainersReport(args, _deps = {}) {
8924
10305
  const scenarios = report.features.flatMap((feature) => feature.scenarios);
8925
10306
  const explainers = [];
8926
10307
  for (const file of collectMarkdownFiles(args.dir).sort()) {
8927
- const parsed = parseExplainerDoc(fs8.readFileSync(file, "utf8"));
10308
+ const parsed = parseExplainerDoc(fs9.readFileSync(file, "utf8"));
8928
10309
  if (!parsed) continue;
8929
- const rel = path10.relative(args.dir, file);
10310
+ const rel = path11.relative(args.dir, file);
8930
10311
  if (!parsed.explainer) {
8931
10312
  explainers.push({ file: rel, status: "invalid", errors: parsed.errors });
8932
10313
  continue;
@@ -8995,13 +10376,13 @@ function renderExplainersReport(report, format) {
8995
10376
  }
8996
10377
 
8997
10378
  // src/run-file.ts
8998
- import fs9 from "fs";
8999
- import path11 from "path";
10379
+ import fs10 from "fs";
10380
+ import path12 from "path";
9000
10381
  var DEFAULT_RUN_FILES = [".executable-stories/raw-run.json", "reports/raw-run.json"];
9001
10382
  var SUPPORTED_RAW_RUN_SCHEMA = 1;
9002
10383
  function findDefaultRunFile(cwd = process.cwd()) {
9003
10384
  for (const candidate of DEFAULT_RUN_FILES) {
9004
- if (fs9.existsSync(path11.resolve(cwd, candidate))) return candidate;
10385
+ if (fs10.existsSync(path12.resolve(cwd, candidate))) return candidate;
9005
10386
  }
9006
10387
  return void 0;
9007
10388
  }
@@ -9027,8 +10408,8 @@ function diagnoseRunFile(file, cwd = process.cwd()) {
9027
10408
  });
9028
10409
  return { checks, healthy: false };
9029
10410
  }
9030
- const abs = path11.resolve(cwd, resolved);
9031
- if (!fs9.existsSync(abs)) {
10411
+ const abs = path12.resolve(cwd, resolved);
10412
+ if (!fs10.existsSync(abs)) {
9032
10413
  checks.push({
9033
10414
  label: "run file",
9034
10415
  status: "fail",
@@ -9037,7 +10418,7 @@ function diagnoseRunFile(file, cwd = process.cwd()) {
9037
10418
  });
9038
10419
  return { checks, healthy: false };
9039
10420
  }
9040
- const stat = fs9.statSync(abs);
10421
+ const stat = fs10.statSync(abs);
9041
10422
  checks.push({
9042
10423
  label: "run file",
9043
10424
  status: "ok",
@@ -9045,7 +10426,7 @@ function diagnoseRunFile(file, cwd = process.cwd()) {
9045
10426
  });
9046
10427
  let parsed;
9047
10428
  try {
9048
- parsed = JSON.parse(fs9.readFileSync(abs, "utf8"));
10429
+ parsed = JSON.parse(fs10.readFileSync(abs, "utf8"));
9049
10430
  } catch (err) {
9050
10431
  checks.push({
9051
10432
  label: "json",
@@ -9176,7 +10557,9 @@ var COMPLETION_SUBCOMMANDS = [
9176
10557
  ["init-astro", "Scaffold a thin Astro docs site"],
9177
10558
  ["new", "Scaffold a docs page from a template"],
9178
10559
  ["check-links", "Scan docs for broken links"],
9179
- ["push", "Send a run to Executable Stories Cloud"],
10560
+ ["push", "Send a run to a cloud ingest endpoint"],
10561
+ ["coverage", "Compare stories against a test-management system (read-only)"],
10562
+ ["sync", "Push cases, executions, and evidence to TestRail or Xray"],
9180
10563
  ["import-openapi", "Generate API doc pages from an OpenAPI spec"],
9181
10564
  ["publish-confluence", "Publish an ADF JSON file to Confluence"],
9182
10565
  ["publish-jira", "Publish an ADF JSON file to a Jira issue"],
@@ -9198,8 +10581,16 @@ var COMMON_FLAGS = [
9198
10581
  "--baseline",
9199
10582
  "--baseline-dir",
9200
10583
  "--emit-canonical",
10584
+ "--apply",
10585
+ "--attach",
10586
+ "--report-url",
9201
10587
  "--help"
9202
10588
  ];
10589
+ var SUBCOMMAND_VALUES = {
10590
+ completion: ["bash", "zsh", "fish"],
10591
+ sync: [...PROVIDER_NAMES],
10592
+ coverage: [...PROVIDER_NAMES]
10593
+ };
9203
10594
  var FLAG_VALUES = {
9204
10595
  "--format": [
9205
10596
  "html",
@@ -9221,12 +10612,13 @@ var FLAG_VALUES = {
9221
10612
  "--preset": ["agent", "ci", "docs"],
9222
10613
  "--input-type": ["raw", "canonical", "ndjson"],
9223
10614
  "--list-format": ["text", "json", "csv", "markdown-table"],
9224
- "--check-format": ["text", "json"]
10615
+ "--check-format": ["text", "json"],
10616
+ "--attach": ["failed", "all", "none"]
9225
10617
  };
9226
10618
  function bashScript() {
9227
10619
  const subcommands = COMPLETION_SUBCOMMANDS.map(([name]) => name).join(" ");
9228
10620
  const flags = COMMON_FLAGS.join(" ");
9229
- const valueCases = Object.entries(FLAG_VALUES).map(([flag, values]) => ` ${flag})
10621
+ const valueCases = [...Object.entries(FLAG_VALUES), ...Object.entries(SUBCOMMAND_VALUES)].map(([word, values]) => ` ${word})
9230
10622
  COMPREPLY=( $(compgen -W "${values.join(" ")}" -- "$cur") ); return 0 ;;`).join("\n");
9231
10623
  return `# executable-stories bash completion
9232
10624
  _executable_stories() {
@@ -9236,8 +10628,6 @@ _executable_stories() {
9236
10628
 
9237
10629
  case "$prev" in
9238
10630
  ${valueCases}
9239
- completion)
9240
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return 0 ;;
9241
10631
  esac
9242
10632
 
9243
10633
  if [ "$COMP_CWORD" -eq 1 ]; then
@@ -9257,8 +10647,8 @@ function zshScript() {
9257
10647
  const subcommands = COMPLETION_SUBCOMMANDS.map(([name, desc]) => ` '${name}:${desc.replaceAll("'", "")}'`).join(
9258
10648
  "\n"
9259
10649
  );
9260
- const valueCases = Object.entries(FLAG_VALUES).map(([flag, values]) => ` ${flag})
9261
- _values '${flag.slice(2)}' ${values.join(" ")} ;;`).join("\n");
10650
+ const valueCases = [...Object.entries(FLAG_VALUES), ...Object.entries(SUBCOMMAND_VALUES)].map(([word, values]) => ` ${word})
10651
+ _values '${word.replace(/^--/, "")}' ${values.join(" ")} ;;`).join("\n");
9262
10652
  return `#compdef executable-stories
9263
10653
  # executable-stories zsh completion
9264
10654
 
@@ -9270,8 +10660,6 @@ ${subcommands}
9270
10660
 
9271
10661
  case "\${words[CURRENT-1]}" in
9272
10662
  ${valueCases}
9273
- completion)
9274
- _values 'shell' bash zsh fish ;;
9275
10663
  *)
9276
10664
  if (( CURRENT == 2 )); then
9277
10665
  _describe 'subcommand' subcommands
@@ -9300,6 +10688,11 @@ function fishScript() {
9300
10688
  `complete -c executable-stories -l ${flag.slice(2)} -x -a '${values.join(" ")}'`
9301
10689
  );
9302
10690
  }
10691
+ for (const [subcommand, values] of Object.entries(SUBCOMMAND_VALUES)) {
10692
+ lines.push(
10693
+ `complete -c executable-stories -n '__fish_seen_subcommand_from ${subcommand}' -x -a '${values.join(" ")}'`
10694
+ );
10695
+ }
9303
10696
  lines.push("complete -c executable-stories -l open -d 'Open the HTML report when done'");
9304
10697
  lines.push("complete -c executable-stories -l help -d 'Show help'");
9305
10698
  return `# executable-stories fish completion
@@ -9334,7 +10727,7 @@ function runCompletion(args) {
9334
10727
 
9335
10728
  // src/open-report.ts
9336
10729
  import { spawn } from "child_process";
9337
- import path12 from "path";
10730
+ import path13 from "path";
9338
10731
  function openCommand(platform) {
9339
10732
  if (platform === "darwin") return { command: "open", args: [] };
9340
10733
  if (platform === "win32") return { command: "cmd", args: ["/c", "start", ""] };
@@ -9350,7 +10743,7 @@ function openInBrowser(file, platform = process.platform) {
9350
10743
  }
9351
10744
  const { command, args } = openCommand(platform);
9352
10745
  try {
9353
- const child = spawn(command, [...args, path12.resolve(file)], { stdio: "ignore", detached: true });
10746
+ const child = spawn(command, [...args, path13.resolve(file)], { stdio: "ignore", detached: true });
9354
10747
  child.on("error", (err) => {
9355
10748
  console.error(`--open: could not open ${file}: ${err.message}`);
9356
10749
  });
@@ -9378,14 +10771,14 @@ function summaryLine(counts, files, durationMs) {
9378
10771
 
9379
10772
  // src/init-astro.ts
9380
10773
  import { spawnSync } from "child_process";
9381
- import * as fs10 from "fs";
9382
- import * as path13 from "path";
10774
+ import * as fs11 from "fs";
10775
+ import * as path14 from "path";
9383
10776
  import { fileURLToPath } from "url";
9384
- var __dirname = path13.dirname(fileURLToPath(import.meta.url));
10777
+ var __dirname = path14.dirname(fileURLToPath(import.meta.url));
9385
10778
  function detectPackageManager(cwd = process.cwd()) {
9386
- if (fs10.existsSync(path13.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
9387
- if (fs10.existsSync(path13.join(cwd, "yarn.lock"))) return "yarn";
9388
- if (fs10.existsSync(path13.join(cwd, "bun.lock")) || fs10.existsSync(path13.join(cwd, "bun.lockb"))) {
10779
+ if (fs11.existsSync(path14.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
10780
+ if (fs11.existsSync(path14.join(cwd, "yarn.lock"))) return "yarn";
10781
+ if (fs11.existsSync(path14.join(cwd, "bun.lock")) || fs11.existsSync(path14.join(cwd, "bun.lockb"))) {
9389
10782
  return "bun";
9390
10783
  }
9391
10784
  return "npm";
@@ -9406,7 +10799,7 @@ function runDocsDev(siteDir) {
9406
10799
  return { kind: "not-scaffolded" };
9407
10800
  }
9408
10801
  const pm = detectPackageManager();
9409
- if (!fs10.existsSync(path13.join(siteDir, "node_modules"))) {
10802
+ if (!fs11.existsSync(path14.join(siteDir, "node_modules"))) {
9410
10803
  console.log(`Installing docs site dependencies with ${pm}\u2026`);
9411
10804
  if (!installScaffoldDependencies(siteDir, pm)) {
9412
10805
  return { kind: "install-failed", pm };
@@ -9417,14 +10810,14 @@ function runDocsDev(siteDir) {
9417
10810
  }
9418
10811
  var SCAFFOLD_MARKER = "executable-stories.config.mjs";
9419
10812
  function isScaffoldedAstroSite(dir) {
9420
- return fs10.existsSync(path13.join(dir, SCAFFOLD_MARKER));
10813
+ return fs11.existsSync(path14.join(dir, SCAFFOLD_MARKER));
9421
10814
  }
9422
10815
  function initAstro(options = {}) {
9423
10816
  const targetDir = options.targetDir ?? "./story-docs";
9424
10817
  const force = options.force ?? false;
9425
10818
  const update = options.update ?? false;
9426
- const templateDir = path13.resolve(__dirname, "..", "templates", "astro-thin");
9427
- if (!fs10.existsSync(templateDir)) {
10819
+ const templateDir = path14.resolve(__dirname, "..", "templates", "astro-thin");
10820
+ if (!fs11.existsSync(templateDir)) {
9428
10821
  throw new Error(
9429
10822
  `Template directory not found at ${templateDir}. Ensure the package is installed correctly.`
9430
10823
  );
@@ -9432,8 +10825,8 @@ function initAstro(options = {}) {
9432
10825
  if (update) {
9433
10826
  return updateScaffoldDeps(templateDir, targetDir);
9434
10827
  }
9435
- if (fs10.existsSync(targetDir)) {
9436
- const entries = fs10.readdirSync(targetDir);
10828
+ if (fs11.existsSync(targetDir)) {
10829
+ const entries = fs11.readdirSync(targetDir);
9437
10830
  if (entries.length > 0 && !force) {
9438
10831
  throw new Error(
9439
10832
  `Directory "${targetDir}" already exists and is not empty. Use --force to overlay the template (existing files are kept; same-path template files are overwritten), or --update to refresh framework files only.`
@@ -9453,11 +10846,11 @@ function updateScaffoldDeps(templateDir, targetDir) {
9453
10846
  return { targetDir };
9454
10847
  }
9455
10848
  function mergeDependencies(templateDir, targetDir) {
9456
- const tmplPkgPath = path13.join(templateDir, "package.json");
9457
- const userPkgPath = path13.join(targetDir, "package.json");
9458
- if (!fs10.existsSync(tmplPkgPath) || !fs10.existsSync(userPkgPath)) return;
9459
- const tmpl = JSON.parse(fs10.readFileSync(tmplPkgPath, "utf8"));
9460
- const user = JSON.parse(fs10.readFileSync(userPkgPath, "utf8"));
10849
+ const tmplPkgPath = path14.join(templateDir, "package.json");
10850
+ const userPkgPath = path14.join(targetDir, "package.json");
10851
+ if (!fs11.existsSync(tmplPkgPath) || !fs11.existsSync(userPkgPath)) return;
10852
+ const tmpl = JSON.parse(fs11.readFileSync(tmplPkgPath, "utf8"));
10853
+ const user = JSON.parse(fs11.readFileSync(userPkgPath, "utf8"));
9461
10854
  user.dependencies = user.dependencies ?? {};
9462
10855
  let changed = false;
9463
10856
  for (const [name, version] of Object.entries(tmpl.dependencies ?? {})) {
@@ -9467,28 +10860,28 @@ function mergeDependencies(templateDir, targetDir) {
9467
10860
  }
9468
10861
  }
9469
10862
  if (changed) {
9470
- fs10.writeFileSync(userPkgPath, `${JSON.stringify(user, null, 2)}
10863
+ fs11.writeFileSync(userPkgPath, `${JSON.stringify(user, null, 2)}
9471
10864
  `, "utf8");
9472
10865
  }
9473
10866
  }
9474
10867
  function copyDirRecursive(src, dest) {
9475
- fs10.mkdirSync(dest, { recursive: true });
9476
- const entries = fs10.readdirSync(src, { withFileTypes: true });
10868
+ fs11.mkdirSync(dest, { recursive: true });
10869
+ const entries = fs11.readdirSync(src, { withFileTypes: true });
9477
10870
  for (const entry of entries) {
9478
- const srcPath = path13.join(src, entry.name);
10871
+ const srcPath = path14.join(src, entry.name);
9479
10872
  const destName = entry.name === "gitignore" ? ".gitignore" : entry.name;
9480
- const destPath = path13.join(dest, destName);
10873
+ const destPath = path14.join(dest, destName);
9481
10874
  if (entry.isDirectory()) {
9482
10875
  copyDirRecursive(srcPath, destPath);
9483
10876
  } else {
9484
- fs10.copyFileSync(srcPath, destPath);
10877
+ fs11.copyFileSync(srcPath, destPath);
9485
10878
  }
9486
10879
  }
9487
10880
  }
9488
10881
 
9489
10882
  // src/scaffold-doc.ts
9490
- import * as fs11 from "fs";
9491
- import * as path14 from "path";
10883
+ import * as fs12 from "fs";
10884
+ import * as path15 from "path";
9492
10885
  var TEMPLATES = [
9493
10886
  "adr",
9494
10887
  "runbook",
@@ -9505,7 +10898,7 @@ function isoDate(today) {
9505
10898
  function nextSeq(dir) {
9506
10899
  let max = 0;
9507
10900
  try {
9508
- for (const entry of fs11.readdirSync(dir)) {
10901
+ for (const entry of fs12.readdirSync(dir)) {
9509
10902
  const match = /^(\d{1,4})-/.exec(entry);
9510
10903
  if (match) max = Math.max(max, Number.parseInt(match[1], 10));
9511
10904
  }
@@ -9662,12 +11055,12 @@ function scaffoldDoc(options) {
9662
11055
  );
9663
11056
  }
9664
11057
  const spec = TEMPLATE_SPECS[template];
9665
- const baseDir = options.baseDir ?? path14.join("src", "content", "docs");
11058
+ const baseDir = options.baseDir ?? path15.join("src", "content", "docs");
9666
11059
  const today = options.today ?? /* @__PURE__ */ new Date();
9667
11060
  const name = (options.name ?? "").trim() || defaultName(template);
9668
11061
  const slug2 = slugify3(name);
9669
11062
  const scenarioId = normalizeScenarioId(options.scenarioId);
9670
- const dir = path14.join(baseDir, spec.subdir);
11063
+ const dir = path15.join(baseDir, spec.subdir);
9671
11064
  if (template === "scenario-note" && !scenarioId) {
9672
11065
  throw new Error(`Template "scenario-note" requires --scenario-id.`);
9673
11066
  }
@@ -9679,14 +11072,14 @@ function scaffoldDoc(options) {
9679
11072
  seq: nextSeq(dir)
9680
11073
  };
9681
11074
  const filename = `${spec.filename(slug2, ctx)}.mdx`;
9682
- const filePath = path14.join(dir, filename);
9683
- if (fs11.existsSync(filePath) && !options.force) {
11075
+ const filePath = path15.join(dir, filename);
11076
+ if (fs12.existsSync(filePath) && !options.force) {
9684
11077
  throw new Error(
9685
11078
  `File "${filePath}" already exists. Use --force to overwrite.`
9686
11079
  );
9687
11080
  }
9688
- fs11.mkdirSync(dir, { recursive: true });
9689
- fs11.writeFileSync(filePath, spec.content(ctx), "utf8");
11081
+ fs12.mkdirSync(dir, { recursive: true });
11082
+ fs12.writeFileSync(filePath, spec.content(ctx), "utf8");
9690
11083
  return { template, path: filePath, title: titleFor2(template, ctx) };
9691
11084
  }
9692
11085
  function defaultName(template) {
@@ -9727,8 +11120,8 @@ function normalizeScenarioId(input) {
9727
11120
  }
9728
11121
 
9729
11122
  // src/check-links.ts
9730
- import * as fs12 from "fs";
9731
- import * as path15 from "path";
11123
+ import * as fs13 from "fs";
11124
+ import * as path16 from "path";
9732
11125
  function stripCode(markdown) {
9733
11126
  let out = markdown.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$/gm, "");
9734
11127
  out = out.replace(/(`+)(?:(?!\1).)+\1/g, "");
@@ -9742,8 +11135,8 @@ function extractLinks(markdown) {
9742
11135
  while ((match = mdRe.exec(stripped)) !== null) {
9743
11136
  found.push(match[1].trim());
9744
11137
  }
9745
- const htmlRe = /<[a-z][^>]*\b(?:href|src)=["']([^"']+)["']/gi;
9746
- while ((match = htmlRe.exec(stripped)) !== null) {
11138
+ const attrRe = /\b(?:href|src)\s*=\s*["']([^"']+)["']/gi;
11139
+ while ((match = attrRe.exec(stripped)) !== null) {
9747
11140
  found.push(match[1].trim());
9748
11141
  }
9749
11142
  return found.filter(Boolean);
@@ -9755,21 +11148,37 @@ function classifyLink(link2) {
9755
11148
  if (link2.startsWith("/")) return "root";
9756
11149
  return "internal";
9757
11150
  }
9758
- function resolutionCandidates(fromFile, link2) {
9759
- const withoutAnchor = link2.split("#")[0];
9760
- if (!withoutAnchor) return [];
9761
- const base = path15.resolve(path15.dirname(fromFile), withoutAnchor);
11151
+ function resolutionCandidates(fromDir, linkPath) {
11152
+ const base = path16.resolve(fromDir, linkPath);
9762
11153
  const candidates = [base];
9763
- if (!path15.extname(base)) {
11154
+ if (!path16.extname(base)) {
9764
11155
  candidates.push(`${base}.md`, `${base}.mdx`);
9765
- candidates.push(path15.join(base, "index.md"), path15.join(base, "index.mdx"));
11156
+ candidates.push(path16.join(base, "index.md"), path16.join(base, "index.mdx"));
9766
11157
  }
9767
11158
  return candidates;
9768
11159
  }
9769
- function resolvesOnDisk(fromFile, link2) {
9770
- return resolutionCandidates(fromFile, link2).some(
9771
- (candidate) => fs12.existsSync(candidate) && fs12.statSync(candidate).isFile()
9772
- );
11160
+ function linkPathOf(link2) {
11161
+ return link2.split("#")[0].split("?")[0];
11162
+ }
11163
+ function existsAsFile(candidate) {
11164
+ return fs13.existsSync(candidate) && fs13.statSync(candidate).isFile();
11165
+ }
11166
+ function resolvesFrom(dirs, linkPath) {
11167
+ return dirs.some((dir) => resolutionCandidates(dir, linkPath).some(existsAsFile));
11168
+ }
11169
+ function detectAssetRoots(target) {
11170
+ let dir = fs13.existsSync(target) && fs13.statSync(target).isDirectory() ? path16.resolve(target) : path16.dirname(path16.resolve(target));
11171
+ for (let depth = 0; depth < 6; depth++) {
11172
+ const hasConfig = ["mjs", "js", "ts", "mts"].some(
11173
+ (ext) => fs13.existsSync(path16.join(dir, `astro.config.${ext}`))
11174
+ );
11175
+ const publicDir = path16.join(dir, "public");
11176
+ if (hasConfig && fs13.existsSync(publicDir)) return [publicDir];
11177
+ const parent = path16.dirname(dir);
11178
+ if (parent === dir) break;
11179
+ dir = parent;
11180
+ }
11181
+ return [];
9773
11182
  }
9774
11183
  async function isExternalAlive(url, timeoutMs) {
9775
11184
  const attempt = async (method) => {
@@ -9794,9 +11203,11 @@ async function isExternalAlive(url, timeoutMs) {
9794
11203
  }
9795
11204
  async function checkLinks(options) {
9796
11205
  const { target, checkExternal = false, externalTimeoutMs = 8e3 } = options;
9797
- if (!fs12.existsSync(target)) {
11206
+ if (!fs13.existsSync(target)) {
9798
11207
  throw new Error(`Path not found: ${target}`);
9799
11208
  }
11209
+ const siteRoot = path16.resolve(options.siteRoot ?? target);
11210
+ const rootDirs = [siteRoot, ...(options.assetRoots ?? detectAssetRoots(target)).map((d) => path16.resolve(d))];
9800
11211
  const files = collectMarkdownFiles(target);
9801
11212
  const broken = [];
9802
11213
  let linksChecked = 0;
@@ -9804,13 +11215,25 @@ async function checkLinks(options) {
9804
11215
  let skipped = 0;
9805
11216
  const externalCache = /* @__PURE__ */ new Map();
9806
11217
  for (const file of files) {
9807
- const content = fs12.readFileSync(file, "utf8");
11218
+ const content = fs13.readFileSync(file, "utf8");
9808
11219
  for (const link2 of extractLinks(content)) {
9809
11220
  const kind = classifyLink(link2);
9810
- if (kind === "anchor" || kind === "mail" || kind === "root") {
11221
+ if (kind === "anchor" || kind === "mail") {
9811
11222
  skipped += 1;
9812
11223
  continue;
9813
11224
  }
11225
+ if (kind === "root") {
11226
+ const linkPath2 = linkPathOf(link2).replace(/^\/+/, "");
11227
+ if (linkPath2 === "") {
11228
+ skipped += 1;
11229
+ continue;
11230
+ }
11231
+ linksChecked += 1;
11232
+ if (!resolvesFrom(rootDirs, linkPath2)) {
11233
+ broken.push({ file, link: link2, reason: "target file not found" });
11234
+ }
11235
+ continue;
11236
+ }
9814
11237
  if (kind === "external") {
9815
11238
  if (!checkExternal) {
9816
11239
  skipped += 1;
@@ -9828,8 +11251,13 @@ async function checkLinks(options) {
9828
11251
  }
9829
11252
  continue;
9830
11253
  }
11254
+ const linkPath = linkPathOf(link2);
11255
+ if (linkPath === "") {
11256
+ skipped += 1;
11257
+ continue;
11258
+ }
9831
11259
  linksChecked += 1;
9832
- if (!resolvesOnDisk(file, link2)) {
11260
+ if (!resolvesFrom([path16.dirname(file)], linkPath)) {
9833
11261
  broken.push({ file, link: link2, reason: "target file not found" });
9834
11262
  }
9835
11263
  }
@@ -9861,7 +11289,7 @@ function formatLinkReport(report) {
9861
11289
 
9862
11290
  // src/push.ts
9863
11291
  import { execFileSync } from "child_process";
9864
- import * as fs13 from "fs";
11292
+ import * as fs14 from "fs";
9865
11293
  import { parseArgs } from "util";
9866
11294
  import { canonicalizeRun as canonicalizeRun4 } from "executable-stories-core/converters/acl/index";
9867
11295
  import { toStoryReport as toStoryReport8 } from "executable-stories-core/converters/story-report";
@@ -9869,10 +11297,11 @@ import { synthesizeStories as synthesizeStories2 } from "executable-stories-core
9869
11297
  var EXIT_SUCCESS = 0;
9870
11298
  var EXIT_PUSH_FAILED = 1;
9871
11299
  var EXIT_USAGE = 4;
11300
+ var EXIT_GATE_BLOCKED = 5;
9872
11301
  var HELP = `Usage:
9873
11302
  executable-stories push <run.json> [options]
9874
11303
 
9875
- Send a run to Executable Stories Cloud. <run.json> is either a StoryReport v1
11304
+ Send a run to a cloud ingest endpoint. <run.json> is either a StoryReport v1
9876
11305
  (e.g. reports/index.story-report.json) or a raw run JSON, which is converted
9877
11306
  through the standard pipeline first.
9878
11307
 
@@ -9886,12 +11315,20 @@ Options:
9886
11315
  --git-sha <sha> Default: current git HEAD.
9887
11316
  --base <ref> Send files changed since <ref> (e.g. origin/main) so the
9888
11317
  cloud can recommend a test scope for the change.
11318
+ --gate After pushing, ask the cloud whether this commit is safe
11319
+ to release and exit 5 if it is blocked. The policy lives
11320
+ in your organization's settings, not in a file here.
9889
11321
  -h, --help Show this help.
9890
11322
 
9891
- Exit codes: 0 pushed, 1 push rejected/failed, 4 usage error.`;
11323
+ Under GitHub Actions, repo/branch/sha, the base commit, and PR metadata are
11324
+ read from the environment, the run URL and recommended scope are written to
11325
+ the job summary, and the run id is written to GITHUB_OUTPUT as ingest-run-id.
11326
+
11327
+ Exit codes: 0 pushed, 1 push rejected/failed, 4 usage error, 5 gate blocked.`;
9892
11328
  function defaultDeps() {
9893
11329
  return {
9894
- readFile: (filePath) => fs13.readFileSync(filePath, "utf8"),
11330
+ readFile: (filePath) => fs14.readFileSync(filePath, "utf8"),
11331
+ appendFile: (filePath, text2) => fs14.appendFileSync(filePath, text2),
9895
11332
  fetchFn: fetch,
9896
11333
  git: (args) => {
9897
11334
  try {
@@ -9912,6 +11349,42 @@ function repoSlugFromRemote(remoteUrl) {
9912
11349
  function isStoryReport(data) {
9913
11350
  return typeof data.schemaVersion === "string";
9914
11351
  }
11352
+ function githubContext(deps) {
11353
+ const env = deps.env;
11354
+ const context = {
11355
+ repo: env.GITHUB_REPOSITORY,
11356
+ // GITHUB_HEAD_REF is the source branch on a pull_request event, where
11357
+ // GITHUB_REF_NAME would be the synthetic "<n>/merge" ref.
11358
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
11359
+ gitSha: env.GITHUB_SHA
11360
+ };
11361
+ let event;
11362
+ try {
11363
+ event = JSON.parse(deps.readFile(env.GITHUB_EVENT_PATH ?? ""));
11364
+ } catch {
11365
+ return context;
11366
+ }
11367
+ context.prNumber = event.pull_request?.number;
11368
+ context.prUrl = event.pull_request?.html_url;
11369
+ const baseSha = event.pull_request?.base?.sha ?? event.before;
11370
+ if (baseSha && !/^0+$/.test(baseSha)) {
11371
+ if (deps.git(["cat-file", "-e", baseSha]) === void 0) {
11372
+ deps.git(["fetch", "--depth=1", "origin", baseSha]);
11373
+ }
11374
+ context.baseSha = baseSha;
11375
+ }
11376
+ return context;
11377
+ }
11378
+ function summaryWriter(deps) {
11379
+ const summaryPath = deps.env.GITHUB_STEP_SUMMARY;
11380
+ return (markdown) => {
11381
+ if (summaryPath) deps.appendFile(summaryPath, `${markdown}
11382
+ `);
11383
+ };
11384
+ }
11385
+ function cell(text2) {
11386
+ return text2.replaceAll("|", "\\|");
11387
+ }
9915
11388
  async function runPush(rawArgs, depsOverride = {}) {
9916
11389
  const deps = { ...defaultDeps(), ...depsOverride };
9917
11390
  let parsed;
@@ -9926,6 +11399,7 @@ async function runPush(rawArgs, depsOverride = {}) {
9926
11399
  branch: { type: "string" },
9927
11400
  "git-sha": { type: "string" },
9928
11401
  base: { type: "string" },
11402
+ gate: { type: "boolean" },
9929
11403
  help: { type: "boolean", short: "h" }
9930
11404
  }
9931
11405
  });
@@ -9947,7 +11421,7 @@ async function runPush(rawArgs, depsOverride = {}) {
9947
11421
  const key = parsed.values.key ?? deps.env.EXECUTABLE_STORIES_API_KEY;
9948
11422
  if (!key) {
9949
11423
  deps.error(
9950
- "push needs an API key: pass --key or set EXECUTABLE_STORIES_API_KEY. Create one in Executable Stories Cloud (Settings -> Ingest key)."
11424
+ "push needs an API key: pass --key or set EXECUTABLE_STORIES_API_KEY. Create one in your cloud instance's settings (Ingest key)."
9951
11425
  );
9952
11426
  return EXIT_USAGE;
9953
11427
  }
@@ -9971,16 +11445,20 @@ async function runPush(rawArgs, depsOverride = {}) {
9971
11445
  return EXIT_USAGE;
9972
11446
  }
9973
11447
  }
9974
- const repo = parsed.values.repo ?? repoSlugFromRemote(deps.git(["config", "--get", "remote.origin.url"]) ?? "");
11448
+ const onActions = deps.env.GITHUB_ACTIONS === "true";
11449
+ const github = onActions ? githubContext(deps) : {};
11450
+ const summary = summaryWriter(deps);
11451
+ const repo = parsed.values.repo ?? github.repo ?? repoSlugFromRemote(deps.git(["config", "--get", "remote.origin.url"]) ?? "");
9975
11452
  if (!repo) {
9976
11453
  deps.error("Could not infer the repository slug from git. Pass --repo <org/name>.");
9977
11454
  return EXIT_USAGE;
9978
11455
  }
9979
- const branch = parsed.values.branch ?? deps.git(["rev-parse", "--abbrev-ref", "HEAD"]);
9980
- const gitSha = parsed.values["git-sha"] ?? deps.git(["rev-parse", "HEAD"]);
11456
+ const branch = parsed.values.branch ?? github.branch ?? deps.git(["rev-parse", "--abbrev-ref", "HEAD"]);
11457
+ const gitSha = parsed.values["git-sha"] ?? github.gitSha ?? deps.git(["rev-parse", "HEAD"]);
9981
11458
  const baseUrl = parsed.values.url ?? deps.env.EXECUTABLE_STORIES_URL ?? "https://app.executablestories.com";
9982
11459
  const base = parsed.values.base;
9983
- const changedFiles = base ? deps.git(["diff", "--name-only", `${base}...HEAD`])?.split("\n").filter(Boolean) ?? [] : [];
11460
+ const baseSha = base ? deps.git(["rev-parse", base]) : github.baseSha;
11461
+ const changedFiles = baseSha ? deps.git(["diff", "--name-only", `${baseSha}...HEAD`])?.split("\n").filter(Boolean) ?? [] : [];
9984
11462
  if (base && changedFiles.length === 0) {
9985
11463
  deps.error(`Warning: no changed files found against ${base}; pushing without change metadata.`);
9986
11464
  }
@@ -9996,9 +11474,13 @@ async function runPush(rawArgs, depsOverride = {}) {
9996
11474
  repo,
9997
11475
  branch,
9998
11476
  gitSha,
9999
- source: "serve",
11477
+ // "serve" named a subcommand that no longer exists (ADR 0006). The
11478
+ // cloud accepts both; "local" is what this is.
11479
+ source: onActions ? "action" : "local",
10000
11480
  report,
10001
- ...changedFiles.length > 0 ? { changedFiles, baseSha: deps.git(["rev-parse", base]) } : {}
11481
+ ...changedFiles.length > 0 ? { changedFiles, baseSha } : {},
11482
+ ...github.prNumber ? { prNumber: github.prNumber } : {},
11483
+ ...github.prUrl ? { prUrl: github.prUrl } : {}
10002
11484
  })
10003
11485
  });
10004
11486
  } catch (err) {
@@ -10011,18 +11493,428 @@ async function runPush(rawArgs, depsOverride = {}) {
10011
11493
  deps.error(`Push rejected: HTTP ${response.status}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${body.slice(0, 500)}`);
10012
11494
  return EXIT_PUSH_FAILED;
10013
11495
  }
10014
- let runId = "";
11496
+ let result = {};
10015
11497
  try {
10016
- runId = String(JSON.parse(body).runId ?? "");
11498
+ result = JSON.parse(body);
10017
11499
  } catch {
10018
11500
  }
11501
+ const runId = String(result.runId ?? "");
10019
11502
  deps.log(runId ? `Pushed run ${runId} (${repo}${branch ? `@${branch}` : ""})` : "Pushed run.");
11503
+ if (result.url) deps.log(result.url);
11504
+ summary(
11505
+ result.url ? `### Executable Stories
11506
+
11507
+ [View this run](${result.url})` : "### Executable Stories\n\nRun pushed."
11508
+ );
11509
+ if (runId && deps.env.GITHUB_OUTPUT) {
11510
+ deps.appendFile(deps.env.GITHUB_OUTPUT, `ingest-run-id=${runId}
11511
+ `);
11512
+ }
11513
+ const recommendations = result.recommendations ?? [];
11514
+ if (recommendations.length > 0) {
11515
+ deps.log(`
11516
+ Recommended scope for this change (${recommendations.length}):`);
11517
+ for (const item of recommendations) {
11518
+ deps.log(` [${item.confidence}] ${item.title} \u2014 ${item.reason}`);
11519
+ }
11520
+ summary(`
11521
+ **Recommended scope for this change (${recommendations.length})**
11522
+ `);
11523
+ summary("| Confidence | Case | Why |\n| --- | --- | --- |");
11524
+ for (const item of recommendations) {
11525
+ summary(`| ${cell(item.confidence)} | ${cell(item.title)} | ${cell(item.reason)} |`);
11526
+ }
11527
+ }
11528
+ if (!parsed.values.gate) return EXIT_SUCCESS;
11529
+ if (!gitSha) {
11530
+ deps.error("--gate needs a commit sha: pass --git-sha or run inside a git repository.");
11531
+ return EXIT_USAGE;
11532
+ }
11533
+ return await runGate({ baseUrl, key, repo, gitSha, onActions }, deps);
11534
+ }
11535
+ async function runGate({
11536
+ baseUrl,
11537
+ key,
11538
+ repo,
11539
+ gitSha,
11540
+ onActions
11541
+ }, deps) {
11542
+ const summary = summaryWriter(deps);
11543
+ const query = `repo=${encodeURIComponent(repo)}&sha=${encodeURIComponent(gitSha)}`;
11544
+ let response;
11545
+ try {
11546
+ response = await deps.fetchFn(new URL(`/api/v1/releases/gate?${query}`, baseUrl), {
11547
+ headers: { Authorization: `Bearer ${key}` }
11548
+ });
11549
+ } catch (err) {
11550
+ deps.error(`Could not reach the gate: ${err instanceof Error ? err.message : String(err)}`);
11551
+ return EXIT_PUSH_FAILED;
11552
+ }
11553
+ const body = await response.text();
11554
+ if (!response.ok) {
11555
+ deps.error(`Gate check failed: HTTP ${response.status}: ${body.slice(0, 500)}`);
11556
+ return EXIT_PUSH_FAILED;
11557
+ }
11558
+ let gate = {};
11559
+ try {
11560
+ gate = JSON.parse(body);
11561
+ } catch {
11562
+ deps.error(`Gate returned a non-JSON body: ${body.slice(0, 200)}`);
11563
+ return EXIT_PUSH_FAILED;
11564
+ }
11565
+ for (const warning of gate.warnings ?? []) deps.log(` warning: ${warning}`);
11566
+ const commit = `${repo}@${gitSha.slice(0, 12)}`;
11567
+ if (gate.status === "no-release") {
11568
+ deps.log(`
11569
+ No release recorded for ${commit} \u2014 nothing to gate on.`);
11570
+ summary("\n**Release gate: no release recorded for this commit**");
11571
+ return EXIT_SUCCESS;
11572
+ }
11573
+ if (gate.status === "blocked") {
11574
+ deps.error(`
11575
+ Release gate: BLOCKED for ${commit}`);
11576
+ summary("\n**Release gate: blocked**\n");
11577
+ for (const reason of gate.blocking ?? []) {
11578
+ deps.error(` - ${reason}`);
11579
+ summary(`- ${reason}`);
11580
+ if (onActions) deps.error(`::error::Release gate: ${reason}`);
11581
+ }
11582
+ return EXIT_GATE_BLOCKED;
11583
+ }
11584
+ deps.log(`
11585
+ Release gate: clear for ${commit}`);
11586
+ summary("\n**Release gate: clear**");
10020
11587
  return EXIT_SUCCESS;
10021
11588
  }
10022
11589
 
11590
+ // src/sync/run.ts
11591
+ import * as fs15 from "fs";
11592
+ import * as path17 from "path";
11593
+ import { parseArgs as parseArgs2 } from "util";
11594
+ import { canonicalizeRun as canonicalizeRun5 } from "executable-stories-core/converters/acl/index";
11595
+ import { synthesizeStories as synthesizeStories3 } from "executable-stories-core/converters/synthesize";
11596
+
11597
+ // src/config.ts
11598
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
11599
+ import { resolve as resolve9 } from "path";
11600
+ var CONFIG_CANDIDATES = [
11601
+ "executable-stories.config.mjs",
11602
+ "executable-stories.config.js",
11603
+ "executable-stories.config.json"
11604
+ ];
11605
+ async function loadConfig(configPath) {
11606
+ let resolved;
11607
+ if (configPath) {
11608
+ resolved = resolve9(configPath);
11609
+ } else {
11610
+ const present = CONFIG_CANDIDATES.map((name) => resolve9(process.cwd(), name)).filter(existsSync11);
11611
+ if (present.length > 1) {
11612
+ throw new Error(
11613
+ `Multiple config files found in this directory:
11614
+ ` + present.map((p) => ` - ${p}`).join("\n") + `
11615
+ Keep only one, or pass --config <path> to choose which to load.`
11616
+ );
11617
+ }
11618
+ resolved = present[0];
11619
+ }
11620
+ if (!resolved || !existsSync11(resolved)) return {};
11621
+ const isJson = resolved.endsWith(".json");
11622
+ let config;
11623
+ if (isJson) {
11624
+ try {
11625
+ config = JSON.parse(readFileSync10(resolved, "utf8"));
11626
+ } catch (err) {
11627
+ throw new Error(`Config file at ${resolved} is not valid JSON: ${err.message}`);
11628
+ }
11629
+ } else {
11630
+ config = (await import(resolved)).default;
11631
+ }
11632
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
11633
+ throw new Error(
11634
+ isJson ? `Config file at ${resolved} must contain a JSON object. Got: ${Array.isArray(config) ? "array" : typeof config}` : `Config file at ${resolved} must export a default object. Got: ${typeof config}`
11635
+ );
11636
+ }
11637
+ const { formatters, sync } = config;
11638
+ return {
11639
+ ...formatters === void 0 ? {} : { formatters },
11640
+ ...sync === void 0 ? {} : { sync }
11641
+ };
11642
+ }
11643
+
11644
+ // src/sync/run.ts
11645
+ var EXIT_SUCCESS2 = 0;
11646
+ var EXIT_FAILED = 1;
11647
+ var EXIT_USAGE2 = 4;
11648
+ var COVERAGE_HELP = `Usage:
11649
+ executable-stories coverage <provider> <run.json> [options]
11650
+
11651
+ Compares what your tests cover against what a test-management system holds.
11652
+ Read-only: needs nothing but a read-scoped API key, and writes nothing remote.
11653
+
11654
+ Providers: ${PROVIDER_NAMES.join(", ")}
11655
+
11656
+ Options:
11657
+ --config <path> Config file (default: executable-stories.config.mjs, .js, or .json)
11658
+ --output-dir <dir> Where the JSON and Markdown land (default: reports)
11659
+ --report-url <url> Published report URL, used for deep links
11660
+ --lockfile <path> Default: ${DEFAULT_LOCKFILE_PATH}
11661
+ --quiet Write the artifacts, skip the stdout summary
11662
+ -h, --help Show this help
11663
+
11664
+ Credentials come from the environment:
11665
+ TestRail TESTRAIL_USERNAME, TESTRAIL_API_KEY
11666
+ Xray XRAY_CLIENT_ID, XRAY_CLIENT_SECRET (JIRA_EMAIL/JIRA_TOKEN to edit Jira fields)
11667
+
11668
+ Exit codes: 0 report produced, 1 provider unreachable, 4 usage error.`;
11669
+ var SYNC_HELP = `Usage:
11670
+ executable-stories sync <provider> <run.json> [options]
11671
+
11672
+ Pushes stories into a test-management system: case bodies authored from the
11673
+ test, executions recorded against them, evidence attached.
11674
+
11675
+ Prints the plan and changes nothing unless --apply is passed.
11676
+
11677
+ Providers: ${PROVIDER_NAMES.join(", ")}
11678
+
11679
+ Options:
11680
+ --apply Actually write. Without it, this is a dry run.
11681
+ --attach <policy> failed (default), all, or none
11682
+ --config <path> Config file (default: executable-stories.config.mjs, .js, or .json)
11683
+ --lockfile <path> Default: ${DEFAULT_LOCKFILE_PATH}
11684
+ --report-url <url> Published report URL, used for deep links
11685
+ --output-dir <dir> Where coverage artifacts land (default: reports)
11686
+ --continue-on-error Exit 0 even when some writes failed
11687
+ --init Print a config block for this provider and exit
11688
+ -h, --help Show this help
11689
+
11690
+ The lockfile binds each story to its case. Commit it: the diff shows up in the
11691
+ pull request that created the case.
11692
+
11693
+ Exit codes: 0 applied (or planned), 1 some writes failed, 4 usage error.`;
11694
+ function defaultDeps2() {
11695
+ return {
11696
+ readFile: (filePath) => fs15.readFileSync(filePath, "utf8"),
11697
+ fileExists: (filePath) => fs15.existsSync(filePath),
11698
+ writeFile: (filePath, contents) => {
11699
+ fs15.mkdirSync(path17.dirname(path17.resolve(filePath)), { recursive: true });
11700
+ fs15.writeFileSync(filePath, contents, "utf8");
11701
+ },
11702
+ fetchFn: globalThis.fetch,
11703
+ env: process.env,
11704
+ log: console.log,
11705
+ error: console.error,
11706
+ loadConfigFn: loadConfig
11707
+ };
11708
+ }
11709
+ var CONFIG_TEMPLATES = {
11710
+ testrail: `export default {
11711
+ sync: {
11712
+ testrail: {
11713
+ url: "https://acme.testrail.io",
11714
+ projectId: 1,
11715
+ suiteId: 1,
11716
+ // Section that newly created cases land in. Without it, creation is refused.
11717
+ sectionId: 1,
11718
+ // TestRail ships no "skipped" status; set one to record skipped tests.
11719
+ // statusIds: { skipped: 6 },
11720
+ // Only needed if this instance uses a customised case template.
11721
+ // fields: { steps: "custom_steps_separated", description: "custom_preconds" },
11722
+ },
11723
+ },
11724
+ };
11725
+
11726
+ // Environment: TESTRAIL_USERNAME (login email), TESTRAIL_API_KEY (My Settings -> API Keys)`,
11727
+ xray: `export default {
11728
+ sync: {
11729
+ xray: {
11730
+ jiraBaseUrl: "https://acme.atlassian.net",
11731
+ projectKey: "PROJ",
11732
+ // testPlanKey: "PROJ-100",
11733
+ },
11734
+ },
11735
+ };
11736
+
11737
+ // Environment: XRAY_CLIENT_ID, XRAY_CLIENT_SECRET (Jira -> Apps -> Xray -> API Keys)
11738
+ // Optional: JIRA_EMAIL, JIRA_TOKEN (needed to update an existing test's summary/description)`
11739
+ };
11740
+ var JSON_CONFIG_TEMPLATES = {
11741
+ testrail: JSON.stringify(
11742
+ { sync: { testrail: { url: "https://acme.testrail.io", projectId: 1, suiteId: 1, sectionId: 1 } } },
11743
+ null,
11744
+ 2
11745
+ ),
11746
+ xray: JSON.stringify(
11747
+ { sync: { xray: { jiraBaseUrl: "https://acme.atlassian.net", projectKey: "PROJ" } } },
11748
+ null,
11749
+ 2
11750
+ )
11751
+ };
11752
+ function isStoryReport2(data) {
11753
+ return typeof data.schemaVersion === "string";
11754
+ }
11755
+ function loadRun(inputPath, deps) {
11756
+ const data = JSON.parse(deps.readFile(inputPath));
11757
+ if (isStoryReport2(data)) {
11758
+ throw new Error(
11759
+ `${inputPath} is a StoryReport, which has already dropped the attachment bodies and source paths this needs. Point at the raw run instead (e.g. reports/raw-run.json).`
11760
+ );
11761
+ }
11762
+ return canonicalizeRun5(synthesizeStories3(data));
11763
+ }
11764
+ async function runSyncCommand(mode, rawArgs, depsOverride = {}) {
11765
+ const deps = { ...defaultDeps2(), ...depsOverride };
11766
+ const help = mode === "sync" ? SYNC_HELP : COVERAGE_HELP;
11767
+ let parsed;
11768
+ try {
11769
+ parsed = parseArgs2({
11770
+ args: rawArgs,
11771
+ allowPositionals: true,
11772
+ options: {
11773
+ apply: { type: "boolean", default: false },
11774
+ attach: { type: "string" },
11775
+ config: { type: "string" },
11776
+ lockfile: { type: "string" },
11777
+ "report-url": { type: "string" },
11778
+ "output-dir": { type: "string" },
11779
+ "continue-on-error": { type: "boolean", default: false },
11780
+ init: { type: "boolean", default: false },
11781
+ quiet: { type: "boolean", default: false },
11782
+ help: { type: "boolean", short: "h", default: false }
11783
+ }
11784
+ });
11785
+ } catch (err) {
11786
+ deps.error(err instanceof Error ? err.message : String(err));
11787
+ deps.error(help);
11788
+ return EXIT_USAGE2;
11789
+ }
11790
+ if (parsed.values.help) {
11791
+ deps.log(help);
11792
+ return EXIT_SUCCESS2;
11793
+ }
11794
+ const providerName = parsed.positionals[0];
11795
+ if (!providerName || !isProviderName(providerName)) {
11796
+ deps.error(
11797
+ providerName ? `Unknown provider "${providerName}". Available: ${PROVIDER_NAMES.join(", ")}.` : `${mode} needs a provider: executable-stories ${mode} <${PROVIDER_NAMES.join("|")}> <run.json>`
11798
+ );
11799
+ deps.error(help);
11800
+ return EXIT_USAGE2;
11801
+ }
11802
+ if (parsed.values.init) {
11803
+ deps.log(CONFIG_TEMPLATES[providerName]);
11804
+ deps.log("");
11805
+ deps.log(
11806
+ `Save the block above as executable-stories.config.mjs (or merge the \`sync\` key into the one you have), then run:
11807
+ executable-stories coverage ${providerName} reports/raw-run.json`
11808
+ );
11809
+ deps.log("");
11810
+ deps.log(
11811
+ `Not a JavaScript project? Put the same \`sync\` object in executable-stories.config.json instead:`
11812
+ );
11813
+ deps.log(JSON_CONFIG_TEMPLATES[providerName]);
11814
+ return EXIT_SUCCESS2;
11815
+ }
11816
+ const inputPath = parsed.positionals[1];
11817
+ if (!inputPath) {
11818
+ deps.error(`${mode} needs a run file: executable-stories ${mode} ${providerName} <run.json>`);
11819
+ deps.error(help);
11820
+ return EXIT_USAGE2;
11821
+ }
11822
+ const attach = parsed.values.attach;
11823
+ if (attach && !["failed", "all", "none"].includes(attach)) {
11824
+ deps.error(`--attach must be one of: failed, all, none (got "${attach}")`);
11825
+ return EXIT_USAGE2;
11826
+ }
11827
+ let run;
11828
+ try {
11829
+ run = loadRun(inputPath, deps);
11830
+ } catch (err) {
11831
+ deps.error(`Could not read ${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
11832
+ return EXIT_USAGE2;
11833
+ }
11834
+ const logger = { warn: (message) => deps.error(`Warning: ${message}`) };
11835
+ let targets;
11836
+ try {
11837
+ const config = await deps.loadConfigFn(parsed.values.config);
11838
+ targets = config.sync ?? {};
11839
+ } catch (err) {
11840
+ deps.error(err instanceof Error ? err.message : String(err));
11841
+ return EXIT_USAGE2;
11842
+ }
11843
+ let built;
11844
+ try {
11845
+ built = buildProvider(
11846
+ { name: providerName, targets, env: deps.env },
11847
+ { fetch: deps.fetchFn, logger }
11848
+ );
11849
+ } catch (err) {
11850
+ deps.error(err instanceof Error ? err.message : String(err));
11851
+ return EXIT_USAGE2;
11852
+ }
11853
+ const targetConfig = targets[providerName] ?? {};
11854
+ const engineConfig = {
11855
+ ...built.engineDefaults,
11856
+ ...targetConfig,
11857
+ ...parsed.values["report-url"] ? { reportUrl: parsed.values["report-url"] } : {},
11858
+ ...attach ? { attach } : {}
11859
+ };
11860
+ const lockfilePath = parsed.values.lockfile ?? DEFAULT_LOCKFILE_PATH;
11861
+ const outputDir = parsed.values["output-dir"] ?? "reports";
11862
+ let lockfile;
11863
+ try {
11864
+ lockfile = deps.fileExists(lockfilePath) ? parseLockfile(deps.readFile(lockfilePath), lockfilePath) : emptyLockfile();
11865
+ } catch (err) {
11866
+ deps.error(err instanceof Error ? err.message : String(err));
11867
+ return EXIT_USAGE2;
11868
+ }
11869
+ let analysis;
11870
+ try {
11871
+ analysis = await analyzeSync({
11872
+ run,
11873
+ provider: built.provider,
11874
+ lockfile,
11875
+ config: engineConfig
11876
+ });
11877
+ } catch (err) {
11878
+ deps.error(`Could not read from ${providerName}: ${err instanceof Error ? err.message : String(err)}`);
11879
+ return EXIT_FAILED;
11880
+ }
11881
+ const jsonPath = path17.join(outputDir, `sync-coverage.${providerName}.json`);
11882
+ const markdownPath = path17.join(outputDir, `sync-coverage.${providerName}.md`);
11883
+ deps.writeFile(jsonPath, `${JSON.stringify(buildCoverageJson(analysis), null, 2)}
11884
+ `);
11885
+ deps.writeFile(markdownPath, `${renderCoverageMarkdown(analysis)}
11886
+ `);
11887
+ if (mode === "coverage") {
11888
+ if (!parsed.values.quiet) {
11889
+ deps.log(renderCoverageText(analysis));
11890
+ deps.log("");
11891
+ }
11892
+ deps.log(`Wrote ${jsonPath} and ${markdownPath}`);
11893
+ return EXIT_SUCCESS2;
11894
+ }
11895
+ const dryRun = !parsed.values.apply;
11896
+ deps.log(renderPlan(analysis, { dryRun }));
11897
+ if (dryRun) return EXIT_SUCCESS2;
11898
+ const applied = await applySync(
11899
+ { analysis, provider: built.provider, lockfile, config: engineConfig },
11900
+ { logger }
11901
+ );
11902
+ deps.writeFile(lockfilePath, serializeLockfile(lockfile));
11903
+ deps.log("");
11904
+ deps.log(renderApplyResult(applied));
11905
+ if (applied.errors.length > 0 && !parsed.values["continue-on-error"]) {
11906
+ deps.error(
11907
+ `
11908
+ ${applied.errors.length} write(s) failed. A stale system of record is worse than a red build, so this exits non-zero. Pass --continue-on-error to treat it as advisory.`
11909
+ );
11910
+ return EXIT_FAILED;
11911
+ }
11912
+ return EXIT_SUCCESS2;
11913
+ }
11914
+
10023
11915
  // src/import-openapi.ts
10024
- import * as fs14 from "fs";
10025
- import * as path16 from "path";
11916
+ import * as fs16 from "fs";
11917
+ import * as path18 from "path";
10026
11918
  import { parse as parseYamlString } from "yaml";
10027
11919
  var HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
10028
11920
  function parseYaml2(raw, specPath) {
@@ -10035,9 +11927,9 @@ function parseYaml2(raw, specPath) {
10035
11927
  }
10036
11928
  }
10037
11929
  function parseSpec(specPath) {
10038
- if (!fs14.existsSync(specPath)) throw new Error(`Spec not found: ${specPath}`);
10039
- const raw = fs14.readFileSync(specPath, "utf8");
10040
- const ext = path16.extname(specPath).toLowerCase();
11930
+ if (!fs16.existsSync(specPath)) throw new Error(`Spec not found: ${specPath}`);
11931
+ const raw = fs16.readFileSync(specPath, "utf8");
11932
+ const ext = path18.extname(specPath).toLowerCase();
10041
11933
  if (ext === ".json") return JSON.parse(raw);
10042
11934
  if (ext === ".yaml" || ext === ".yml") return parseYaml2(raw, specPath);
10043
11935
  try {
@@ -10068,8 +11960,8 @@ function extractEndpoints(spec) {
10068
11960
  }
10069
11961
  function loadScenarios(runFile) {
10070
11962
  if (!runFile) return [];
10071
- if (!fs14.existsSync(runFile)) throw new Error(`Run file not found: ${runFile}`);
10072
- const report = JSON.parse(fs14.readFileSync(runFile, "utf8"));
11963
+ if (!fs16.existsSync(runFile)) throw new Error(`Run file not found: ${runFile}`);
11964
+ const report = JSON.parse(fs16.readFileSync(runFile, "utf8"));
10073
11965
  return (report.features ?? []).flatMap((f) => f.scenarios ?? []);
10074
11966
  }
10075
11967
  function endpointRefs(endpoint) {
@@ -10176,25 +12068,25 @@ async function importOpenApi(options) {
10176
12068
  list.push(item);
10177
12069
  groups.set(item.endpoint.tag, list);
10178
12070
  }
10179
- const outputDir = options.outputDir ?? path16.join("src", "content", "docs", "api");
10180
- if (fs14.existsSync(outputDir) && !options.force) {
10181
- const entries = fs14.readdirSync(outputDir);
12071
+ const outputDir = options.outputDir ?? path18.join("src", "content", "docs", "api");
12072
+ if (fs16.existsSync(outputDir) && !options.force) {
12073
+ const entries = fs16.readdirSync(outputDir);
10182
12074
  if (entries.length > 0) {
10183
12075
  throw new Error(`Output directory "${outputDir}" is not empty. Use --force to overwrite.`);
10184
12076
  }
10185
12077
  }
10186
- fs14.mkdirSync(outputDir, { recursive: true });
12078
+ fs16.mkdirSync(outputDir, { recursive: true });
10187
12079
  const coveredCount = coverage.filter((c) => c.status === "covered").length;
10188
12080
  const uncoveredCount = coverage.filter((c) => c.status === "uncovered").length;
10189
- fs14.writeFileSync(
10190
- path16.join(outputDir, "index.mdx"),
12081
+ fs16.writeFileSync(
12082
+ path18.join(outputDir, "index.mdx"),
10191
12083
  renderIndex(groups, hasRun, { endpointCount: endpoints.length, coveredCount, uncoveredCount }),
10192
12084
  "utf8"
10193
12085
  );
10194
12086
  for (const [tag, rows] of groups) {
10195
- const dir = path16.join(outputDir, slug(tag));
10196
- fs14.mkdirSync(dir, { recursive: true });
10197
- fs14.writeFileSync(path16.join(dir, "index.mdx"), renderTagPage(tag, rows, hasRun), "utf8");
12087
+ const dir = path18.join(outputDir, slug(tag));
12088
+ fs16.mkdirSync(dir, { recursive: true });
12089
+ fs16.writeFileSync(path18.join(dir, "index.mdx"), renderTagPage(tag, rows, hasRun), "utf8");
10198
12090
  }
10199
12091
  return {
10200
12092
  outputDir,
@@ -10205,43 +12097,12 @@ async function importOpenApi(options) {
10205
12097
  };
10206
12098
  }
10207
12099
 
10208
- // src/config.ts
10209
- import { existsSync as existsSync11 } from "fs";
10210
- import { resolve as resolve8 } from "path";
10211
- var CONFIG_CANDIDATES = ["executable-stories.config.mjs", "executable-stories.config.js"];
10212
- async function loadConfig(configPath) {
10213
- let resolved;
10214
- if (configPath) {
10215
- resolved = resolve8(configPath);
10216
- } else {
10217
- const present = CONFIG_CANDIDATES.map((name) => resolve8(process.cwd(), name)).filter(existsSync11);
10218
- if (present.length > 1) {
10219
- throw new Error(
10220
- `Multiple config files found in this directory:
10221
- ` + present.map((p) => ` - ${p}`).join("\n") + `
10222
- Keep only one, or pass --config <path> to choose which to load.`
10223
- );
10224
- }
10225
- resolved = present[0];
10226
- }
10227
- if (!resolved || !existsSync11(resolved)) return {};
10228
- const mod = await import(resolved);
10229
- const config = mod.default;
10230
- if (!config || typeof config !== "object" || Array.isArray(config)) {
10231
- throw new Error(
10232
- `Config file at ${resolved} must export a default object. Got: ${typeof config}`
10233
- );
10234
- }
10235
- const { formatters } = config;
10236
- return formatters === void 0 ? {} : { formatters };
10237
- }
10238
-
10239
12100
  // src/cli.ts
10240
- var EXIT_SUCCESS2 = 0;
12101
+ var EXIT_SUCCESS3 = 0;
10241
12102
  var EXIT_SCHEMA_VALIDATION = 1;
10242
12103
  var EXIT_CANONICAL_VALIDATION = 2;
10243
12104
  var EXIT_GENERATION = 3;
10244
- var EXIT_USAGE2 = 4;
12105
+ var EXIT_USAGE3 = 4;
10245
12106
  var EXIT_COMPARE_GATE = 5;
10246
12107
  var EXIT_REVIEW_GATE = 5;
10247
12108
  var EXIT_AGENT_GATE = 5;
@@ -10270,6 +12131,8 @@ USAGE
10270
12131
  executable-stories new <template> "<name>" [options]
10271
12132
  executable-stories check-links <dir> [options]
10272
12133
  executable-stories push <run.json> [--key <es_...>] [--url <base>] [--repo <org/name>]
12134
+ executable-stories coverage <testrail|xray> <run.json> [options]
12135
+ executable-stories sync <testrail|xray> <run.json> [--apply] [options]
10273
12136
  executable-stories import-openapi <spec> [options]
10274
12137
  executable-stories publish-confluence <file.adf.json> [options]
10275
12138
  executable-stories publish-jira <file.adf.json> [options]
@@ -10294,7 +12157,9 @@ SUBCOMMANDS
10294
12157
  init-astro Scaffold a thin Astro docs site (Starlight + executable-stories-astro; live stories at /stories)
10295
12158
  new Scaffold a docs page from a template (adr, runbook, decision-log, incident, scenario-note)
10296
12159
  check-links Scan docs for broken internal/external links (CI-friendly exit code)
10297
- push Send a run (StoryReport or raw run JSON) to Executable Stories Cloud
12160
+ push Send a run (StoryReport or raw run JSON) to a cloud ingest endpoint
12161
+ coverage Compare your stories against a test-management system (read-only)
12162
+ sync Push cases, executions, and evidence to TestRail or Xray (dry run by default)
10298
12163
  import-openapi Generate API doc pages from an OpenAPI spec, linked to verifying stories
10299
12164
  publish-confluence Publish an ADF JSON file to a Confluence page via REST API
10300
12165
  publish-jira Publish an ADF JSON file to a Jira issue (as comment or description)
@@ -10487,18 +12352,19 @@ EXIT CODES
10487
12352
  function parseTextJsonFormat(flag, value) {
10488
12353
  if (value !== "text" && value !== "json") {
10489
12354
  console.error(`Error: ${flag} must be "text" or "json", got "${value}".`);
10490
- process.exit(EXIT_USAGE2);
12355
+ process.exit(EXIT_USAGE3);
10491
12356
  }
10492
12357
  return value;
10493
12358
  }
10494
12359
  async function parseCliArgs(argv) {
10495
12360
  const args = argv.slice(2);
10496
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
12361
+ const SELF_DOCUMENTING = /* @__PURE__ */ new Set(["sync", "coverage"]);
12362
+ if (args.length === 0 || (args.includes("--help") || args.includes("-h")) && !SELF_DOCUMENTING.has(args[0] ?? "")) {
10497
12363
  console.log(HELP_TEXT);
10498
- process.exit(EXIT_SUCCESS2);
12364
+ process.exit(EXIT_SUCCESS3);
10499
12365
  }
10500
12366
  const subcommand = args[0];
10501
- if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "gate-release" && subcommand !== "deploy" && subcommand !== "review" && subcommand !== "list" && subcommand !== "check" && subcommand !== "check-explainers" && subcommand !== "goal" && subcommand !== "triage" && subcommand !== "validate" && subcommand !== "doctor" && subcommand !== "completion" && subcommand !== "dev" && subcommand !== "init-astro" && subcommand !== "new" && subcommand !== "check-links" && subcommand !== "push" && subcommand !== "import-openapi" && subcommand !== "publish-confluence" && subcommand !== "publish-jira") {
12367
+ if (subcommand !== "format" && subcommand !== "watch" && subcommand !== "compare" && subcommand !== "gate-release" && subcommand !== "deploy" && subcommand !== "review" && subcommand !== "list" && subcommand !== "check" && subcommand !== "check-explainers" && subcommand !== "goal" && subcommand !== "triage" && subcommand !== "validate" && subcommand !== "doctor" && subcommand !== "completion" && subcommand !== "dev" && subcommand !== "init-astro" && subcommand !== "new" && subcommand !== "check-links" && subcommand !== "push" && subcommand !== "import-openapi" && subcommand !== "publish-confluence" && subcommand !== "publish-jira" && subcommand !== "sync" && subcommand !== "coverage") {
10502
12368
  if (subcommand === "serve" || subcommand === "build-docs") {
10503
12369
  console.error(
10504
12370
  `The "${subcommand}" subcommand was removed. Living docs are now an Astro site, rendered live from the run JSON (no Markdown generation step):
@@ -10507,12 +12373,12 @@ async function parseCliArgs(argv) {
10507
12373
  3. run \`executable-stories dev\` in another \u2014 it hot-reloads the docs.
10508
12374
  See: https://github.com/jagreehal/executable-stories (executable-stories-astro).`
10509
12375
  );
10510
- process.exit(EXIT_USAGE2);
12376
+ process.exit(EXIT_USAGE3);
10511
12377
  }
10512
12378
  console.error(
10513
- `Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "gate-release", "deploy", "review", "list", "check", "check-explainers", "goal", "triage", "validate", "doctor", "completion", "dev", "init-astro", "new", "check-links", "push", "import-openapi", "publish-confluence", or "publish-jira".`
12379
+ `Unknown subcommand: "${subcommand}". Use "format", "watch", "compare", "gate-release", "deploy", "review", "list", "check", "check-explainers", "goal", "triage", "validate", "doctor", "completion", "dev", "init-astro", "new", "check-links", "push", "sync", "coverage", "import-openapi", "publish-confluence", or "publish-jira".`
10514
12380
  );
10515
- process.exit(EXIT_USAGE2);
12381
+ process.exit(EXIT_USAGE3);
10516
12382
  }
10517
12383
  if (subcommand === "completion") {
10518
12384
  process.exit(runCompletion(args.slice(1)));
@@ -10526,15 +12392,15 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10526
12392
  } else {
10527
12393
  console.log(formatDoctorReport(report));
10528
12394
  }
10529
- process.exit(report.healthy ? EXIT_SUCCESS2 : EXIT_USAGE2);
12395
+ process.exit(report.healthy ? EXIT_SUCCESS3 : EXIT_USAGE3);
10530
12396
  }
10531
12397
  if (subcommand === "publish-confluence") {
10532
12398
  await runPublishConfluence(args.slice(1));
10533
- process.exit(EXIT_SUCCESS2);
12399
+ process.exit(EXIT_SUCCESS3);
10534
12400
  }
10535
12401
  if (subcommand === "publish-jira") {
10536
12402
  await runPublishJira(args.slice(1));
10537
- process.exit(EXIT_SUCCESS2);
12403
+ process.exit(EXIT_SUCCESS3);
10538
12404
  }
10539
12405
  if (subcommand === "deploy") {
10540
12406
  process.exit(await runDeploy(args.slice(1)));
@@ -10548,7 +12414,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10548
12414
  `No docs site found at ${siteDir}. Create one (scaffold + install) with:
10549
12415
  npx executable-stories init-astro --install`
10550
12416
  );
10551
- process.exit(EXIT_USAGE2);
12417
+ process.exit(EXIT_USAGE3);
10552
12418
  }
10553
12419
  if (dev.kind === "install-failed") {
10554
12420
  console.error(`"${dev.pm} install" failed in ${siteDir} \u2014 run it manually, then retry.`);
@@ -10567,7 +12433,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10567
12433
  if (update) {
10568
12434
  console.log(`Updated ${result.targetDir} (content + config left untouched)`);
10569
12435
  console.log(" Framework updates come via: pnpm update executable-stories-astro");
10570
- process.exit(EXIT_SUCCESS2);
12436
+ process.exit(EXIT_SUCCESS3);
10571
12437
  }
10572
12438
  console.log(`Scaffolded Astro docs site at ${result.targetDir}`);
10573
12439
  const pm = detectPackageManager();
@@ -10594,17 +12460,19 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10594
12460
  console.log("");
10595
12461
  console.log("Everything is configured in one file: executable-stories.config.mjs");
10596
12462
  console.log(" \u2014 sources, scenario selection (include/exclude), grouping (groupBy), docs, and theme.");
10597
- process.exit(EXIT_SUCCESS2);
12463
+ process.exit(EXIT_SUCCESS3);
10598
12464
  } catch (err) {
10599
12465
  console.error(`Error: ${err.message}`);
10600
- process.exit(EXIT_USAGE2);
12466
+ process.exit(EXIT_USAGE3);
10601
12467
  }
10602
12468
  }
10603
12469
  if (subcommand === "new") process.exit(runNew(args.slice(1)));
10604
12470
  if (subcommand === "check-links") process.exit(await runCheckLinks(args.slice(1)));
10605
12471
  if (subcommand === "push") process.exit(await runPush(args.slice(1)));
12472
+ if (subcommand === "sync") process.exit(await runSyncCommand("sync", args.slice(1)));
12473
+ if (subcommand === "coverage") process.exit(await runSyncCommand("coverage", args.slice(1)));
10606
12474
  if (subcommand === "import-openapi") process.exit(await runImportOpenApi(args.slice(1)));
10607
- const { values, positionals } = parseArgs2({
12475
+ const { values, positionals } = parseArgs3({
10608
12476
  args: args.slice(1),
10609
12477
  options: {
10610
12478
  format: { type: "string", default: "html" },
@@ -10682,7 +12550,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10682
12550
  });
10683
12551
  if (values.help) {
10684
12552
  console.log(HELP_TEXT);
10685
- process.exit(EXIT_SUCCESS2);
12553
+ process.exit(EXIT_SUCCESS3);
10686
12554
  }
10687
12555
  const userSetFormat = args.slice(1).some((a) => a === "--format" || a.startsWith("--format="));
10688
12556
  const preset = expandPreset(
@@ -10692,7 +12560,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10692
12560
  );
10693
12561
  if (preset.error) {
10694
12562
  console.error(`Error: ${preset.error}`);
10695
- process.exit(EXIT_USAGE2);
12563
+ process.exit(EXIT_USAGE3);
10696
12564
  }
10697
12565
  const useStdin = values.stdin;
10698
12566
  const baselineValue = values.baseline;
@@ -10704,15 +12572,15 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10704
12572
  if (isCompareLike) {
10705
12573
  if (useStdin) {
10706
12574
  console.error(`Error: ${subcommand} does not support --stdin. Pass baseline and current files.`);
10707
- process.exit(EXIT_USAGE2);
12575
+ process.exit(EXIT_USAGE3);
10708
12576
  }
10709
12577
  if (!currentFile) {
10710
12578
  console.error(`Error: ${subcommand} requires <current-file>, and either <baseline-file> or --baseline auto.`);
10711
- process.exit(EXIT_USAGE2);
12579
+ process.exit(EXIT_USAGE3);
10712
12580
  }
10713
12581
  if (baselineMode === "explicit" && !baselineFile) {
10714
12582
  console.error(`Error: ${subcommand} requires <baseline-file> and <current-file>, or use --baseline auto.`);
10715
- process.exit(EXIT_USAGE2);
12583
+ process.exit(EXIT_USAGE3);
10716
12584
  }
10717
12585
  }
10718
12586
  let resolvedInputFile = inputFile;
@@ -10727,13 +12595,13 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10727
12595
  Pass a path, use --stdin, or run your tests first (non-JS adapters write
10728
12596
  ${DEFAULT_RUN_FILES[0]}; set rawRunPath in a JS reporter to write ${DEFAULT_RUN_FILES[1]}).`
10729
12597
  );
10730
- process.exit(EXIT_USAGE2);
12598
+ process.exit(EXIT_USAGE3);
10731
12599
  }
10732
12600
  }
10733
12601
  const inputType = values["input-type"];
10734
12602
  if (inputType !== "raw" && inputType !== "canonical" && inputType !== "ndjson") {
10735
12603
  console.error(`Error: --input-type must be "raw", "canonical", or "ndjson", got "${inputType}".`);
10736
- process.exit(EXIT_USAGE2);
12604
+ process.exit(EXIT_USAGE3);
10737
12605
  }
10738
12606
  const pluginConfig = await loadConfig(values["config"]);
10739
12607
  const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
@@ -10752,7 +12620,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10752
12620
  if (unknownFormats.length > 0) {
10753
12621
  const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
10754
12622
  console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: agent-text, astro-markdown, behavior-manifest-json, confluence, html, markdown, release-manifest, traceability-matrix, traceability-csv, junit, cucumber-json, cucumber-messages, cucumber-html, scenario-index-json, story-report-json${knownCustom}.`);
10755
- process.exit(EXIT_USAGE2);
12623
+ process.exit(EXIT_USAGE3);
10756
12624
  }
10757
12625
  const formats = builtInRequested;
10758
12626
  const noSynthesize = values["no-synthesize-stories"];
@@ -10761,19 +12629,19 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10761
12629
  const validNotifyConditions = /* @__PURE__ */ new Set(["always", "on-failure", "never"]);
10762
12630
  if (!validNotifyConditions.has(notifyValue)) {
10763
12631
  console.error(`Error: --notify must be "always", "on-failure", or "never", got "${notifyValue}".`);
10764
- process.exit(EXIT_USAGE2);
12632
+ process.exit(EXIT_USAGE3);
10765
12633
  }
10766
12634
  const maxFailedTestsStr = values["max-failed-tests"];
10767
12635
  const maxFailedTests = maxFailedTestsStr ? parseInt(maxFailedTestsStr, 10) : 5;
10768
12636
  if (maxFailedTestsStr && (isNaN(maxFailedTests) || maxFailedTests < 0)) {
10769
12637
  console.error(`Error: --max-failed-tests must be a non-negative integer, got "${maxFailedTestsStr}".`);
10770
- process.exit(EXIT_USAGE2);
12638
+ process.exit(EXIT_USAGE3);
10771
12639
  }
10772
12640
  const htmlStaleAfterDaysStr = values["html-stale-after-days"];
10773
12641
  const htmlStaleAfterDays = htmlStaleAfterDaysStr ? parseInt(htmlStaleAfterDaysStr, 10) : 7;
10774
12642
  if (htmlStaleAfterDaysStr && (isNaN(htmlStaleAfterDays) || htmlStaleAfterDays < 0)) {
10775
12643
  console.error(`Error: --html-stale-after-days must be a non-negative integer, got "${htmlStaleAfterDaysStr}".`);
10776
- process.exit(EXIT_USAGE2);
12644
+ process.exit(EXIT_USAGE3);
10777
12645
  }
10778
12646
  const slackWebhook = values["slack-webhook"];
10779
12647
  const teamsWebhook = values["teams-webhook"];
@@ -10800,7 +12668,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10800
12668
  const upper = webhookMethodRaw.toUpperCase();
10801
12669
  if (upper !== "POST" && upper !== "PUT") {
10802
12670
  console.error(`Error: --webhook-method must be "POST" or "PUT", got "${webhookMethodRaw}".`);
10803
- process.exit(EXIT_USAGE2);
12671
+ process.exit(EXIT_USAGE3);
10804
12672
  }
10805
12673
  webhookMethod = upper;
10806
12674
  }
@@ -10808,36 +12676,36 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10808
12676
  const maxHistoryRuns = maxHistoryRunsStr ? parseInt(maxHistoryRunsStr, 10) : 10;
10809
12677
  if (maxHistoryRunsStr && (isNaN(maxHistoryRuns) || maxHistoryRuns < 1)) {
10810
12678
  console.error(`Error: --max-history-runs must be a positive integer, got "${maxHistoryRunsStr}".`);
10811
- process.exit(EXIT_USAGE2);
12679
+ process.exit(EXIT_USAGE3);
10812
12680
  }
10813
12681
  const maxRegressionsStr = values["max-regressions"];
10814
12682
  const maxRegressions = maxRegressionsStr !== void 0 ? parseInt(maxRegressionsStr, 10) : void 0;
10815
12683
  if (maxRegressionsStr !== void 0 && (isNaN(maxRegressions) || maxRegressions < 0)) {
10816
12684
  console.error(`Error: --max-regressions must be a non-negative integer, got "${maxRegressionsStr}".`);
10817
- process.exit(EXIT_USAGE2);
12685
+ process.exit(EXIT_USAGE3);
10818
12686
  }
10819
12687
  const sortTestCasesRaw = values["sort-test-cases"];
10820
12688
  const validSortModes = /* @__PURE__ */ new Set(["id", "source", "none"]);
10821
12689
  if (!validSortModes.has(sortTestCasesRaw)) {
10822
12690
  console.error(`Error: --sort-test-cases must be id, source, or none, got "${sortTestCasesRaw}".`);
10823
- process.exit(EXIT_USAGE2);
12691
+ process.exit(EXIT_USAGE3);
10824
12692
  }
10825
12693
  const assetModeRaw = values["asset-mode"];
10826
12694
  const validAssetModes = /* @__PURE__ */ new Set(["none", "copy"]);
10827
12695
  if (!validAssetModes.has(assetModeRaw)) {
10828
12696
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
10829
- process.exit(EXIT_USAGE2);
12697
+ process.exit(EXIT_USAGE3);
10830
12698
  }
10831
12699
  const failOnRaw = values["fail-on"];
10832
12700
  if (failOnRaw !== void 0 && failOnRaw !== "uncovered" && failOnRaw !== "weak") {
10833
12701
  console.error(`Error: --fail-on must be "uncovered" or "weak", got "${failOnRaw}".`);
10834
- process.exit(EXIT_USAGE2);
12702
+ process.exit(EXIT_USAGE3);
10835
12703
  }
10836
12704
  const minEvidenceRaw = values["min-evidence"];
10837
12705
  const validMinEvidence = /* @__PURE__ */ new Set(["weak", "moderate", "strong"]);
10838
12706
  if (minEvidenceRaw !== void 0 && !validMinEvidence.has(minEvidenceRaw)) {
10839
12707
  console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
10840
- process.exit(EXIT_USAGE2);
12708
+ process.exit(EXIT_USAGE3);
10841
12709
  }
10842
12710
  const checkFormat = parseTextJsonFormat("--check-format", values["check-format"]);
10843
12711
  const goalFormat = parseTextJsonFormat("--goal-format", values["goal-format"]);
@@ -10921,27 +12789,27 @@ async function readInput(args) {
10921
12789
  if (args.stdin) {
10922
12790
  return readStdin();
10923
12791
  }
10924
- const filePath = path17.resolve(args.inputFile);
10925
- if (!fs15.existsSync(filePath)) {
12792
+ const filePath = path19.resolve(args.inputFile);
12793
+ if (!fs17.existsSync(filePath)) {
10926
12794
  console.error(`Error: File not found: ${filePath}`);
10927
- process.exit(EXIT_USAGE2);
12795
+ process.exit(EXIT_USAGE3);
10928
12796
  }
10929
- return fs15.readFileSync(filePath, "utf8");
12797
+ return fs17.readFileSync(filePath, "utf8");
10930
12798
  }
10931
12799
  function readFileInput(filePath) {
10932
- const resolved = path17.resolve(filePath);
10933
- if (!fs15.existsSync(resolved)) {
12800
+ const resolved = path19.resolve(filePath);
12801
+ if (!fs17.existsSync(resolved)) {
10934
12802
  console.error(`Error: File not found: ${resolved}`);
10935
- process.exit(EXIT_USAGE2);
12803
+ process.exit(EXIT_USAGE3);
10936
12804
  }
10937
- return fs15.readFileSync(resolved, "utf8");
12805
+ return fs17.readFileSync(resolved, "utf8");
10938
12806
  }
10939
12807
  function readStdin() {
10940
- return new Promise((resolve10, reject) => {
12808
+ return new Promise((resolve12, reject) => {
10941
12809
  const chunks = [];
10942
12810
  process.stdin.setEncoding("utf8");
10943
12811
  process.stdin.on("data", (chunk) => chunks.push(chunk));
10944
- process.stdin.on("end", () => resolve10(chunks.join("")));
12812
+ process.stdin.on("end", () => resolve12(chunks.join("")));
10945
12813
  process.stdin.on("error", reject);
10946
12814
  });
10947
12815
  }
@@ -10951,7 +12819,7 @@ function parseJson(text2) {
10951
12819
  } catch (err) {
10952
12820
  const msg = err instanceof Error ? err.message : String(err);
10953
12821
  console.error(`Error: Invalid JSON \u2014 ${msg}`);
10954
- process.exit(EXIT_USAGE2);
12822
+ process.exit(EXIT_USAGE3);
10955
12823
  }
10956
12824
  }
10957
12825
  function tryParseJson(text2) {
@@ -10989,13 +12857,13 @@ ${msg}`);
10989
12857
  let raw = data;
10990
12858
  let droppedMissingStory = 0;
10991
12859
  if (args.synthesizeStories) {
10992
- raw = synthesizeStories3(raw);
12860
+ raw = synthesizeStories4(raw);
10993
12861
  } else {
10994
12862
  const before = raw.testCases.length;
10995
12863
  const withStory = raw.testCases.filter((tc) => tc.story != null).length;
10996
12864
  droppedMissingStory = before - withStory;
10997
12865
  }
10998
- const canonical = canonicalizeRun5(raw);
12866
+ const canonical = canonicalizeRun6(raw);
10999
12867
  try {
11000
12868
  assertValidRun2(canonical);
11001
12869
  } catch (err) {
@@ -11076,9 +12944,9 @@ function tryNormalizeRunFromText(text2, args) {
11076
12944
  if (!schemaResult.valid) return void 0;
11077
12945
  let raw = data;
11078
12946
  if (args.synthesizeStories) {
11079
- raw = synthesizeStories3(raw);
12947
+ raw = synthesizeStories4(raw);
11080
12948
  }
11081
- const canonical = canonicalizeRun5(raw);
12949
+ const canonical = canonicalizeRun6(raw);
11082
12950
  try {
11083
12951
  assertValidRun2(canonical);
11084
12952
  return canonical;
@@ -11087,14 +12955,14 @@ function tryNormalizeRunFromText(text2, args) {
11087
12955
  }
11088
12956
  }
11089
12957
  function listBaselineCandidates(currentFile, args) {
11090
- const baselineDir = path17.resolve(args.baselineDir ?? path17.dirname(currentFile));
11091
- const currentResolved = path17.resolve(currentFile);
11092
- if (!fs15.existsSync(baselineDir)) {
12958
+ const baselineDir = path19.resolve(args.baselineDir ?? path19.dirname(currentFile));
12959
+ const currentResolved = path19.resolve(currentFile);
12960
+ if (!fs17.existsSync(baselineDir)) {
11093
12961
  console.error(`Error: baseline directory not found: ${baselineDir}`);
11094
- process.exit(EXIT_USAGE2);
12962
+ process.exit(EXIT_USAGE3);
11095
12963
  }
11096
- const entries = fs15.readdirSync(baselineDir, { withFileTypes: true });
11097
- return entries.filter((entry) => entry.isFile()).map((entry) => path17.join(baselineDir, entry.name)).filter((candidate) => path17.resolve(candidate) !== currentResolved).filter(
12964
+ const entries = fs17.readdirSync(baselineDir, { withFileTypes: true });
12965
+ return entries.filter((entry) => entry.isFile()).map((entry) => path19.join(baselineDir, entry.name)).filter((candidate) => path19.resolve(candidate) !== currentResolved).filter(
11098
12966
  (candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
11099
12967
  );
11100
12968
  }
@@ -11102,21 +12970,21 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
11102
12970
  const candidates = listBaselineCandidates(currentFile, args);
11103
12971
  const comparable = [];
11104
12972
  for (const candidate of candidates) {
11105
- const run = tryNormalizeRunFromText(fs15.readFileSync(candidate, "utf8"), args);
12973
+ const run = tryNormalizeRunFromText(fs17.readFileSync(candidate, "utf8"), args);
11106
12974
  if (run) {
11107
12975
  comparable.push({ file: candidate, run });
11108
12976
  }
11109
12977
  }
11110
12978
  if (comparable.length === 0) {
11111
12979
  console.error(
11112
- `Error: no compatible baseline files found in ${path17.resolve(args.baselineDir ?? path17.dirname(currentFile))}.`
12980
+ `Error: no compatible baseline files found in ${path19.resolve(args.baselineDir ?? path19.dirname(currentFile))}.`
11113
12981
  );
11114
- process.exit(EXIT_USAGE2);
12982
+ process.exit(EXIT_USAGE3);
11115
12983
  }
11116
12984
  const picked = pickAutoBaseline(currentRun, comparable);
11117
12985
  if (!picked) {
11118
12986
  console.error("Error: unable to choose an automatic baseline.");
11119
- process.exit(EXIT_USAGE2);
12987
+ process.exit(EXIT_USAGE3);
11120
12988
  }
11121
12989
  return picked.file;
11122
12990
  }
@@ -11126,7 +12994,7 @@ function resolveBaselineRun(args, currentRun) {
11126
12994
  if (args.baselineArg === "auto") {
11127
12995
  if (!args.inputFile) {
11128
12996
  console.error("Error: --baseline auto requires a current input file (not --stdin).");
11129
- process.exit(EXIT_USAGE2);
12997
+ process.exit(EXIT_USAGE3);
11130
12998
  }
11131
12999
  baselineFile = resolveBaselineAuto(args.inputFile, currentRun, args);
11132
13000
  } else {
@@ -11172,7 +13040,7 @@ async function runCompare(ctx) {
11172
13040
  }
11173
13041
  process.exit(EXIT_COMPARE_GATE);
11174
13042
  }
11175
- process.exit(EXIT_SUCCESS2);
13043
+ process.exit(EXIT_SUCCESS3);
11176
13044
  } catch (err) {
11177
13045
  const msg = err instanceof Error ? err.message : String(err);
11178
13046
  console.error(`Comparison failed: ${msg}`);
@@ -11217,7 +13085,7 @@ async function runGateRelease(ctx) {
11217
13085
  process.exit(EXIT_RELEASE_GATE);
11218
13086
  }
11219
13087
  console.error("Release gate passed: RC matches dev baseline.");
11220
- process.exit(EXIT_SUCCESS2);
13088
+ process.exit(EXIT_SUCCESS3);
11221
13089
  } catch (err) {
11222
13090
  const msg = err instanceof Error ? err.message : String(err);
11223
13091
  console.error(`Release gate check failed: ${msg}`);
@@ -11249,7 +13117,7 @@ async function runReview(ctx) {
11249
13117
  }
11250
13118
  process.exit(EXIT_REVIEW_GATE);
11251
13119
  }
11252
- process.exit(EXIT_SUCCESS2);
13120
+ process.exit(EXIT_SUCCESS3);
11253
13121
  } catch (err) {
11254
13122
  const msg = err instanceof Error ? err.message : String(err);
11255
13123
  console.error(`Review failed: ${msg}`);
@@ -11265,7 +13133,7 @@ async function runList(ctx) {
11265
13133
  const validListFormats = /* @__PURE__ */ new Set(["text", "json", "csv", "markdown-table"]);
11266
13134
  if (!validListFormats.has(resolvedFormat)) {
11267
13135
  console.error(`Error: Unknown list format "${resolvedFormat}". Valid: text, json, csv, markdown-table.`);
11268
- process.exit(EXIT_USAGE2);
13136
+ process.exit(EXIT_USAGE3);
11269
13137
  }
11270
13138
  const output = listScenarios(
11271
13139
  {
@@ -11276,7 +13144,7 @@ async function runList(ctx) {
11276
13144
  {}
11277
13145
  );
11278
13146
  console.log(output);
11279
- process.exit(EXIT_SUCCESS2);
13147
+ process.exit(EXIT_SUCCESS3);
11280
13148
  }
11281
13149
  async function runCheck(ctx) {
11282
13150
  const { args } = ctx;
@@ -11291,17 +13159,17 @@ async function runCheck(ctx) {
11291
13159
  if (report.summary.failed > 0 && !args.noFail) {
11292
13160
  process.exit(EXIT_AGENT_GATE);
11293
13161
  }
11294
- process.exit(EXIT_SUCCESS2);
13162
+ process.exit(EXIT_SUCCESS3);
11295
13163
  }
11296
13164
  async function runCheckExplainers(ctx) {
11297
13165
  const { args } = ctx;
11298
13166
  if (!args.explainersDir) {
11299
13167
  console.error("Error: check-explainers requires --explainers-dir <dir> (the folder of explainer markdown).");
11300
- process.exit(EXIT_USAGE2);
13168
+ process.exit(EXIT_USAGE3);
11301
13169
  }
11302
- if (!fs15.existsSync(args.explainersDir) || !fs15.statSync(args.explainersDir).isDirectory()) {
13170
+ if (!fs17.existsSync(args.explainersDir) || !fs17.statSync(args.explainersDir).isDirectory()) {
11303
13171
  console.error(`Error: --explainers-dir "${args.explainersDir}" is not a directory.`);
11304
- process.exit(EXIT_USAGE2);
13172
+ process.exit(EXIT_USAGE3);
11305
13173
  }
11306
13174
  const text2 = await readInput(args);
11307
13175
  const run = applySelection(normalizeRunFromText(text2, args).run, args);
@@ -11310,7 +13178,7 @@ async function runCheckExplainers(ctx) {
11310
13178
  if (explainersGateFailed(report) && !args.noFail) {
11311
13179
  process.exit(EXIT_AGENT_GATE);
11312
13180
  }
11313
- process.exit(EXIT_SUCCESS2);
13181
+ process.exit(EXIT_SUCCESS3);
11314
13182
  }
11315
13183
  async function runGoal(ctx) {
11316
13184
  const { args } = ctx;
@@ -11331,7 +13199,7 @@ async function runGoal(ctx) {
11331
13199
  {}
11332
13200
  );
11333
13201
  console.log(renderGoal(report, args.goalFormat));
11334
- process.exit(report.met ? EXIT_SUCCESS2 : EXIT_AGENT_GATE);
13202
+ process.exit(report.met ? EXIT_SUCCESS3 : EXIT_AGENT_GATE);
11335
13203
  }
11336
13204
  async function runTriage(ctx) {
11337
13205
  const { args } = ctx;
@@ -11343,13 +13211,13 @@ async function runTriage(ctx) {
11343
13211
  {}
11344
13212
  );
11345
13213
  console.log(renderTriage(report, args.triageFormat));
11346
- process.exit(EXIT_SUCCESS2);
13214
+ process.exit(EXIT_SUCCESS3);
11347
13215
  }
11348
13216
  async function runWatch(ctx) {
11349
13217
  const { args } = ctx;
11350
13218
  if (!args.inputFile) {
11351
13219
  console.error("Error: watch requires an input file (the raw-run JSON the framework writes).");
11352
- process.exit(EXIT_USAGE2);
13220
+ process.exit(EXIT_USAGE3);
11353
13221
  }
11354
13222
  console.log(
11355
13223
  `Watching ${args.inputFile} \u2192 regenerating [${args.formats.join(", ")}] into ${args.outputDir}/ (Ctrl+C to stop)`
@@ -11398,7 +13266,7 @@ async function runFormatOrValidate(ctx) {
11398
13266
  }
11399
13267
  }
11400
13268
  console.log(`Valid NDJSON (${lines.length} envelopes).`);
11401
- process.exit(EXIT_SUCCESS2);
13269
+ process.exit(EXIT_SUCCESS3);
11402
13270
  }
11403
13271
  let run;
11404
13272
  try {
@@ -11409,9 +13277,9 @@ async function runFormatOrValidate(ctx) {
11409
13277
  process.exit(EXIT_SCHEMA_VALIDATION);
11410
13278
  }
11411
13279
  if (args.emitCanonical) {
11412
- const outPath = path17.resolve(args.emitCanonical);
11413
- fs15.mkdirSync(path17.dirname(outPath), { recursive: true });
11414
- fs15.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
13280
+ const outPath = path19.resolve(args.emitCanonical);
13281
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13282
+ fs17.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
11415
13283
  }
11416
13284
  try {
11417
13285
  const history = runHistoryPipeline(run, args);
@@ -11419,7 +13287,7 @@ async function runFormatOrValidate(ctx) {
11419
13287
  runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
11420
13288
  await dispatchNotifications(run, args);
11421
13289
  printResult(result, args, startMs);
11422
- process.exit(EXIT_SUCCESS2);
13290
+ process.exit(EXIT_SUCCESS3);
11423
13291
  } catch (err) {
11424
13292
  const msg = err instanceof Error ? err.message : String(err);
11425
13293
  console.error(`Generation failed: ${msg}`);
@@ -11433,7 +13301,7 @@ async function runFormatOrValidate(ctx) {
11433
13301
  assertValidRun2(data);
11434
13302
  warnLargeStateDocs(data.testCases);
11435
13303
  console.log("Valid canonical TestRunResult.");
11436
- process.exit(EXIT_SUCCESS2);
13304
+ process.exit(EXIT_SUCCESS3);
11437
13305
  } catch (err) {
11438
13306
  const msg = err instanceof Error ? err.message : String(err);
11439
13307
  console.error(msg);
@@ -11457,7 +13325,7 @@ async function runFormatOrValidate(ctx) {
11457
13325
  }
11458
13326
  warnLargeStateDocs(data.testCases);
11459
13327
  console.log("Valid RawRun (schemaVersion 1).");
11460
- process.exit(EXIT_SUCCESS2);
13328
+ process.exit(EXIT_SUCCESS3);
11461
13329
  }
11462
13330
  if (args.inputType === "canonical") {
11463
13331
  try {
@@ -11470,9 +13338,9 @@ ${msg}`);
11470
13338
  }
11471
13339
  const run = data;
11472
13340
  if (args.emitCanonical) {
11473
- const outPath = path17.resolve(args.emitCanonical);
11474
- fs15.mkdirSync(path17.dirname(outPath), { recursive: true });
11475
- fs15.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
13341
+ const outPath = path19.resolve(args.emitCanonical);
13342
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13343
+ fs17.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
11476
13344
  }
11477
13345
  try {
11478
13346
  const history = runHistoryPipeline(run, args);
@@ -11480,7 +13348,7 @@ ${msg}`);
11480
13348
  runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
11481
13349
  await dispatchNotifications(run, args);
11482
13350
  printResult(result, args, startMs);
11483
- process.exit(EXIT_SUCCESS2);
13351
+ process.exit(EXIT_SUCCESS3);
11484
13352
  } catch (err) {
11485
13353
  const msg = err instanceof Error ? err.message : String(err);
11486
13354
  console.error(`Generation failed: ${msg}`);
@@ -11505,7 +13373,7 @@ ${msg}`);
11505
13373
  let raw = data;
11506
13374
  let droppedMissingStory = 0;
11507
13375
  if (args.synthesizeStories) {
11508
- raw = synthesizeStories3(raw);
13376
+ raw = synthesizeStories4(raw);
11509
13377
  } else {
11510
13378
  const before = raw.testCases.length;
11511
13379
  const withStory = raw.testCases.filter(
@@ -11518,7 +13386,7 @@ ${msg}`);
11518
13386
  );
11519
13387
  }
11520
13388
  }
11521
- const canonical = canonicalizeRun5(raw);
13389
+ const canonical = canonicalizeRun6(raw);
11522
13390
  try {
11523
13391
  assertValidRun2(canonical);
11524
13392
  } catch (err) {
@@ -11528,9 +13396,9 @@ ${msg}`);
11528
13396
  process.exit(EXIT_CANONICAL_VALIDATION);
11529
13397
  }
11530
13398
  if (args.emitCanonical) {
11531
- const outPath = path17.resolve(args.emitCanonical);
11532
- fs15.mkdirSync(path17.dirname(outPath), { recursive: true });
11533
- fs15.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
13399
+ const outPath = path19.resolve(args.emitCanonical);
13400
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13401
+ fs17.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
11534
13402
  }
11535
13403
  try {
11536
13404
  const history = runHistoryPipeline(canonical, args);
@@ -11538,7 +13406,7 @@ ${msg}`);
11538
13406
  runCustomFormatters(canonical, customRequested, pluginConfig.formatters ?? {}, args);
11539
13407
  await dispatchNotifications(canonical, args);
11540
13408
  printResult(result, args, startMs, droppedMissingStory);
11541
- process.exit(EXIT_SUCCESS2);
13409
+ process.exit(EXIT_SUCCESS3);
11542
13410
  } catch (err) {
11543
13411
  const msg = err instanceof Error ? err.message : String(err);
11544
13412
  console.error(`Generation failed: ${msg}`);
@@ -11555,9 +13423,9 @@ function runCustomFormatters(run, customRequested, formatters, args) {
11555
13423
  const ext = formatter.fileExtension ?? formatName;
11556
13424
  const baseName = args.outputName ?? "report";
11557
13425
  const filename = args.outputNameTimestamp ? `${baseName}-${Math.floor(run.startedAtMs / 1e3)}.${ext}` : `${baseName}.${ext}`;
11558
- const filepath = path17.join(outputDir, filename);
11559
- fs15.mkdirSync(outputDir, { recursive: true });
11560
- fs15.writeFileSync(filepath, content, "utf8");
13426
+ const filepath = path19.join(outputDir, filename);
13427
+ fs17.mkdirSync(outputDir, { recursive: true });
13428
+ fs17.writeFileSync(filepath, content, "utf8");
11561
13429
  console.log(`Generated: ${filepath}`);
11562
13430
  } catch (err) {
11563
13431
  console.error(`Error running custom formatter "${formatName}": ${err instanceof Error ? err.message : String(err)}`);
@@ -11607,13 +13475,13 @@ async function dispatchNotifications(run, args) {
11607
13475
  }
11608
13476
  function runHistoryPipeline(run, args) {
11609
13477
  if (!args.historyFile) return void 0;
11610
- const historyPath = path17.resolve(args.historyFile);
13478
+ const historyPath = path19.resolve(args.historyFile);
11611
13479
  const store = loadHistory(
11612
13480
  { filePath: historyPath },
11613
13481
  {
11614
13482
  readFile: (p) => {
11615
13483
  try {
11616
- return fs15.readFileSync(p, "utf8");
13484
+ return fs17.readFileSync(p, "utf8");
11617
13485
  } catch {
11618
13486
  return void 0;
11619
13487
  }
@@ -11626,11 +13494,11 @@ function runHistoryPipeline(run, args) {
11626
13494
  run,
11627
13495
  maxRuns: args.maxHistoryRuns
11628
13496
  });
11629
- const dir = path17.dirname(historyPath);
11630
- fs15.mkdirSync(dir, { recursive: true });
13497
+ const dir = path19.dirname(historyPath);
13498
+ fs17.mkdirSync(dir, { recursive: true });
11631
13499
  saveHistory(
11632
13500
  { filePath: historyPath, store: updated },
11633
- { writeFile: (p, content) => fs15.writeFileSync(p, content, "utf8") }
13501
+ { writeFile: (p, content) => fs17.writeFileSync(p, content, "utf8") }
11634
13502
  );
11635
13503
  let metricsCount = 0;
11636
13504
  for (const testId of Object.keys(updated.tests)) {
@@ -11781,7 +13649,7 @@ function loadReviewContext(args) {
11781
13649
  console.error(
11782
13650
  "Error: --code-diff requires --patch <file> (generate it with: git diff --histogram > changes.patch)."
11783
13651
  );
11784
- process.exit(EXIT_USAGE2);
13652
+ process.exit(EXIT_USAGE3);
11785
13653
  }
11786
13654
  const sidecar = JSON.parse(readFileInput(args.codeDiffPath));
11787
13655
  const patch = readFileInput(args.patchPath);
@@ -11801,11 +13669,11 @@ function writeReviewReport(review, args) {
11801
13669
  const outputDir = args.outputDir ?? "reports";
11802
13670
  const baseName = args.outputName ?? "evidence-review";
11803
13671
  const suffix = args.outputNameTimestamp ? `-${Math.floor(review.run.startedAtMs / 1e3)}` : "";
11804
- fs15.mkdirSync(outputDir, { recursive: true });
11805
- const mdPath = path17.join(outputDir, `${baseName}${suffix}.md`);
11806
- const htmlPath = path17.join(outputDir, `${baseName}${suffix}.html`);
11807
- fs15.writeFileSync(mdPath, markdown, "utf8");
11808
- fs15.writeFileSync(htmlPath, html, "utf8");
13672
+ fs17.mkdirSync(outputDir, { recursive: true });
13673
+ const mdPath = path19.join(outputDir, `${baseName}${suffix}.md`);
13674
+ const htmlPath = path19.join(outputDir, `${baseName}${suffix}.html`);
13675
+ fs17.writeFileSync(mdPath, markdown, "utf8");
13676
+ fs17.writeFileSync(htmlPath, html, "utf8");
11809
13677
  return [mdPath, htmlPath];
11810
13678
  }
11811
13679
  function evaluateReviewGate(review, args) {
@@ -11860,9 +13728,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
11860
13728
  function printCompareResult(result, args, startMs) {
11861
13729
  const durationMs = Date.now() - startMs;
11862
13730
  if (result.prSummary && args.prSummaryFile) {
11863
- const outputPath = path17.resolve(args.prSummaryFile);
11864
- fs15.mkdirSync(path17.dirname(outputPath), { recursive: true });
11865
- fs15.writeFileSync(outputPath, result.prSummary, "utf8");
13731
+ const outputPath = path19.resolve(args.prSummaryFile);
13732
+ fs17.mkdirSync(path19.dirname(outputPath), { recursive: true });
13733
+ fs17.writeFileSync(outputPath, result.prSummary, "utf8");
11866
13734
  }
11867
13735
  if (args.jsonSummary) {
11868
13736
  console.log(
@@ -11896,13 +13764,13 @@ function printCompareResult(result, args, startMs) {
11896
13764
  }
11897
13765
  }
11898
13766
  function loadReleasePolicy(policyPath) {
11899
- const resolved = path17.resolve(policyPath);
11900
- if (!fs15.existsSync(resolved)) {
13767
+ const resolved = path19.resolve(policyPath);
13768
+ if (!fs17.existsSync(resolved)) {
11901
13769
  console.error(`Error: release policy file not found: ${resolved}`);
11902
- process.exit(EXIT_USAGE2);
13770
+ process.exit(EXIT_USAGE3);
11903
13771
  }
11904
13772
  try {
11905
- const raw = JSON.parse(fs15.readFileSync(resolved, "utf8"));
13773
+ const raw = JSON.parse(fs17.readFileSync(resolved, "utf8"));
11906
13774
  return {
11907
13775
  allowedOmissions: Array.isArray(raw.allowedOmissions) ? raw.allowedOmissions : [],
11908
13776
  allowedRegressions: Array.isArray(raw.allowedRegressions) ? raw.allowedRegressions : [],
@@ -11911,7 +13779,7 @@ function loadReleasePolicy(policyPath) {
11911
13779
  } catch (err) {
11912
13780
  const msg = err instanceof Error ? err.message : String(err);
11913
13781
  console.error(`Error reading release policy: ${msg}`);
11914
- process.exit(EXIT_USAGE2);
13782
+ process.exit(EXIT_USAGE3);
11915
13783
  }
11916
13784
  }
11917
13785
  function applyReleasePolicy(result, policy) {
@@ -11963,7 +13831,7 @@ function evaluateCompareGate(result, args) {
11963
13831
  return failures;
11964
13832
  }
11965
13833
  async function runPublishConfluence(rawArgs) {
11966
- const { values, positionals } = parseArgs2({
13834
+ const { values, positionals } = parseArgs3({
11967
13835
  args: rawArgs,
11968
13836
  options: {
11969
13837
  "page-id": { type: "string" },
@@ -11999,16 +13867,16 @@ Optional:
11999
13867
  --help Show this help
12000
13868
 
12001
13869
  Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens`);
12002
- process.exit(EXIT_SUCCESS2);
13870
+ process.exit(EXIT_SUCCESS3);
12003
13871
  }
12004
13872
  const inputFile = positionals[0];
12005
13873
  if (!inputFile) {
12006
13874
  console.error("Error: missing ADF file argument. Run with --help for usage.");
12007
- process.exit(EXIT_USAGE2);
13875
+ process.exit(EXIT_USAGE3);
12008
13876
  }
12009
- if (!fs15.existsSync(inputFile)) {
13877
+ if (!fs17.existsSync(inputFile)) {
12010
13878
  console.error(`Error: file not found: ${inputFile}`);
12011
- process.exit(EXIT_USAGE2);
13879
+ process.exit(EXIT_USAGE3);
12012
13880
  }
12013
13881
  const baseUrl = values["base-url"] ?? process.env.CONFLUENCE_BASE_URL;
12014
13882
  const email = values.email ?? process.env.CONFLUENCE_EMAIL;
@@ -12022,19 +13890,19 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12022
13890
  console.error(
12023
13891
  "Error: --base-url or CONFLUENCE_BASE_URL is required (e.g. https://acme.atlassian.net/wiki)"
12024
13892
  );
12025
- process.exit(EXIT_USAGE2);
13893
+ process.exit(EXIT_USAGE3);
12026
13894
  }
12027
13895
  if (!pageId && !spaceId) {
12028
13896
  console.error(
12029
13897
  "Error: specify either --page-id (to update) or --space-id (to create)"
12030
13898
  );
12031
- process.exit(EXIT_USAGE2);
13899
+ process.exit(EXIT_USAGE3);
12032
13900
  }
12033
13901
  if (!pageId && !title) {
12034
13902
  console.error("Error: --title is required when creating a new page");
12035
- process.exit(EXIT_USAGE2);
13903
+ process.exit(EXIT_USAGE3);
12036
13904
  }
12037
- const adf = fs15.readFileSync(path17.resolve(inputFile), "utf8");
13905
+ const adf = fs17.readFileSync(path19.resolve(inputFile), "utf8");
12038
13906
  if (dryRun) {
12039
13907
  console.log(
12040
13908
  JSON.stringify(
@@ -12051,13 +13919,13 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12051
13919
  2
12052
13920
  )
12053
13921
  );
12054
- process.exit(EXIT_SUCCESS2);
13922
+ process.exit(EXIT_SUCCESS3);
12055
13923
  }
12056
13924
  if (!email || !token) {
12057
13925
  console.error(
12058
13926
  "Error: --email/CONFLUENCE_EMAIL and --token/CONFLUENCE_TOKEN are required unless --dry-run is set"
12059
13927
  );
12060
- process.exit(EXIT_USAGE2);
13928
+ process.exit(EXIT_USAGE3);
12061
13929
  }
12062
13930
  try {
12063
13931
  const result = await publishConfluencePage(
@@ -12067,14 +13935,14 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12067
13935
  console.log(
12068
13936
  `${result.action === "created" ? "Created" : "Updated"} "${result.title}" (v${result.version}) \u2192 ${result.url}`
12069
13937
  );
12070
- process.exit(EXIT_SUCCESS2);
13938
+ process.exit(EXIT_SUCCESS3);
12071
13939
  } catch (err) {
12072
13940
  console.error(`Error: ${err.message}`);
12073
13941
  process.exit(EXIT_GENERATION);
12074
13942
  }
12075
13943
  }
12076
13944
  async function runPublishJira(rawArgs) {
12077
- const { values, positionals } = parseArgs2({
13945
+ const { values, positionals } = parseArgs3({
12078
13946
  args: rawArgs,
12079
13947
  options: {
12080
13948
  issue: { type: "string" },
@@ -12106,16 +13974,16 @@ Optional:
12106
13974
  --help Show this help
12107
13975
 
12108
13976
  Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens`);
12109
- process.exit(EXIT_SUCCESS2);
13977
+ process.exit(EXIT_SUCCESS3);
12110
13978
  }
12111
13979
  const inputFile = positionals[0];
12112
13980
  if (!inputFile) {
12113
13981
  console.error("Error: missing ADF file argument. Run with --help for usage.");
12114
- process.exit(EXIT_USAGE2);
13982
+ process.exit(EXIT_USAGE3);
12115
13983
  }
12116
- if (!fs15.existsSync(inputFile)) {
13984
+ if (!fs17.existsSync(inputFile)) {
12117
13985
  console.error(`Error: file not found: ${inputFile}`);
12118
- process.exit(EXIT_USAGE2);
13986
+ process.exit(EXIT_USAGE3);
12119
13987
  }
12120
13988
  const baseUrl = values["base-url"] ?? process.env.JIRA_BASE_URL;
12121
13989
  const email = values.email ?? process.env.JIRA_EMAIL;
@@ -12127,20 +13995,20 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12127
13995
  console.error(
12128
13996
  "Error: --base-url or JIRA_BASE_URL is required (e.g. https://acme.atlassian.net)"
12129
13997
  );
12130
- process.exit(EXIT_USAGE2);
13998
+ process.exit(EXIT_USAGE3);
12131
13999
  }
12132
14000
  if (!issueKey) {
12133
14001
  console.error("Error: --issue <KEY> is required (e.g. --issue PROJ-123)");
12134
- process.exit(EXIT_USAGE2);
14002
+ process.exit(EXIT_USAGE3);
12135
14003
  }
12136
14004
  if (modeRaw !== "comment" && modeRaw !== "description") {
12137
14005
  console.error(
12138
14006
  `Error: --mode must be "comment" or "description" (got "${modeRaw}")`
12139
14007
  );
12140
- process.exit(EXIT_USAGE2);
14008
+ process.exit(EXIT_USAGE3);
12141
14009
  }
12142
14010
  const mode = modeRaw;
12143
- const adf = fs15.readFileSync(path17.resolve(inputFile), "utf8");
14011
+ const adf = fs17.readFileSync(path19.resolve(inputFile), "utf8");
12144
14012
  if (dryRun) {
12145
14013
  console.log(
12146
14014
  JSON.stringify(
@@ -12155,13 +14023,13 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12155
14023
  2
12156
14024
  )
12157
14025
  );
12158
- process.exit(EXIT_SUCCESS2);
14026
+ process.exit(EXIT_SUCCESS3);
12159
14027
  }
12160
14028
  if (!email || !token) {
12161
14029
  console.error(
12162
14030
  "Error: --email/JIRA_EMAIL and --token/JIRA_TOKEN are required unless --dry-run is set"
12163
14031
  );
12164
- process.exit(EXIT_USAGE2);
14032
+ process.exit(EXIT_USAGE3);
12165
14033
  }
12166
14034
  try {
12167
14035
  const result = await publishJiraIssue(
@@ -12175,14 +14043,14 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12175
14043
  } else {
12176
14044
  console.log(`Updated description for ${result.issueKey} \u2192 ${result.url}`);
12177
14045
  }
12178
- process.exit(EXIT_SUCCESS2);
14046
+ process.exit(EXIT_SUCCESS3);
12179
14047
  } catch (err) {
12180
14048
  console.error(`Error: ${err.message}`);
12181
14049
  process.exit(EXIT_GENERATION);
12182
14050
  }
12183
14051
  }
12184
14052
  function runNew(rawArgs) {
12185
- const { values, positionals } = parseArgs2({
14053
+ const { values, positionals } = parseArgs3({
12186
14054
  args: rawArgs,
12187
14055
  options: {
12188
14056
  dir: { type: "string" },
@@ -12199,7 +14067,7 @@ function runNew(rawArgs) {
12199
14067
  `Usage: executable-stories new <template> "<name>" [--dir <docs-dir>] [--scenario-id <id>] [--force]`
12200
14068
  );
12201
14069
  console.error(`Templates: ${TEMPLATES.join(", ")}`);
12202
- return EXIT_USAGE2;
14070
+ return EXIT_USAGE3;
12203
14071
  }
12204
14072
  try {
12205
14073
  const result = scaffoldDoc({
@@ -12213,36 +14081,41 @@ function runNew(rawArgs) {
12213
14081
  console.log(` Title: ${result.title}`);
12214
14082
  console.log("");
12215
14083
  console.log("Next: fill in the content and link verifying stories in `verifiedBy`.");
12216
- return EXIT_SUCCESS2;
14084
+ return EXIT_SUCCESS3;
12217
14085
  } catch (err) {
12218
14086
  console.error(`Error: ${err.message}`);
12219
- return EXIT_USAGE2;
14087
+ return EXIT_USAGE3;
12220
14088
  }
12221
14089
  }
12222
14090
  async function runCheckLinks(rawArgs) {
12223
- const { values, positionals } = parseArgs2({
14091
+ const { values, positionals } = parseArgs3({
12224
14092
  args: rawArgs,
12225
14093
  options: {
12226
14094
  external: { type: "boolean", default: false },
12227
- json: { type: "boolean", default: false }
14095
+ json: { type: "boolean", default: false },
14096
+ "site-root": { type: "string" },
14097
+ assets: { type: "string", multiple: true }
12228
14098
  },
12229
14099
  allowPositionals: true,
12230
14100
  strict: true
12231
14101
  });
12232
14102
  try {
14103
+ const assets = values.assets;
12233
14104
  const report = await checkLinks({
12234
14105
  target: positionals[0] ?? ".",
12235
- checkExternal: values.external
14106
+ checkExternal: values.external,
14107
+ ...values["site-root"] ? { siteRoot: values["site-root"] } : {},
14108
+ ...assets && assets.length > 0 ? { assetRoots: assets } : {}
12236
14109
  });
12237
14110
  console.log(values.json ? JSON.stringify(report, null, 2) : formatLinkReport(report));
12238
- return report.brokenCount > 0 ? EXIT_GENERATION : EXIT_SUCCESS2;
14111
+ return report.brokenCount > 0 ? EXIT_GENERATION : EXIT_SUCCESS3;
12239
14112
  } catch (err) {
12240
14113
  console.error(`Error: ${err.message}`);
12241
- return EXIT_USAGE2;
14114
+ return EXIT_USAGE3;
12242
14115
  }
12243
14116
  }
12244
14117
  async function runImportOpenApi(rawArgs) {
12245
- const { values, positionals } = parseArgs2({
14118
+ const { values, positionals } = parseArgs3({
12246
14119
  args: rawArgs,
12247
14120
  options: {
12248
14121
  "output-dir": { type: "string" },
@@ -12255,7 +14128,7 @@ async function runImportOpenApi(rawArgs) {
12255
14128
  const spec = positionals[0];
12256
14129
  if (!spec) {
12257
14130
  console.error(`Usage: executable-stories import-openapi <spec.json|yaml> [--output-dir <dir>] [--run <story-report.json>] [--force]`);
12258
- return EXIT_USAGE2;
14131
+ return EXIT_USAGE3;
12259
14132
  }
12260
14133
  try {
12261
14134
  const result = await importOpenApi({
@@ -12269,10 +14142,10 @@ async function runImportOpenApi(rawArgs) {
12269
14142
  if (result.uncoveredCount > 0) {
12270
14143
  console.log(` \u26A0 ${result.uncoveredCount} endpoint(s) have no verifying story`);
12271
14144
  }
12272
- return EXIT_SUCCESS2;
14145
+ return EXIT_SUCCESS3;
12273
14146
  } catch (err) {
12274
14147
  console.error(`Error: ${err.message}`);
12275
- return EXIT_USAGE2;
14148
+ return EXIT_USAGE3;
12276
14149
  }
12277
14150
  }
12278
14151
  async function runDeploy(rawArgs) {
@@ -12282,9 +14155,9 @@ async function runDeploy(rawArgs) {
12282
14155
  console.error(" deploy record <file> --env <env> [--tag <tag>] [--ledger <path>]");
12283
14156
  console.error(" deploy status [--ledger <path>] [--json]");
12284
14157
  console.error(" deploy diff <env-a> <env-b> [--ledger <path>] [--json]");
12285
- return EXIT_USAGE2;
14158
+ return EXIT_USAGE3;
12286
14159
  }
12287
- const { values, positionals } = parseArgs2({
14160
+ const { values, positionals } = parseArgs3({
12288
14161
  args: rawArgs.slice(1),
12289
14162
  options: {
12290
14163
  env: { type: "string" },
@@ -12309,19 +14182,19 @@ OPTIONS
12309
14182
  --tag <tag> Optional Git tag for this deployment (e.g. v1.2.3)
12310
14183
  --ledger <path> Path to deployment ledger JSON (default: .executable-stories/deployments.json)
12311
14184
  --json Output as JSON instead of text`);
12312
- return EXIT_SUCCESS2;
14185
+ return EXIT_SUCCESS3;
12313
14186
  }
12314
14187
  const ledgerPath = values.ledger;
12315
14188
  if (mode === "record") {
12316
14189
  const inputFile = positionals[0];
12317
14190
  if (!inputFile) {
12318
14191
  console.error("Error: deploy record requires an input file.");
12319
- return EXIT_USAGE2;
14192
+ return EXIT_USAGE3;
12320
14193
  }
12321
14194
  const env = values.env;
12322
14195
  if (!env) {
12323
14196
  console.error("Error: deploy record requires --env <environment>.");
12324
- return EXIT_USAGE2;
14197
+ return EXIT_USAGE3;
12325
14198
  }
12326
14199
  const text2 = readFileInput(inputFile);
12327
14200
  const { run } = normalizeRunFromText(text2, {
@@ -12347,14 +14220,14 @@ OPTIONS
12347
14220
  console.error(` Tag: ${result.entry.tag}`);
12348
14221
  }
12349
14222
  console.error(` Ledger: ${result.ledgerPath}`);
12350
- return EXIT_SUCCESS2;
14223
+ return EXIT_SUCCESS3;
12351
14224
  }
12352
14225
  if (mode === "status") {
12353
14226
  const status = getDeploymentStatus(ledgerPath);
12354
14227
  const envs = Object.keys(status.environments);
12355
14228
  if (envs.length === 0) {
12356
14229
  console.error("No deployments recorded yet.");
12357
- return EXIT_SUCCESS2;
14230
+ return EXIT_SUCCESS3;
12358
14231
  }
12359
14232
  if (values.json) {
12360
14233
  console.log(JSON.stringify(status, null, 2));
@@ -12380,14 +14253,14 @@ OPTIONS
12380
14253
  }
12381
14254
  console.log(`Ledger: ${ledgerPath}`);
12382
14255
  }
12383
- return EXIT_SUCCESS2;
14256
+ return EXIT_SUCCESS3;
12384
14257
  }
12385
14258
  if (mode === "diff") {
12386
14259
  const envA = positionals[0];
12387
14260
  const envB = positionals[1];
12388
14261
  if (!envA || !envB) {
12389
14262
  console.error("Error: deploy diff requires two environment names.");
12390
- return EXIT_USAGE2;
14263
+ return EXIT_USAGE3;
12391
14264
  }
12392
14265
  try {
12393
14266
  const drift = getEnvironmentDrift(ledgerPath, envA, envB);
@@ -12433,11 +14306,11 @@ OPTIONS
12433
14306
  }
12434
14307
  } catch (err) {
12435
14308
  console.error(`Error: ${err.message}`);
12436
- return EXIT_USAGE2;
14309
+ return EXIT_USAGE3;
12437
14310
  }
12438
- return EXIT_SUCCESS2;
14311
+ return EXIT_SUCCESS3;
12439
14312
  }
12440
- return EXIT_USAGE2;
14313
+ return EXIT_USAGE3;
12441
14314
  }
12442
14315
  function createDefaultCliArgs() {
12443
14316
  return {
@@ -12493,6 +14366,6 @@ function createDefaultCliArgs() {
12493
14366
  }
12494
14367
  main().catch((err) => {
12495
14368
  console.error(err);
12496
- process.exit(EXIT_USAGE2);
14369
+ process.exit(EXIT_USAGE3);
12497
14370
  });
12498
14371
  //# sourceMappingURL=cli.js.map