executable-stories-formatters 1.9.2 → 1.10.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("");
@@ -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
  });
@@ -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",
@@ -9177,6 +10558,8 @@ var COMPLETION_SUBCOMMANDS = [
9177
10558
  ["new", "Scaffold a docs page from a template"],
9178
10559
  ["check-links", "Scan docs for broken links"],
9179
10560
  ["push", "Send a run to Executable Stories Cloud"],
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";
@@ -9891,7 +11319,7 @@ Options:
9891
11319
  Exit codes: 0 pushed, 1 push rejected/failed, 4 usage error.`;
9892
11320
  function defaultDeps() {
9893
11321
  return {
9894
- readFile: (filePath) => fs13.readFileSync(filePath, "utf8"),
11322
+ readFile: (filePath) => fs14.readFileSync(filePath, "utf8"),
9895
11323
  fetchFn: fetch,
9896
11324
  git: (args) => {
9897
11325
  try {
@@ -10020,9 +11448,334 @@ async function runPush(rawArgs, depsOverride = {}) {
10020
11448
  return EXIT_SUCCESS;
10021
11449
  }
10022
11450
 
11451
+ // src/sync/run.ts
11452
+ import * as fs15 from "fs";
11453
+ import * as path17 from "path";
11454
+ import { parseArgs as parseArgs2 } from "util";
11455
+ import { canonicalizeRun as canonicalizeRun5 } from "executable-stories-core/converters/acl/index";
11456
+ import { synthesizeStories as synthesizeStories3 } from "executable-stories-core/converters/synthesize";
11457
+
11458
+ // src/config.ts
11459
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
11460
+ import { resolve as resolve9 } from "path";
11461
+ var CONFIG_CANDIDATES = [
11462
+ "executable-stories.config.mjs",
11463
+ "executable-stories.config.js",
11464
+ "executable-stories.config.json"
11465
+ ];
11466
+ async function loadConfig(configPath) {
11467
+ let resolved;
11468
+ if (configPath) {
11469
+ resolved = resolve9(configPath);
11470
+ } else {
11471
+ const present = CONFIG_CANDIDATES.map((name) => resolve9(process.cwd(), name)).filter(existsSync11);
11472
+ if (present.length > 1) {
11473
+ throw new Error(
11474
+ `Multiple config files found in this directory:
11475
+ ` + present.map((p) => ` - ${p}`).join("\n") + `
11476
+ Keep only one, or pass --config <path> to choose which to load.`
11477
+ );
11478
+ }
11479
+ resolved = present[0];
11480
+ }
11481
+ if (!resolved || !existsSync11(resolved)) return {};
11482
+ const isJson = resolved.endsWith(".json");
11483
+ let config;
11484
+ if (isJson) {
11485
+ try {
11486
+ config = JSON.parse(readFileSync10(resolved, "utf8"));
11487
+ } catch (err) {
11488
+ throw new Error(`Config file at ${resolved} is not valid JSON: ${err.message}`);
11489
+ }
11490
+ } else {
11491
+ config = (await import(resolved)).default;
11492
+ }
11493
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
11494
+ throw new Error(
11495
+ 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}`
11496
+ );
11497
+ }
11498
+ const { formatters, sync } = config;
11499
+ return {
11500
+ ...formatters === void 0 ? {} : { formatters },
11501
+ ...sync === void 0 ? {} : { sync }
11502
+ };
11503
+ }
11504
+
11505
+ // src/sync/run.ts
11506
+ var EXIT_SUCCESS2 = 0;
11507
+ var EXIT_FAILED = 1;
11508
+ var EXIT_USAGE2 = 4;
11509
+ var COVERAGE_HELP = `Usage:
11510
+ executable-stories coverage <provider> <run.json> [options]
11511
+
11512
+ Compares what your tests cover against what a test-management system holds.
11513
+ Read-only: needs nothing but a read-scoped API key, and writes nothing remote.
11514
+
11515
+ Providers: ${PROVIDER_NAMES.join(", ")}
11516
+
11517
+ Options:
11518
+ --config <path> Config file (default: executable-stories.config.mjs, .js, or .json)
11519
+ --output-dir <dir> Where the JSON and Markdown land (default: reports)
11520
+ --report-url <url> Published report URL, used for deep links
11521
+ --lockfile <path> Default: ${DEFAULT_LOCKFILE_PATH}
11522
+ --quiet Write the artifacts, skip the stdout summary
11523
+ -h, --help Show this help
11524
+
11525
+ Credentials come from the environment:
11526
+ TestRail TESTRAIL_USERNAME, TESTRAIL_API_KEY
11527
+ Xray XRAY_CLIENT_ID, XRAY_CLIENT_SECRET (JIRA_EMAIL/JIRA_TOKEN to edit Jira fields)
11528
+
11529
+ Exit codes: 0 report produced, 1 provider unreachable, 4 usage error.`;
11530
+ var SYNC_HELP = `Usage:
11531
+ executable-stories sync <provider> <run.json> [options]
11532
+
11533
+ Pushes stories into a test-management system: case bodies authored from the
11534
+ test, executions recorded against them, evidence attached.
11535
+
11536
+ Prints the plan and changes nothing unless --apply is passed.
11537
+
11538
+ Providers: ${PROVIDER_NAMES.join(", ")}
11539
+
11540
+ Options:
11541
+ --apply Actually write. Without it, this is a dry run.
11542
+ --attach <policy> failed (default), all, or none
11543
+ --config <path> Config file (default: executable-stories.config.mjs, .js, or .json)
11544
+ --lockfile <path> Default: ${DEFAULT_LOCKFILE_PATH}
11545
+ --report-url <url> Published report URL, used for deep links
11546
+ --output-dir <dir> Where coverage artifacts land (default: reports)
11547
+ --continue-on-error Exit 0 even when some writes failed
11548
+ --init Print a config block for this provider and exit
11549
+ -h, --help Show this help
11550
+
11551
+ The lockfile binds each story to its case. Commit it: the diff shows up in the
11552
+ pull request that created the case.
11553
+
11554
+ Exit codes: 0 applied (or planned), 1 some writes failed, 4 usage error.`;
11555
+ function defaultDeps2() {
11556
+ return {
11557
+ readFile: (filePath) => fs15.readFileSync(filePath, "utf8"),
11558
+ fileExists: (filePath) => fs15.existsSync(filePath),
11559
+ writeFile: (filePath, contents) => {
11560
+ fs15.mkdirSync(path17.dirname(path17.resolve(filePath)), { recursive: true });
11561
+ fs15.writeFileSync(filePath, contents, "utf8");
11562
+ },
11563
+ fetchFn: globalThis.fetch,
11564
+ env: process.env,
11565
+ log: console.log,
11566
+ error: console.error,
11567
+ loadConfigFn: loadConfig
11568
+ };
11569
+ }
11570
+ var CONFIG_TEMPLATES = {
11571
+ testrail: `export default {
11572
+ sync: {
11573
+ testrail: {
11574
+ url: "https://acme.testrail.io",
11575
+ projectId: 1,
11576
+ suiteId: 1,
11577
+ // Section that newly created cases land in. Without it, creation is refused.
11578
+ sectionId: 1,
11579
+ // TestRail ships no "skipped" status; set one to record skipped tests.
11580
+ // statusIds: { skipped: 6 },
11581
+ // Only needed if this instance uses a customised case template.
11582
+ // fields: { steps: "custom_steps_separated", description: "custom_preconds" },
11583
+ },
11584
+ },
11585
+ };
11586
+
11587
+ // Environment: TESTRAIL_USERNAME (login email), TESTRAIL_API_KEY (My Settings -> API Keys)`,
11588
+ xray: `export default {
11589
+ sync: {
11590
+ xray: {
11591
+ jiraBaseUrl: "https://acme.atlassian.net",
11592
+ projectKey: "PROJ",
11593
+ // testPlanKey: "PROJ-100",
11594
+ },
11595
+ },
11596
+ };
11597
+
11598
+ // Environment: XRAY_CLIENT_ID, XRAY_CLIENT_SECRET (Jira -> Apps -> Xray -> API Keys)
11599
+ // Optional: JIRA_EMAIL, JIRA_TOKEN (needed to update an existing test's summary/description)`
11600
+ };
11601
+ var JSON_CONFIG_TEMPLATES = {
11602
+ testrail: JSON.stringify(
11603
+ { sync: { testrail: { url: "https://acme.testrail.io", projectId: 1, suiteId: 1, sectionId: 1 } } },
11604
+ null,
11605
+ 2
11606
+ ),
11607
+ xray: JSON.stringify(
11608
+ { sync: { xray: { jiraBaseUrl: "https://acme.atlassian.net", projectKey: "PROJ" } } },
11609
+ null,
11610
+ 2
11611
+ )
11612
+ };
11613
+ function isStoryReport2(data) {
11614
+ return typeof data.schemaVersion === "string";
11615
+ }
11616
+ function loadRun(inputPath, deps) {
11617
+ const data = JSON.parse(deps.readFile(inputPath));
11618
+ if (isStoryReport2(data)) {
11619
+ throw new Error(
11620
+ `${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).`
11621
+ );
11622
+ }
11623
+ return canonicalizeRun5(synthesizeStories3(data));
11624
+ }
11625
+ async function runSyncCommand(mode, rawArgs, depsOverride = {}) {
11626
+ const deps = { ...defaultDeps2(), ...depsOverride };
11627
+ const help = mode === "sync" ? SYNC_HELP : COVERAGE_HELP;
11628
+ let parsed;
11629
+ try {
11630
+ parsed = parseArgs2({
11631
+ args: rawArgs,
11632
+ allowPositionals: true,
11633
+ options: {
11634
+ apply: { type: "boolean", default: false },
11635
+ attach: { type: "string" },
11636
+ config: { type: "string" },
11637
+ lockfile: { type: "string" },
11638
+ "report-url": { type: "string" },
11639
+ "output-dir": { type: "string" },
11640
+ "continue-on-error": { type: "boolean", default: false },
11641
+ init: { type: "boolean", default: false },
11642
+ quiet: { type: "boolean", default: false },
11643
+ help: { type: "boolean", short: "h", default: false }
11644
+ }
11645
+ });
11646
+ } catch (err) {
11647
+ deps.error(err instanceof Error ? err.message : String(err));
11648
+ deps.error(help);
11649
+ return EXIT_USAGE2;
11650
+ }
11651
+ if (parsed.values.help) {
11652
+ deps.log(help);
11653
+ return EXIT_SUCCESS2;
11654
+ }
11655
+ const providerName = parsed.positionals[0];
11656
+ if (!providerName || !isProviderName(providerName)) {
11657
+ deps.error(
11658
+ providerName ? `Unknown provider "${providerName}". Available: ${PROVIDER_NAMES.join(", ")}.` : `${mode} needs a provider: executable-stories ${mode} <${PROVIDER_NAMES.join("|")}> <run.json>`
11659
+ );
11660
+ deps.error(help);
11661
+ return EXIT_USAGE2;
11662
+ }
11663
+ if (parsed.values.init) {
11664
+ deps.log(CONFIG_TEMPLATES[providerName]);
11665
+ deps.log("");
11666
+ deps.log(
11667
+ `Save the block above as executable-stories.config.mjs (or merge the \`sync\` key into the one you have), then run:
11668
+ executable-stories coverage ${providerName} reports/raw-run.json`
11669
+ );
11670
+ deps.log("");
11671
+ deps.log(
11672
+ `Not a JavaScript project? Put the same \`sync\` object in executable-stories.config.json instead:`
11673
+ );
11674
+ deps.log(JSON_CONFIG_TEMPLATES[providerName]);
11675
+ return EXIT_SUCCESS2;
11676
+ }
11677
+ const inputPath = parsed.positionals[1];
11678
+ if (!inputPath) {
11679
+ deps.error(`${mode} needs a run file: executable-stories ${mode} ${providerName} <run.json>`);
11680
+ deps.error(help);
11681
+ return EXIT_USAGE2;
11682
+ }
11683
+ const attach = parsed.values.attach;
11684
+ if (attach && !["failed", "all", "none"].includes(attach)) {
11685
+ deps.error(`--attach must be one of: failed, all, none (got "${attach}")`);
11686
+ return EXIT_USAGE2;
11687
+ }
11688
+ let run;
11689
+ try {
11690
+ run = loadRun(inputPath, deps);
11691
+ } catch (err) {
11692
+ deps.error(`Could not read ${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
11693
+ return EXIT_USAGE2;
11694
+ }
11695
+ const logger = { warn: (message) => deps.error(`Warning: ${message}`) };
11696
+ let targets;
11697
+ try {
11698
+ const config = await deps.loadConfigFn(parsed.values.config);
11699
+ targets = config.sync ?? {};
11700
+ } catch (err) {
11701
+ deps.error(err instanceof Error ? err.message : String(err));
11702
+ return EXIT_USAGE2;
11703
+ }
11704
+ let built;
11705
+ try {
11706
+ built = buildProvider(
11707
+ { name: providerName, targets, env: deps.env },
11708
+ { fetch: deps.fetchFn, logger }
11709
+ );
11710
+ } catch (err) {
11711
+ deps.error(err instanceof Error ? err.message : String(err));
11712
+ return EXIT_USAGE2;
11713
+ }
11714
+ const targetConfig = targets[providerName] ?? {};
11715
+ const engineConfig = {
11716
+ ...built.engineDefaults,
11717
+ ...targetConfig,
11718
+ ...parsed.values["report-url"] ? { reportUrl: parsed.values["report-url"] } : {},
11719
+ ...attach ? { attach } : {}
11720
+ };
11721
+ const lockfilePath = parsed.values.lockfile ?? DEFAULT_LOCKFILE_PATH;
11722
+ const outputDir = parsed.values["output-dir"] ?? "reports";
11723
+ let lockfile;
11724
+ try {
11725
+ lockfile = deps.fileExists(lockfilePath) ? parseLockfile(deps.readFile(lockfilePath), lockfilePath) : emptyLockfile();
11726
+ } catch (err) {
11727
+ deps.error(err instanceof Error ? err.message : String(err));
11728
+ return EXIT_USAGE2;
11729
+ }
11730
+ let analysis;
11731
+ try {
11732
+ analysis = await analyzeSync({
11733
+ run,
11734
+ provider: built.provider,
11735
+ lockfile,
11736
+ config: engineConfig
11737
+ });
11738
+ } catch (err) {
11739
+ deps.error(`Could not read from ${providerName}: ${err instanceof Error ? err.message : String(err)}`);
11740
+ return EXIT_FAILED;
11741
+ }
11742
+ const jsonPath = path17.join(outputDir, `sync-coverage.${providerName}.json`);
11743
+ const markdownPath = path17.join(outputDir, `sync-coverage.${providerName}.md`);
11744
+ deps.writeFile(jsonPath, `${JSON.stringify(buildCoverageJson(analysis), null, 2)}
11745
+ `);
11746
+ deps.writeFile(markdownPath, `${renderCoverageMarkdown(analysis)}
11747
+ `);
11748
+ if (mode === "coverage") {
11749
+ if (!parsed.values.quiet) {
11750
+ deps.log(renderCoverageText(analysis));
11751
+ deps.log("");
11752
+ }
11753
+ deps.log(`Wrote ${jsonPath} and ${markdownPath}`);
11754
+ return EXIT_SUCCESS2;
11755
+ }
11756
+ const dryRun = !parsed.values.apply;
11757
+ deps.log(renderPlan(analysis, { dryRun }));
11758
+ if (dryRun) return EXIT_SUCCESS2;
11759
+ const applied = await applySync(
11760
+ { analysis, provider: built.provider, lockfile, config: engineConfig },
11761
+ { logger }
11762
+ );
11763
+ deps.writeFile(lockfilePath, serializeLockfile(lockfile));
11764
+ deps.log("");
11765
+ deps.log(renderApplyResult(applied));
11766
+ if (applied.errors.length > 0 && !parsed.values["continue-on-error"]) {
11767
+ deps.error(
11768
+ `
11769
+ ${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.`
11770
+ );
11771
+ return EXIT_FAILED;
11772
+ }
11773
+ return EXIT_SUCCESS2;
11774
+ }
11775
+
10023
11776
  // src/import-openapi.ts
10024
- import * as fs14 from "fs";
10025
- import * as path16 from "path";
11777
+ import * as fs16 from "fs";
11778
+ import * as path18 from "path";
10026
11779
  import { parse as parseYamlString } from "yaml";
10027
11780
  var HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
10028
11781
  function parseYaml2(raw, specPath) {
@@ -10035,9 +11788,9 @@ function parseYaml2(raw, specPath) {
10035
11788
  }
10036
11789
  }
10037
11790
  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();
11791
+ if (!fs16.existsSync(specPath)) throw new Error(`Spec not found: ${specPath}`);
11792
+ const raw = fs16.readFileSync(specPath, "utf8");
11793
+ const ext = path18.extname(specPath).toLowerCase();
10041
11794
  if (ext === ".json") return JSON.parse(raw);
10042
11795
  if (ext === ".yaml" || ext === ".yml") return parseYaml2(raw, specPath);
10043
11796
  try {
@@ -10068,8 +11821,8 @@ function extractEndpoints(spec) {
10068
11821
  }
10069
11822
  function loadScenarios(runFile) {
10070
11823
  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"));
11824
+ if (!fs16.existsSync(runFile)) throw new Error(`Run file not found: ${runFile}`);
11825
+ const report = JSON.parse(fs16.readFileSync(runFile, "utf8"));
10073
11826
  return (report.features ?? []).flatMap((f) => f.scenarios ?? []);
10074
11827
  }
10075
11828
  function endpointRefs(endpoint) {
@@ -10176,25 +11929,25 @@ async function importOpenApi(options) {
10176
11929
  list.push(item);
10177
11930
  groups.set(item.endpoint.tag, list);
10178
11931
  }
10179
- const outputDir = options.outputDir ?? path16.join("src", "content", "docs", "api");
10180
- if (fs14.existsSync(outputDir) && !options.force) {
10181
- const entries = fs14.readdirSync(outputDir);
11932
+ const outputDir = options.outputDir ?? path18.join("src", "content", "docs", "api");
11933
+ if (fs16.existsSync(outputDir) && !options.force) {
11934
+ const entries = fs16.readdirSync(outputDir);
10182
11935
  if (entries.length > 0) {
10183
11936
  throw new Error(`Output directory "${outputDir}" is not empty. Use --force to overwrite.`);
10184
11937
  }
10185
11938
  }
10186
- fs14.mkdirSync(outputDir, { recursive: true });
11939
+ fs16.mkdirSync(outputDir, { recursive: true });
10187
11940
  const coveredCount = coverage.filter((c) => c.status === "covered").length;
10188
11941
  const uncoveredCount = coverage.filter((c) => c.status === "uncovered").length;
10189
- fs14.writeFileSync(
10190
- path16.join(outputDir, "index.mdx"),
11942
+ fs16.writeFileSync(
11943
+ path18.join(outputDir, "index.mdx"),
10191
11944
  renderIndex(groups, hasRun, { endpointCount: endpoints.length, coveredCount, uncoveredCount }),
10192
11945
  "utf8"
10193
11946
  );
10194
11947
  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");
11948
+ const dir = path18.join(outputDir, slug(tag));
11949
+ fs16.mkdirSync(dir, { recursive: true });
11950
+ fs16.writeFileSync(path18.join(dir, "index.mdx"), renderTagPage(tag, rows, hasRun), "utf8");
10198
11951
  }
10199
11952
  return {
10200
11953
  outputDir,
@@ -10205,43 +11958,12 @@ async function importOpenApi(options) {
10205
11958
  };
10206
11959
  }
10207
11960
 
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
11961
  // src/cli.ts
10240
- var EXIT_SUCCESS2 = 0;
11962
+ var EXIT_SUCCESS3 = 0;
10241
11963
  var EXIT_SCHEMA_VALIDATION = 1;
10242
11964
  var EXIT_CANONICAL_VALIDATION = 2;
10243
11965
  var EXIT_GENERATION = 3;
10244
- var EXIT_USAGE2 = 4;
11966
+ var EXIT_USAGE3 = 4;
10245
11967
  var EXIT_COMPARE_GATE = 5;
10246
11968
  var EXIT_REVIEW_GATE = 5;
10247
11969
  var EXIT_AGENT_GATE = 5;
@@ -10270,6 +11992,8 @@ USAGE
10270
11992
  executable-stories new <template> "<name>" [options]
10271
11993
  executable-stories check-links <dir> [options]
10272
11994
  executable-stories push <run.json> [--key <es_...>] [--url <base>] [--repo <org/name>]
11995
+ executable-stories coverage <testrail|xray> <run.json> [options]
11996
+ executable-stories sync <testrail|xray> <run.json> [--apply] [options]
10273
11997
  executable-stories import-openapi <spec> [options]
10274
11998
  executable-stories publish-confluence <file.adf.json> [options]
10275
11999
  executable-stories publish-jira <file.adf.json> [options]
@@ -10295,6 +12019,8 @@ SUBCOMMANDS
10295
12019
  new Scaffold a docs page from a template (adr, runbook, decision-log, incident, scenario-note)
10296
12020
  check-links Scan docs for broken internal/external links (CI-friendly exit code)
10297
12021
  push Send a run (StoryReport or raw run JSON) to Executable Stories Cloud
12022
+ coverage Compare your stories against a test-management system (read-only)
12023
+ sync Push cases, executions, and evidence to TestRail or Xray (dry run by default)
10298
12024
  import-openapi Generate API doc pages from an OpenAPI spec, linked to verifying stories
10299
12025
  publish-confluence Publish an ADF JSON file to a Confluence page via REST API
10300
12026
  publish-jira Publish an ADF JSON file to a Jira issue (as comment or description)
@@ -10487,18 +12213,19 @@ EXIT CODES
10487
12213
  function parseTextJsonFormat(flag, value) {
10488
12214
  if (value !== "text" && value !== "json") {
10489
12215
  console.error(`Error: ${flag} must be "text" or "json", got "${value}".`);
10490
- process.exit(EXIT_USAGE2);
12216
+ process.exit(EXIT_USAGE3);
10491
12217
  }
10492
12218
  return value;
10493
12219
  }
10494
12220
  async function parseCliArgs(argv) {
10495
12221
  const args = argv.slice(2);
10496
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
12222
+ const SELF_DOCUMENTING = /* @__PURE__ */ new Set(["sync", "coverage"]);
12223
+ if (args.length === 0 || (args.includes("--help") || args.includes("-h")) && !SELF_DOCUMENTING.has(args[0] ?? "")) {
10497
12224
  console.log(HELP_TEXT);
10498
- process.exit(EXIT_SUCCESS2);
12225
+ process.exit(EXIT_SUCCESS3);
10499
12226
  }
10500
12227
  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") {
12228
+ 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
12229
  if (subcommand === "serve" || subcommand === "build-docs") {
10503
12230
  console.error(
10504
12231
  `The "${subcommand}" subcommand was removed. Living docs are now an Astro site, rendered live from the run JSON (no Markdown generation step):
@@ -10507,12 +12234,12 @@ async function parseCliArgs(argv) {
10507
12234
  3. run \`executable-stories dev\` in another \u2014 it hot-reloads the docs.
10508
12235
  See: https://github.com/jagreehal/executable-stories (executable-stories-astro).`
10509
12236
  );
10510
- process.exit(EXIT_USAGE2);
12237
+ process.exit(EXIT_USAGE3);
10511
12238
  }
10512
12239
  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".`
12240
+ `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
12241
  );
10515
- process.exit(EXIT_USAGE2);
12242
+ process.exit(EXIT_USAGE3);
10516
12243
  }
10517
12244
  if (subcommand === "completion") {
10518
12245
  process.exit(runCompletion(args.slice(1)));
@@ -10526,15 +12253,15 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10526
12253
  } else {
10527
12254
  console.log(formatDoctorReport(report));
10528
12255
  }
10529
- process.exit(report.healthy ? EXIT_SUCCESS2 : EXIT_USAGE2);
12256
+ process.exit(report.healthy ? EXIT_SUCCESS3 : EXIT_USAGE3);
10530
12257
  }
10531
12258
  if (subcommand === "publish-confluence") {
10532
12259
  await runPublishConfluence(args.slice(1));
10533
- process.exit(EXIT_SUCCESS2);
12260
+ process.exit(EXIT_SUCCESS3);
10534
12261
  }
10535
12262
  if (subcommand === "publish-jira") {
10536
12263
  await runPublishJira(args.slice(1));
10537
- process.exit(EXIT_SUCCESS2);
12264
+ process.exit(EXIT_SUCCESS3);
10538
12265
  }
10539
12266
  if (subcommand === "deploy") {
10540
12267
  process.exit(await runDeploy(args.slice(1)));
@@ -10548,7 +12275,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10548
12275
  `No docs site found at ${siteDir}. Create one (scaffold + install) with:
10549
12276
  npx executable-stories init-astro --install`
10550
12277
  );
10551
- process.exit(EXIT_USAGE2);
12278
+ process.exit(EXIT_USAGE3);
10552
12279
  }
10553
12280
  if (dev.kind === "install-failed") {
10554
12281
  console.error(`"${dev.pm} install" failed in ${siteDir} \u2014 run it manually, then retry.`);
@@ -10567,7 +12294,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10567
12294
  if (update) {
10568
12295
  console.log(`Updated ${result.targetDir} (content + config left untouched)`);
10569
12296
  console.log(" Framework updates come via: pnpm update executable-stories-astro");
10570
- process.exit(EXIT_SUCCESS2);
12297
+ process.exit(EXIT_SUCCESS3);
10571
12298
  }
10572
12299
  console.log(`Scaffolded Astro docs site at ${result.targetDir}`);
10573
12300
  const pm = detectPackageManager();
@@ -10594,17 +12321,19 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10594
12321
  console.log("");
10595
12322
  console.log("Everything is configured in one file: executable-stories.config.mjs");
10596
12323
  console.log(" \u2014 sources, scenario selection (include/exclude), grouping (groupBy), docs, and theme.");
10597
- process.exit(EXIT_SUCCESS2);
12324
+ process.exit(EXIT_SUCCESS3);
10598
12325
  } catch (err) {
10599
12326
  console.error(`Error: ${err.message}`);
10600
- process.exit(EXIT_USAGE2);
12327
+ process.exit(EXIT_USAGE3);
10601
12328
  }
10602
12329
  }
10603
12330
  if (subcommand === "new") process.exit(runNew(args.slice(1)));
10604
12331
  if (subcommand === "check-links") process.exit(await runCheckLinks(args.slice(1)));
10605
12332
  if (subcommand === "push") process.exit(await runPush(args.slice(1)));
12333
+ if (subcommand === "sync") process.exit(await runSyncCommand("sync", args.slice(1)));
12334
+ if (subcommand === "coverage") process.exit(await runSyncCommand("coverage", args.slice(1)));
10606
12335
  if (subcommand === "import-openapi") process.exit(await runImportOpenApi(args.slice(1)));
10607
- const { values, positionals } = parseArgs2({
12336
+ const { values, positionals } = parseArgs3({
10608
12337
  args: args.slice(1),
10609
12338
  options: {
10610
12339
  format: { type: "string", default: "html" },
@@ -10682,7 +12411,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10682
12411
  });
10683
12412
  if (values.help) {
10684
12413
  console.log(HELP_TEXT);
10685
- process.exit(EXIT_SUCCESS2);
12414
+ process.exit(EXIT_SUCCESS3);
10686
12415
  }
10687
12416
  const userSetFormat = args.slice(1).some((a) => a === "--format" || a.startsWith("--format="));
10688
12417
  const preset = expandPreset(
@@ -10692,7 +12421,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10692
12421
  );
10693
12422
  if (preset.error) {
10694
12423
  console.error(`Error: ${preset.error}`);
10695
- process.exit(EXIT_USAGE2);
12424
+ process.exit(EXIT_USAGE3);
10696
12425
  }
10697
12426
  const useStdin = values.stdin;
10698
12427
  const baselineValue = values.baseline;
@@ -10704,15 +12433,15 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10704
12433
  if (isCompareLike) {
10705
12434
  if (useStdin) {
10706
12435
  console.error(`Error: ${subcommand} does not support --stdin. Pass baseline and current files.`);
10707
- process.exit(EXIT_USAGE2);
12436
+ process.exit(EXIT_USAGE3);
10708
12437
  }
10709
12438
  if (!currentFile) {
10710
12439
  console.error(`Error: ${subcommand} requires <current-file>, and either <baseline-file> or --baseline auto.`);
10711
- process.exit(EXIT_USAGE2);
12440
+ process.exit(EXIT_USAGE3);
10712
12441
  }
10713
12442
  if (baselineMode === "explicit" && !baselineFile) {
10714
12443
  console.error(`Error: ${subcommand} requires <baseline-file> and <current-file>, or use --baseline auto.`);
10715
- process.exit(EXIT_USAGE2);
12444
+ process.exit(EXIT_USAGE3);
10716
12445
  }
10717
12446
  }
10718
12447
  let resolvedInputFile = inputFile;
@@ -10727,13 +12456,13 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10727
12456
  Pass a path, use --stdin, or run your tests first (non-JS adapters write
10728
12457
  ${DEFAULT_RUN_FILES[0]}; set rawRunPath in a JS reporter to write ${DEFAULT_RUN_FILES[1]}).`
10729
12458
  );
10730
- process.exit(EXIT_USAGE2);
12459
+ process.exit(EXIT_USAGE3);
10731
12460
  }
10732
12461
  }
10733
12462
  const inputType = values["input-type"];
10734
12463
  if (inputType !== "raw" && inputType !== "canonical" && inputType !== "ndjson") {
10735
12464
  console.error(`Error: --input-type must be "raw", "canonical", or "ndjson", got "${inputType}".`);
10736
- process.exit(EXIT_USAGE2);
12465
+ process.exit(EXIT_USAGE3);
10737
12466
  }
10738
12467
  const pluginConfig = await loadConfig(values["config"]);
10739
12468
  const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
@@ -10752,7 +12481,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10752
12481
  if (unknownFormats.length > 0) {
10753
12482
  const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
10754
12483
  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);
12484
+ process.exit(EXIT_USAGE3);
10756
12485
  }
10757
12486
  const formats = builtInRequested;
10758
12487
  const noSynthesize = values["no-synthesize-stories"];
@@ -10761,19 +12490,19 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10761
12490
  const validNotifyConditions = /* @__PURE__ */ new Set(["always", "on-failure", "never"]);
10762
12491
  if (!validNotifyConditions.has(notifyValue)) {
10763
12492
  console.error(`Error: --notify must be "always", "on-failure", or "never", got "${notifyValue}".`);
10764
- process.exit(EXIT_USAGE2);
12493
+ process.exit(EXIT_USAGE3);
10765
12494
  }
10766
12495
  const maxFailedTestsStr = values["max-failed-tests"];
10767
12496
  const maxFailedTests = maxFailedTestsStr ? parseInt(maxFailedTestsStr, 10) : 5;
10768
12497
  if (maxFailedTestsStr && (isNaN(maxFailedTests) || maxFailedTests < 0)) {
10769
12498
  console.error(`Error: --max-failed-tests must be a non-negative integer, got "${maxFailedTestsStr}".`);
10770
- process.exit(EXIT_USAGE2);
12499
+ process.exit(EXIT_USAGE3);
10771
12500
  }
10772
12501
  const htmlStaleAfterDaysStr = values["html-stale-after-days"];
10773
12502
  const htmlStaleAfterDays = htmlStaleAfterDaysStr ? parseInt(htmlStaleAfterDaysStr, 10) : 7;
10774
12503
  if (htmlStaleAfterDaysStr && (isNaN(htmlStaleAfterDays) || htmlStaleAfterDays < 0)) {
10775
12504
  console.error(`Error: --html-stale-after-days must be a non-negative integer, got "${htmlStaleAfterDaysStr}".`);
10776
- process.exit(EXIT_USAGE2);
12505
+ process.exit(EXIT_USAGE3);
10777
12506
  }
10778
12507
  const slackWebhook = values["slack-webhook"];
10779
12508
  const teamsWebhook = values["teams-webhook"];
@@ -10800,7 +12529,7 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10800
12529
  const upper = webhookMethodRaw.toUpperCase();
10801
12530
  if (upper !== "POST" && upper !== "PUT") {
10802
12531
  console.error(`Error: --webhook-method must be "POST" or "PUT", got "${webhookMethodRaw}".`);
10803
- process.exit(EXIT_USAGE2);
12532
+ process.exit(EXIT_USAGE3);
10804
12533
  }
10805
12534
  webhookMethod = upper;
10806
12535
  }
@@ -10808,36 +12537,36 @@ See: https://github.com/jagreehal/executable-stories (executable-stories-astro).
10808
12537
  const maxHistoryRuns = maxHistoryRunsStr ? parseInt(maxHistoryRunsStr, 10) : 10;
10809
12538
  if (maxHistoryRunsStr && (isNaN(maxHistoryRuns) || maxHistoryRuns < 1)) {
10810
12539
  console.error(`Error: --max-history-runs must be a positive integer, got "${maxHistoryRunsStr}".`);
10811
- process.exit(EXIT_USAGE2);
12540
+ process.exit(EXIT_USAGE3);
10812
12541
  }
10813
12542
  const maxRegressionsStr = values["max-regressions"];
10814
12543
  const maxRegressions = maxRegressionsStr !== void 0 ? parseInt(maxRegressionsStr, 10) : void 0;
10815
12544
  if (maxRegressionsStr !== void 0 && (isNaN(maxRegressions) || maxRegressions < 0)) {
10816
12545
  console.error(`Error: --max-regressions must be a non-negative integer, got "${maxRegressionsStr}".`);
10817
- process.exit(EXIT_USAGE2);
12546
+ process.exit(EXIT_USAGE3);
10818
12547
  }
10819
12548
  const sortTestCasesRaw = values["sort-test-cases"];
10820
12549
  const validSortModes = /* @__PURE__ */ new Set(["id", "source", "none"]);
10821
12550
  if (!validSortModes.has(sortTestCasesRaw)) {
10822
12551
  console.error(`Error: --sort-test-cases must be id, source, or none, got "${sortTestCasesRaw}".`);
10823
- process.exit(EXIT_USAGE2);
12552
+ process.exit(EXIT_USAGE3);
10824
12553
  }
10825
12554
  const assetModeRaw = values["asset-mode"];
10826
12555
  const validAssetModes = /* @__PURE__ */ new Set(["none", "copy"]);
10827
12556
  if (!validAssetModes.has(assetModeRaw)) {
10828
12557
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
10829
- process.exit(EXIT_USAGE2);
12558
+ process.exit(EXIT_USAGE3);
10830
12559
  }
10831
12560
  const failOnRaw = values["fail-on"];
10832
12561
  if (failOnRaw !== void 0 && failOnRaw !== "uncovered" && failOnRaw !== "weak") {
10833
12562
  console.error(`Error: --fail-on must be "uncovered" or "weak", got "${failOnRaw}".`);
10834
- process.exit(EXIT_USAGE2);
12563
+ process.exit(EXIT_USAGE3);
10835
12564
  }
10836
12565
  const minEvidenceRaw = values["min-evidence"];
10837
12566
  const validMinEvidence = /* @__PURE__ */ new Set(["weak", "moderate", "strong"]);
10838
12567
  if (minEvidenceRaw !== void 0 && !validMinEvidence.has(minEvidenceRaw)) {
10839
12568
  console.error(`Error: --min-evidence must be "weak", "moderate", or "strong", got "${minEvidenceRaw}".`);
10840
- process.exit(EXIT_USAGE2);
12569
+ process.exit(EXIT_USAGE3);
10841
12570
  }
10842
12571
  const checkFormat = parseTextJsonFormat("--check-format", values["check-format"]);
10843
12572
  const goalFormat = parseTextJsonFormat("--goal-format", values["goal-format"]);
@@ -10921,27 +12650,27 @@ async function readInput(args) {
10921
12650
  if (args.stdin) {
10922
12651
  return readStdin();
10923
12652
  }
10924
- const filePath = path17.resolve(args.inputFile);
10925
- if (!fs15.existsSync(filePath)) {
12653
+ const filePath = path19.resolve(args.inputFile);
12654
+ if (!fs17.existsSync(filePath)) {
10926
12655
  console.error(`Error: File not found: ${filePath}`);
10927
- process.exit(EXIT_USAGE2);
12656
+ process.exit(EXIT_USAGE3);
10928
12657
  }
10929
- return fs15.readFileSync(filePath, "utf8");
12658
+ return fs17.readFileSync(filePath, "utf8");
10930
12659
  }
10931
12660
  function readFileInput(filePath) {
10932
- const resolved = path17.resolve(filePath);
10933
- if (!fs15.existsSync(resolved)) {
12661
+ const resolved = path19.resolve(filePath);
12662
+ if (!fs17.existsSync(resolved)) {
10934
12663
  console.error(`Error: File not found: ${resolved}`);
10935
- process.exit(EXIT_USAGE2);
12664
+ process.exit(EXIT_USAGE3);
10936
12665
  }
10937
- return fs15.readFileSync(resolved, "utf8");
12666
+ return fs17.readFileSync(resolved, "utf8");
10938
12667
  }
10939
12668
  function readStdin() {
10940
- return new Promise((resolve10, reject) => {
12669
+ return new Promise((resolve12, reject) => {
10941
12670
  const chunks = [];
10942
12671
  process.stdin.setEncoding("utf8");
10943
12672
  process.stdin.on("data", (chunk) => chunks.push(chunk));
10944
- process.stdin.on("end", () => resolve10(chunks.join("")));
12673
+ process.stdin.on("end", () => resolve12(chunks.join("")));
10945
12674
  process.stdin.on("error", reject);
10946
12675
  });
10947
12676
  }
@@ -10951,7 +12680,7 @@ function parseJson(text2) {
10951
12680
  } catch (err) {
10952
12681
  const msg = err instanceof Error ? err.message : String(err);
10953
12682
  console.error(`Error: Invalid JSON \u2014 ${msg}`);
10954
- process.exit(EXIT_USAGE2);
12683
+ process.exit(EXIT_USAGE3);
10955
12684
  }
10956
12685
  }
10957
12686
  function tryParseJson(text2) {
@@ -10989,13 +12718,13 @@ ${msg}`);
10989
12718
  let raw = data;
10990
12719
  let droppedMissingStory = 0;
10991
12720
  if (args.synthesizeStories) {
10992
- raw = synthesizeStories3(raw);
12721
+ raw = synthesizeStories4(raw);
10993
12722
  } else {
10994
12723
  const before = raw.testCases.length;
10995
12724
  const withStory = raw.testCases.filter((tc) => tc.story != null).length;
10996
12725
  droppedMissingStory = before - withStory;
10997
12726
  }
10998
- const canonical = canonicalizeRun5(raw);
12727
+ const canonical = canonicalizeRun6(raw);
10999
12728
  try {
11000
12729
  assertValidRun2(canonical);
11001
12730
  } catch (err) {
@@ -11076,9 +12805,9 @@ function tryNormalizeRunFromText(text2, args) {
11076
12805
  if (!schemaResult.valid) return void 0;
11077
12806
  let raw = data;
11078
12807
  if (args.synthesizeStories) {
11079
- raw = synthesizeStories3(raw);
12808
+ raw = synthesizeStories4(raw);
11080
12809
  }
11081
- const canonical = canonicalizeRun5(raw);
12810
+ const canonical = canonicalizeRun6(raw);
11082
12811
  try {
11083
12812
  assertValidRun2(canonical);
11084
12813
  return canonical;
@@ -11087,14 +12816,14 @@ function tryNormalizeRunFromText(text2, args) {
11087
12816
  }
11088
12817
  }
11089
12818
  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)) {
12819
+ const baselineDir = path19.resolve(args.baselineDir ?? path19.dirname(currentFile));
12820
+ const currentResolved = path19.resolve(currentFile);
12821
+ if (!fs17.existsSync(baselineDir)) {
11093
12822
  console.error(`Error: baseline directory not found: ${baselineDir}`);
11094
- process.exit(EXIT_USAGE2);
12823
+ process.exit(EXIT_USAGE3);
11095
12824
  }
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(
12825
+ const entries = fs17.readdirSync(baselineDir, { withFileTypes: true });
12826
+ return entries.filter((entry) => entry.isFile()).map((entry) => path19.join(baselineDir, entry.name)).filter((candidate) => path19.resolve(candidate) !== currentResolved).filter(
11098
12827
  (candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
11099
12828
  );
11100
12829
  }
@@ -11102,21 +12831,21 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
11102
12831
  const candidates = listBaselineCandidates(currentFile, args);
11103
12832
  const comparable = [];
11104
12833
  for (const candidate of candidates) {
11105
- const run = tryNormalizeRunFromText(fs15.readFileSync(candidate, "utf8"), args);
12834
+ const run = tryNormalizeRunFromText(fs17.readFileSync(candidate, "utf8"), args);
11106
12835
  if (run) {
11107
12836
  comparable.push({ file: candidate, run });
11108
12837
  }
11109
12838
  }
11110
12839
  if (comparable.length === 0) {
11111
12840
  console.error(
11112
- `Error: no compatible baseline files found in ${path17.resolve(args.baselineDir ?? path17.dirname(currentFile))}.`
12841
+ `Error: no compatible baseline files found in ${path19.resolve(args.baselineDir ?? path19.dirname(currentFile))}.`
11113
12842
  );
11114
- process.exit(EXIT_USAGE2);
12843
+ process.exit(EXIT_USAGE3);
11115
12844
  }
11116
12845
  const picked = pickAutoBaseline(currentRun, comparable);
11117
12846
  if (!picked) {
11118
12847
  console.error("Error: unable to choose an automatic baseline.");
11119
- process.exit(EXIT_USAGE2);
12848
+ process.exit(EXIT_USAGE3);
11120
12849
  }
11121
12850
  return picked.file;
11122
12851
  }
@@ -11126,7 +12855,7 @@ function resolveBaselineRun(args, currentRun) {
11126
12855
  if (args.baselineArg === "auto") {
11127
12856
  if (!args.inputFile) {
11128
12857
  console.error("Error: --baseline auto requires a current input file (not --stdin).");
11129
- process.exit(EXIT_USAGE2);
12858
+ process.exit(EXIT_USAGE3);
11130
12859
  }
11131
12860
  baselineFile = resolveBaselineAuto(args.inputFile, currentRun, args);
11132
12861
  } else {
@@ -11172,7 +12901,7 @@ async function runCompare(ctx) {
11172
12901
  }
11173
12902
  process.exit(EXIT_COMPARE_GATE);
11174
12903
  }
11175
- process.exit(EXIT_SUCCESS2);
12904
+ process.exit(EXIT_SUCCESS3);
11176
12905
  } catch (err) {
11177
12906
  const msg = err instanceof Error ? err.message : String(err);
11178
12907
  console.error(`Comparison failed: ${msg}`);
@@ -11217,7 +12946,7 @@ async function runGateRelease(ctx) {
11217
12946
  process.exit(EXIT_RELEASE_GATE);
11218
12947
  }
11219
12948
  console.error("Release gate passed: RC matches dev baseline.");
11220
- process.exit(EXIT_SUCCESS2);
12949
+ process.exit(EXIT_SUCCESS3);
11221
12950
  } catch (err) {
11222
12951
  const msg = err instanceof Error ? err.message : String(err);
11223
12952
  console.error(`Release gate check failed: ${msg}`);
@@ -11249,7 +12978,7 @@ async function runReview(ctx) {
11249
12978
  }
11250
12979
  process.exit(EXIT_REVIEW_GATE);
11251
12980
  }
11252
- process.exit(EXIT_SUCCESS2);
12981
+ process.exit(EXIT_SUCCESS3);
11253
12982
  } catch (err) {
11254
12983
  const msg = err instanceof Error ? err.message : String(err);
11255
12984
  console.error(`Review failed: ${msg}`);
@@ -11265,7 +12994,7 @@ async function runList(ctx) {
11265
12994
  const validListFormats = /* @__PURE__ */ new Set(["text", "json", "csv", "markdown-table"]);
11266
12995
  if (!validListFormats.has(resolvedFormat)) {
11267
12996
  console.error(`Error: Unknown list format "${resolvedFormat}". Valid: text, json, csv, markdown-table.`);
11268
- process.exit(EXIT_USAGE2);
12997
+ process.exit(EXIT_USAGE3);
11269
12998
  }
11270
12999
  const output = listScenarios(
11271
13000
  {
@@ -11276,7 +13005,7 @@ async function runList(ctx) {
11276
13005
  {}
11277
13006
  );
11278
13007
  console.log(output);
11279
- process.exit(EXIT_SUCCESS2);
13008
+ process.exit(EXIT_SUCCESS3);
11280
13009
  }
11281
13010
  async function runCheck(ctx) {
11282
13011
  const { args } = ctx;
@@ -11291,17 +13020,17 @@ async function runCheck(ctx) {
11291
13020
  if (report.summary.failed > 0 && !args.noFail) {
11292
13021
  process.exit(EXIT_AGENT_GATE);
11293
13022
  }
11294
- process.exit(EXIT_SUCCESS2);
13023
+ process.exit(EXIT_SUCCESS3);
11295
13024
  }
11296
13025
  async function runCheckExplainers(ctx) {
11297
13026
  const { args } = ctx;
11298
13027
  if (!args.explainersDir) {
11299
13028
  console.error("Error: check-explainers requires --explainers-dir <dir> (the folder of explainer markdown).");
11300
- process.exit(EXIT_USAGE2);
13029
+ process.exit(EXIT_USAGE3);
11301
13030
  }
11302
- if (!fs15.existsSync(args.explainersDir) || !fs15.statSync(args.explainersDir).isDirectory()) {
13031
+ if (!fs17.existsSync(args.explainersDir) || !fs17.statSync(args.explainersDir).isDirectory()) {
11303
13032
  console.error(`Error: --explainers-dir "${args.explainersDir}" is not a directory.`);
11304
- process.exit(EXIT_USAGE2);
13033
+ process.exit(EXIT_USAGE3);
11305
13034
  }
11306
13035
  const text2 = await readInput(args);
11307
13036
  const run = applySelection(normalizeRunFromText(text2, args).run, args);
@@ -11310,7 +13039,7 @@ async function runCheckExplainers(ctx) {
11310
13039
  if (explainersGateFailed(report) && !args.noFail) {
11311
13040
  process.exit(EXIT_AGENT_GATE);
11312
13041
  }
11313
- process.exit(EXIT_SUCCESS2);
13042
+ process.exit(EXIT_SUCCESS3);
11314
13043
  }
11315
13044
  async function runGoal(ctx) {
11316
13045
  const { args } = ctx;
@@ -11331,7 +13060,7 @@ async function runGoal(ctx) {
11331
13060
  {}
11332
13061
  );
11333
13062
  console.log(renderGoal(report, args.goalFormat));
11334
- process.exit(report.met ? EXIT_SUCCESS2 : EXIT_AGENT_GATE);
13063
+ process.exit(report.met ? EXIT_SUCCESS3 : EXIT_AGENT_GATE);
11335
13064
  }
11336
13065
  async function runTriage(ctx) {
11337
13066
  const { args } = ctx;
@@ -11343,13 +13072,13 @@ async function runTriage(ctx) {
11343
13072
  {}
11344
13073
  );
11345
13074
  console.log(renderTriage(report, args.triageFormat));
11346
- process.exit(EXIT_SUCCESS2);
13075
+ process.exit(EXIT_SUCCESS3);
11347
13076
  }
11348
13077
  async function runWatch(ctx) {
11349
13078
  const { args } = ctx;
11350
13079
  if (!args.inputFile) {
11351
13080
  console.error("Error: watch requires an input file (the raw-run JSON the framework writes).");
11352
- process.exit(EXIT_USAGE2);
13081
+ process.exit(EXIT_USAGE3);
11353
13082
  }
11354
13083
  console.log(
11355
13084
  `Watching ${args.inputFile} \u2192 regenerating [${args.formats.join(", ")}] into ${args.outputDir}/ (Ctrl+C to stop)`
@@ -11398,7 +13127,7 @@ async function runFormatOrValidate(ctx) {
11398
13127
  }
11399
13128
  }
11400
13129
  console.log(`Valid NDJSON (${lines.length} envelopes).`);
11401
- process.exit(EXIT_SUCCESS2);
13130
+ process.exit(EXIT_SUCCESS3);
11402
13131
  }
11403
13132
  let run;
11404
13133
  try {
@@ -11409,9 +13138,9 @@ async function runFormatOrValidate(ctx) {
11409
13138
  process.exit(EXIT_SCHEMA_VALIDATION);
11410
13139
  }
11411
13140
  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");
13141
+ const outPath = path19.resolve(args.emitCanonical);
13142
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13143
+ fs17.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
11415
13144
  }
11416
13145
  try {
11417
13146
  const history = runHistoryPipeline(run, args);
@@ -11419,7 +13148,7 @@ async function runFormatOrValidate(ctx) {
11419
13148
  runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
11420
13149
  await dispatchNotifications(run, args);
11421
13150
  printResult(result, args, startMs);
11422
- process.exit(EXIT_SUCCESS2);
13151
+ process.exit(EXIT_SUCCESS3);
11423
13152
  } catch (err) {
11424
13153
  const msg = err instanceof Error ? err.message : String(err);
11425
13154
  console.error(`Generation failed: ${msg}`);
@@ -11433,7 +13162,7 @@ async function runFormatOrValidate(ctx) {
11433
13162
  assertValidRun2(data);
11434
13163
  warnLargeStateDocs(data.testCases);
11435
13164
  console.log("Valid canonical TestRunResult.");
11436
- process.exit(EXIT_SUCCESS2);
13165
+ process.exit(EXIT_SUCCESS3);
11437
13166
  } catch (err) {
11438
13167
  const msg = err instanceof Error ? err.message : String(err);
11439
13168
  console.error(msg);
@@ -11457,7 +13186,7 @@ async function runFormatOrValidate(ctx) {
11457
13186
  }
11458
13187
  warnLargeStateDocs(data.testCases);
11459
13188
  console.log("Valid RawRun (schemaVersion 1).");
11460
- process.exit(EXIT_SUCCESS2);
13189
+ process.exit(EXIT_SUCCESS3);
11461
13190
  }
11462
13191
  if (args.inputType === "canonical") {
11463
13192
  try {
@@ -11470,9 +13199,9 @@ ${msg}`);
11470
13199
  }
11471
13200
  const run = data;
11472
13201
  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");
13202
+ const outPath = path19.resolve(args.emitCanonical);
13203
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13204
+ fs17.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
11476
13205
  }
11477
13206
  try {
11478
13207
  const history = runHistoryPipeline(run, args);
@@ -11480,7 +13209,7 @@ ${msg}`);
11480
13209
  runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
11481
13210
  await dispatchNotifications(run, args);
11482
13211
  printResult(result, args, startMs);
11483
- process.exit(EXIT_SUCCESS2);
13212
+ process.exit(EXIT_SUCCESS3);
11484
13213
  } catch (err) {
11485
13214
  const msg = err instanceof Error ? err.message : String(err);
11486
13215
  console.error(`Generation failed: ${msg}`);
@@ -11505,7 +13234,7 @@ ${msg}`);
11505
13234
  let raw = data;
11506
13235
  let droppedMissingStory = 0;
11507
13236
  if (args.synthesizeStories) {
11508
- raw = synthesizeStories3(raw);
13237
+ raw = synthesizeStories4(raw);
11509
13238
  } else {
11510
13239
  const before = raw.testCases.length;
11511
13240
  const withStory = raw.testCases.filter(
@@ -11518,7 +13247,7 @@ ${msg}`);
11518
13247
  );
11519
13248
  }
11520
13249
  }
11521
- const canonical = canonicalizeRun5(raw);
13250
+ const canonical = canonicalizeRun6(raw);
11522
13251
  try {
11523
13252
  assertValidRun2(canonical);
11524
13253
  } catch (err) {
@@ -11528,9 +13257,9 @@ ${msg}`);
11528
13257
  process.exit(EXIT_CANONICAL_VALIDATION);
11529
13258
  }
11530
13259
  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");
13260
+ const outPath = path19.resolve(args.emitCanonical);
13261
+ fs17.mkdirSync(path19.dirname(outPath), { recursive: true });
13262
+ fs17.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
11534
13263
  }
11535
13264
  try {
11536
13265
  const history = runHistoryPipeline(canonical, args);
@@ -11538,7 +13267,7 @@ ${msg}`);
11538
13267
  runCustomFormatters(canonical, customRequested, pluginConfig.formatters ?? {}, args);
11539
13268
  await dispatchNotifications(canonical, args);
11540
13269
  printResult(result, args, startMs, droppedMissingStory);
11541
- process.exit(EXIT_SUCCESS2);
13270
+ process.exit(EXIT_SUCCESS3);
11542
13271
  } catch (err) {
11543
13272
  const msg = err instanceof Error ? err.message : String(err);
11544
13273
  console.error(`Generation failed: ${msg}`);
@@ -11555,9 +13284,9 @@ function runCustomFormatters(run, customRequested, formatters, args) {
11555
13284
  const ext = formatter.fileExtension ?? formatName;
11556
13285
  const baseName = args.outputName ?? "report";
11557
13286
  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");
13287
+ const filepath = path19.join(outputDir, filename);
13288
+ fs17.mkdirSync(outputDir, { recursive: true });
13289
+ fs17.writeFileSync(filepath, content, "utf8");
11561
13290
  console.log(`Generated: ${filepath}`);
11562
13291
  } catch (err) {
11563
13292
  console.error(`Error running custom formatter "${formatName}": ${err instanceof Error ? err.message : String(err)}`);
@@ -11607,13 +13336,13 @@ async function dispatchNotifications(run, args) {
11607
13336
  }
11608
13337
  function runHistoryPipeline(run, args) {
11609
13338
  if (!args.historyFile) return void 0;
11610
- const historyPath = path17.resolve(args.historyFile);
13339
+ const historyPath = path19.resolve(args.historyFile);
11611
13340
  const store = loadHistory(
11612
13341
  { filePath: historyPath },
11613
13342
  {
11614
13343
  readFile: (p) => {
11615
13344
  try {
11616
- return fs15.readFileSync(p, "utf8");
13345
+ return fs17.readFileSync(p, "utf8");
11617
13346
  } catch {
11618
13347
  return void 0;
11619
13348
  }
@@ -11626,11 +13355,11 @@ function runHistoryPipeline(run, args) {
11626
13355
  run,
11627
13356
  maxRuns: args.maxHistoryRuns
11628
13357
  });
11629
- const dir = path17.dirname(historyPath);
11630
- fs15.mkdirSync(dir, { recursive: true });
13358
+ const dir = path19.dirname(historyPath);
13359
+ fs17.mkdirSync(dir, { recursive: true });
11631
13360
  saveHistory(
11632
13361
  { filePath: historyPath, store: updated },
11633
- { writeFile: (p, content) => fs15.writeFileSync(p, content, "utf8") }
13362
+ { writeFile: (p, content) => fs17.writeFileSync(p, content, "utf8") }
11634
13363
  );
11635
13364
  let metricsCount = 0;
11636
13365
  for (const testId of Object.keys(updated.tests)) {
@@ -11781,7 +13510,7 @@ function loadReviewContext(args) {
11781
13510
  console.error(
11782
13511
  "Error: --code-diff requires --patch <file> (generate it with: git diff --histogram > changes.patch)."
11783
13512
  );
11784
- process.exit(EXIT_USAGE2);
13513
+ process.exit(EXIT_USAGE3);
11785
13514
  }
11786
13515
  const sidecar = JSON.parse(readFileInput(args.codeDiffPath));
11787
13516
  const patch = readFileInput(args.patchPath);
@@ -11801,11 +13530,11 @@ function writeReviewReport(review, args) {
11801
13530
  const outputDir = args.outputDir ?? "reports";
11802
13531
  const baseName = args.outputName ?? "evidence-review";
11803
13532
  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");
13533
+ fs17.mkdirSync(outputDir, { recursive: true });
13534
+ const mdPath = path19.join(outputDir, `${baseName}${suffix}.md`);
13535
+ const htmlPath = path19.join(outputDir, `${baseName}${suffix}.html`);
13536
+ fs17.writeFileSync(mdPath, markdown, "utf8");
13537
+ fs17.writeFileSync(htmlPath, html, "utf8");
11809
13538
  return [mdPath, htmlPath];
11810
13539
  }
11811
13540
  function evaluateReviewGate(review, args) {
@@ -11860,9 +13589,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
11860
13589
  function printCompareResult(result, args, startMs) {
11861
13590
  const durationMs = Date.now() - startMs;
11862
13591
  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");
13592
+ const outputPath = path19.resolve(args.prSummaryFile);
13593
+ fs17.mkdirSync(path19.dirname(outputPath), { recursive: true });
13594
+ fs17.writeFileSync(outputPath, result.prSummary, "utf8");
11866
13595
  }
11867
13596
  if (args.jsonSummary) {
11868
13597
  console.log(
@@ -11896,13 +13625,13 @@ function printCompareResult(result, args, startMs) {
11896
13625
  }
11897
13626
  }
11898
13627
  function loadReleasePolicy(policyPath) {
11899
- const resolved = path17.resolve(policyPath);
11900
- if (!fs15.existsSync(resolved)) {
13628
+ const resolved = path19.resolve(policyPath);
13629
+ if (!fs17.existsSync(resolved)) {
11901
13630
  console.error(`Error: release policy file not found: ${resolved}`);
11902
- process.exit(EXIT_USAGE2);
13631
+ process.exit(EXIT_USAGE3);
11903
13632
  }
11904
13633
  try {
11905
- const raw = JSON.parse(fs15.readFileSync(resolved, "utf8"));
13634
+ const raw = JSON.parse(fs17.readFileSync(resolved, "utf8"));
11906
13635
  return {
11907
13636
  allowedOmissions: Array.isArray(raw.allowedOmissions) ? raw.allowedOmissions : [],
11908
13637
  allowedRegressions: Array.isArray(raw.allowedRegressions) ? raw.allowedRegressions : [],
@@ -11911,7 +13640,7 @@ function loadReleasePolicy(policyPath) {
11911
13640
  } catch (err) {
11912
13641
  const msg = err instanceof Error ? err.message : String(err);
11913
13642
  console.error(`Error reading release policy: ${msg}`);
11914
- process.exit(EXIT_USAGE2);
13643
+ process.exit(EXIT_USAGE3);
11915
13644
  }
11916
13645
  }
11917
13646
  function applyReleasePolicy(result, policy) {
@@ -11963,7 +13692,7 @@ function evaluateCompareGate(result, args) {
11963
13692
  return failures;
11964
13693
  }
11965
13694
  async function runPublishConfluence(rawArgs) {
11966
- const { values, positionals } = parseArgs2({
13695
+ const { values, positionals } = parseArgs3({
11967
13696
  args: rawArgs,
11968
13697
  options: {
11969
13698
  "page-id": { type: "string" },
@@ -11999,16 +13728,16 @@ Optional:
11999
13728
  --help Show this help
12000
13729
 
12001
13730
  Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens`);
12002
- process.exit(EXIT_SUCCESS2);
13731
+ process.exit(EXIT_SUCCESS3);
12003
13732
  }
12004
13733
  const inputFile = positionals[0];
12005
13734
  if (!inputFile) {
12006
13735
  console.error("Error: missing ADF file argument. Run with --help for usage.");
12007
- process.exit(EXIT_USAGE2);
13736
+ process.exit(EXIT_USAGE3);
12008
13737
  }
12009
- if (!fs15.existsSync(inputFile)) {
13738
+ if (!fs17.existsSync(inputFile)) {
12010
13739
  console.error(`Error: file not found: ${inputFile}`);
12011
- process.exit(EXIT_USAGE2);
13740
+ process.exit(EXIT_USAGE3);
12012
13741
  }
12013
13742
  const baseUrl = values["base-url"] ?? process.env.CONFLUENCE_BASE_URL;
12014
13743
  const email = values.email ?? process.env.CONFLUENCE_EMAIL;
@@ -12022,19 +13751,19 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12022
13751
  console.error(
12023
13752
  "Error: --base-url or CONFLUENCE_BASE_URL is required (e.g. https://acme.atlassian.net/wiki)"
12024
13753
  );
12025
- process.exit(EXIT_USAGE2);
13754
+ process.exit(EXIT_USAGE3);
12026
13755
  }
12027
13756
  if (!pageId && !spaceId) {
12028
13757
  console.error(
12029
13758
  "Error: specify either --page-id (to update) or --space-id (to create)"
12030
13759
  );
12031
- process.exit(EXIT_USAGE2);
13760
+ process.exit(EXIT_USAGE3);
12032
13761
  }
12033
13762
  if (!pageId && !title) {
12034
13763
  console.error("Error: --title is required when creating a new page");
12035
- process.exit(EXIT_USAGE2);
13764
+ process.exit(EXIT_USAGE3);
12036
13765
  }
12037
- const adf = fs15.readFileSync(path17.resolve(inputFile), "utf8");
13766
+ const adf = fs17.readFileSync(path19.resolve(inputFile), "utf8");
12038
13767
  if (dryRun) {
12039
13768
  console.log(
12040
13769
  JSON.stringify(
@@ -12051,13 +13780,13 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12051
13780
  2
12052
13781
  )
12053
13782
  );
12054
- process.exit(EXIT_SUCCESS2);
13783
+ process.exit(EXIT_SUCCESS3);
12055
13784
  }
12056
13785
  if (!email || !token) {
12057
13786
  console.error(
12058
13787
  "Error: --email/CONFLUENCE_EMAIL and --token/CONFLUENCE_TOKEN are required unless --dry-run is set"
12059
13788
  );
12060
- process.exit(EXIT_USAGE2);
13789
+ process.exit(EXIT_USAGE3);
12061
13790
  }
12062
13791
  try {
12063
13792
  const result = await publishConfluencePage(
@@ -12067,14 +13796,14 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12067
13796
  console.log(
12068
13797
  `${result.action === "created" ? "Created" : "Updated"} "${result.title}" (v${result.version}) \u2192 ${result.url}`
12069
13798
  );
12070
- process.exit(EXIT_SUCCESS2);
13799
+ process.exit(EXIT_SUCCESS3);
12071
13800
  } catch (err) {
12072
13801
  console.error(`Error: ${err.message}`);
12073
13802
  process.exit(EXIT_GENERATION);
12074
13803
  }
12075
13804
  }
12076
13805
  async function runPublishJira(rawArgs) {
12077
- const { values, positionals } = parseArgs2({
13806
+ const { values, positionals } = parseArgs3({
12078
13807
  args: rawArgs,
12079
13808
  options: {
12080
13809
  issue: { type: "string" },
@@ -12106,16 +13835,16 @@ Optional:
12106
13835
  --help Show this help
12107
13836
 
12108
13837
  Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens`);
12109
- process.exit(EXIT_SUCCESS2);
13838
+ process.exit(EXIT_SUCCESS3);
12110
13839
  }
12111
13840
  const inputFile = positionals[0];
12112
13841
  if (!inputFile) {
12113
13842
  console.error("Error: missing ADF file argument. Run with --help for usage.");
12114
- process.exit(EXIT_USAGE2);
13843
+ process.exit(EXIT_USAGE3);
12115
13844
  }
12116
- if (!fs15.existsSync(inputFile)) {
13845
+ if (!fs17.existsSync(inputFile)) {
12117
13846
  console.error(`Error: file not found: ${inputFile}`);
12118
- process.exit(EXIT_USAGE2);
13847
+ process.exit(EXIT_USAGE3);
12119
13848
  }
12120
13849
  const baseUrl = values["base-url"] ?? process.env.JIRA_BASE_URL;
12121
13850
  const email = values.email ?? process.env.JIRA_EMAIL;
@@ -12127,20 +13856,20 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12127
13856
  console.error(
12128
13857
  "Error: --base-url or JIRA_BASE_URL is required (e.g. https://acme.atlassian.net)"
12129
13858
  );
12130
- process.exit(EXIT_USAGE2);
13859
+ process.exit(EXIT_USAGE3);
12131
13860
  }
12132
13861
  if (!issueKey) {
12133
13862
  console.error("Error: --issue <KEY> is required (e.g. --issue PROJ-123)");
12134
- process.exit(EXIT_USAGE2);
13863
+ process.exit(EXIT_USAGE3);
12135
13864
  }
12136
13865
  if (modeRaw !== "comment" && modeRaw !== "description") {
12137
13866
  console.error(
12138
13867
  `Error: --mode must be "comment" or "description" (got "${modeRaw}")`
12139
13868
  );
12140
- process.exit(EXIT_USAGE2);
13869
+ process.exit(EXIT_USAGE3);
12141
13870
  }
12142
13871
  const mode = modeRaw;
12143
- const adf = fs15.readFileSync(path17.resolve(inputFile), "utf8");
13872
+ const adf = fs17.readFileSync(path19.resolve(inputFile), "utf8");
12144
13873
  if (dryRun) {
12145
13874
  console.log(
12146
13875
  JSON.stringify(
@@ -12155,13 +13884,13 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12155
13884
  2
12156
13885
  )
12157
13886
  );
12158
- process.exit(EXIT_SUCCESS2);
13887
+ process.exit(EXIT_SUCCESS3);
12159
13888
  }
12160
13889
  if (!email || !token) {
12161
13890
  console.error(
12162
13891
  "Error: --email/JIRA_EMAIL and --token/JIRA_TOKEN are required unless --dry-run is set"
12163
13892
  );
12164
- process.exit(EXIT_USAGE2);
13893
+ process.exit(EXIT_USAGE3);
12165
13894
  }
12166
13895
  try {
12167
13896
  const result = await publishJiraIssue(
@@ -12175,14 +13904,14 @@ Generate an API token at https://id.atlassian.com/manage-profile/security/api-to
12175
13904
  } else {
12176
13905
  console.log(`Updated description for ${result.issueKey} \u2192 ${result.url}`);
12177
13906
  }
12178
- process.exit(EXIT_SUCCESS2);
13907
+ process.exit(EXIT_SUCCESS3);
12179
13908
  } catch (err) {
12180
13909
  console.error(`Error: ${err.message}`);
12181
13910
  process.exit(EXIT_GENERATION);
12182
13911
  }
12183
13912
  }
12184
13913
  function runNew(rawArgs) {
12185
- const { values, positionals } = parseArgs2({
13914
+ const { values, positionals } = parseArgs3({
12186
13915
  args: rawArgs,
12187
13916
  options: {
12188
13917
  dir: { type: "string" },
@@ -12199,7 +13928,7 @@ function runNew(rawArgs) {
12199
13928
  `Usage: executable-stories new <template> "<name>" [--dir <docs-dir>] [--scenario-id <id>] [--force]`
12200
13929
  );
12201
13930
  console.error(`Templates: ${TEMPLATES.join(", ")}`);
12202
- return EXIT_USAGE2;
13931
+ return EXIT_USAGE3;
12203
13932
  }
12204
13933
  try {
12205
13934
  const result = scaffoldDoc({
@@ -12213,36 +13942,41 @@ function runNew(rawArgs) {
12213
13942
  console.log(` Title: ${result.title}`);
12214
13943
  console.log("");
12215
13944
  console.log("Next: fill in the content and link verifying stories in `verifiedBy`.");
12216
- return EXIT_SUCCESS2;
13945
+ return EXIT_SUCCESS3;
12217
13946
  } catch (err) {
12218
13947
  console.error(`Error: ${err.message}`);
12219
- return EXIT_USAGE2;
13948
+ return EXIT_USAGE3;
12220
13949
  }
12221
13950
  }
12222
13951
  async function runCheckLinks(rawArgs) {
12223
- const { values, positionals } = parseArgs2({
13952
+ const { values, positionals } = parseArgs3({
12224
13953
  args: rawArgs,
12225
13954
  options: {
12226
13955
  external: { type: "boolean", default: false },
12227
- json: { type: "boolean", default: false }
13956
+ json: { type: "boolean", default: false },
13957
+ "site-root": { type: "string" },
13958
+ assets: { type: "string", multiple: true }
12228
13959
  },
12229
13960
  allowPositionals: true,
12230
13961
  strict: true
12231
13962
  });
12232
13963
  try {
13964
+ const assets = values.assets;
12233
13965
  const report = await checkLinks({
12234
13966
  target: positionals[0] ?? ".",
12235
- checkExternal: values.external
13967
+ checkExternal: values.external,
13968
+ ...values["site-root"] ? { siteRoot: values["site-root"] } : {},
13969
+ ...assets && assets.length > 0 ? { assetRoots: assets } : {}
12236
13970
  });
12237
13971
  console.log(values.json ? JSON.stringify(report, null, 2) : formatLinkReport(report));
12238
- return report.brokenCount > 0 ? EXIT_GENERATION : EXIT_SUCCESS2;
13972
+ return report.brokenCount > 0 ? EXIT_GENERATION : EXIT_SUCCESS3;
12239
13973
  } catch (err) {
12240
13974
  console.error(`Error: ${err.message}`);
12241
- return EXIT_USAGE2;
13975
+ return EXIT_USAGE3;
12242
13976
  }
12243
13977
  }
12244
13978
  async function runImportOpenApi(rawArgs) {
12245
- const { values, positionals } = parseArgs2({
13979
+ const { values, positionals } = parseArgs3({
12246
13980
  args: rawArgs,
12247
13981
  options: {
12248
13982
  "output-dir": { type: "string" },
@@ -12255,7 +13989,7 @@ async function runImportOpenApi(rawArgs) {
12255
13989
  const spec = positionals[0];
12256
13990
  if (!spec) {
12257
13991
  console.error(`Usage: executable-stories import-openapi <spec.json|yaml> [--output-dir <dir>] [--run <story-report.json>] [--force]`);
12258
- return EXIT_USAGE2;
13992
+ return EXIT_USAGE3;
12259
13993
  }
12260
13994
  try {
12261
13995
  const result = await importOpenApi({
@@ -12269,10 +14003,10 @@ async function runImportOpenApi(rawArgs) {
12269
14003
  if (result.uncoveredCount > 0) {
12270
14004
  console.log(` \u26A0 ${result.uncoveredCount} endpoint(s) have no verifying story`);
12271
14005
  }
12272
- return EXIT_SUCCESS2;
14006
+ return EXIT_SUCCESS3;
12273
14007
  } catch (err) {
12274
14008
  console.error(`Error: ${err.message}`);
12275
- return EXIT_USAGE2;
14009
+ return EXIT_USAGE3;
12276
14010
  }
12277
14011
  }
12278
14012
  async function runDeploy(rawArgs) {
@@ -12282,9 +14016,9 @@ async function runDeploy(rawArgs) {
12282
14016
  console.error(" deploy record <file> --env <env> [--tag <tag>] [--ledger <path>]");
12283
14017
  console.error(" deploy status [--ledger <path>] [--json]");
12284
14018
  console.error(" deploy diff <env-a> <env-b> [--ledger <path>] [--json]");
12285
- return EXIT_USAGE2;
14019
+ return EXIT_USAGE3;
12286
14020
  }
12287
- const { values, positionals } = parseArgs2({
14021
+ const { values, positionals } = parseArgs3({
12288
14022
  args: rawArgs.slice(1),
12289
14023
  options: {
12290
14024
  env: { type: "string" },
@@ -12309,19 +14043,19 @@ OPTIONS
12309
14043
  --tag <tag> Optional Git tag for this deployment (e.g. v1.2.3)
12310
14044
  --ledger <path> Path to deployment ledger JSON (default: .executable-stories/deployments.json)
12311
14045
  --json Output as JSON instead of text`);
12312
- return EXIT_SUCCESS2;
14046
+ return EXIT_SUCCESS3;
12313
14047
  }
12314
14048
  const ledgerPath = values.ledger;
12315
14049
  if (mode === "record") {
12316
14050
  const inputFile = positionals[0];
12317
14051
  if (!inputFile) {
12318
14052
  console.error("Error: deploy record requires an input file.");
12319
- return EXIT_USAGE2;
14053
+ return EXIT_USAGE3;
12320
14054
  }
12321
14055
  const env = values.env;
12322
14056
  if (!env) {
12323
14057
  console.error("Error: deploy record requires --env <environment>.");
12324
- return EXIT_USAGE2;
14058
+ return EXIT_USAGE3;
12325
14059
  }
12326
14060
  const text2 = readFileInput(inputFile);
12327
14061
  const { run } = normalizeRunFromText(text2, {
@@ -12347,14 +14081,14 @@ OPTIONS
12347
14081
  console.error(` Tag: ${result.entry.tag}`);
12348
14082
  }
12349
14083
  console.error(` Ledger: ${result.ledgerPath}`);
12350
- return EXIT_SUCCESS2;
14084
+ return EXIT_SUCCESS3;
12351
14085
  }
12352
14086
  if (mode === "status") {
12353
14087
  const status = getDeploymentStatus(ledgerPath);
12354
14088
  const envs = Object.keys(status.environments);
12355
14089
  if (envs.length === 0) {
12356
14090
  console.error("No deployments recorded yet.");
12357
- return EXIT_SUCCESS2;
14091
+ return EXIT_SUCCESS3;
12358
14092
  }
12359
14093
  if (values.json) {
12360
14094
  console.log(JSON.stringify(status, null, 2));
@@ -12380,14 +14114,14 @@ OPTIONS
12380
14114
  }
12381
14115
  console.log(`Ledger: ${ledgerPath}`);
12382
14116
  }
12383
- return EXIT_SUCCESS2;
14117
+ return EXIT_SUCCESS3;
12384
14118
  }
12385
14119
  if (mode === "diff") {
12386
14120
  const envA = positionals[0];
12387
14121
  const envB = positionals[1];
12388
14122
  if (!envA || !envB) {
12389
14123
  console.error("Error: deploy diff requires two environment names.");
12390
- return EXIT_USAGE2;
14124
+ return EXIT_USAGE3;
12391
14125
  }
12392
14126
  try {
12393
14127
  const drift = getEnvironmentDrift(ledgerPath, envA, envB);
@@ -12433,11 +14167,11 @@ OPTIONS
12433
14167
  }
12434
14168
  } catch (err) {
12435
14169
  console.error(`Error: ${err.message}`);
12436
- return EXIT_USAGE2;
14170
+ return EXIT_USAGE3;
12437
14171
  }
12438
- return EXIT_SUCCESS2;
14172
+ return EXIT_SUCCESS3;
12439
14173
  }
12440
- return EXIT_USAGE2;
14174
+ return EXIT_USAGE3;
12441
14175
  }
12442
14176
  function createDefaultCliArgs() {
12443
14177
  return {
@@ -12493,6 +14227,6 @@ function createDefaultCliArgs() {
12493
14227
  }
12494
14228
  main().catch((err) => {
12495
14229
  console.error(err);
12496
- process.exit(EXIT_USAGE2);
14230
+ process.exit(EXIT_USAGE3);
12497
14231
  });
12498
14232
  //# sourceMappingURL=cli.js.map