@plune-ai/cli 0.15.0 → 0.17.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.cjs CHANGED
@@ -75,8 +75,8 @@ var env_exports = {};
75
75
  __export(env_exports, {
76
76
  loadEnv: () => loadEnv
77
77
  });
78
- function loadEnv(configDir) {
79
- (0, import_dotenv.config)({ path: path.join(configDir, ".env"), override: false });
78
+ function loadEnv(dir) {
79
+ (0, import_dotenv.config)({ path: path.join(dir, ".env"), override: false });
80
80
  }
81
81
  var import_dotenv, path;
82
82
  var init_env = __esm({
@@ -523,11 +523,25 @@ var init_prices = __esm({
523
523
  "use strict";
524
524
  init_cjs_shims();
525
525
  PRICE_TABLE = {
526
- // Anthropic
526
+ // Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
527
+ // input and output tokens per million, read on 2026-09-29, kept here per thousand.
528
+ "claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
529
+ "claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
530
+ "claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
531
+ "claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
532
+ // The dated id of the same model; a config may name either.
533
+ "claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
534
+ // Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
535
+ // retired these models — a new project should not start on them.
527
536
  "claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
528
537
  "claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
529
538
  "claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
530
- // OpenAI
539
+ // OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
540
+ // short-context rates (a long-context request costs more), read on 2026-09-29.
541
+ "gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
542
+ "gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
543
+ "gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
544
+ // OpenAI, older ids: unchanged (the same rates the page above still lists for both).
531
545
  "gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
532
546
  "gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
533
547
  // OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
@@ -776,6 +790,25 @@ var init_providers = __esm({
776
790
  }
777
791
  });
778
792
 
793
+ // src/providers/estimate.ts
794
+ function makeEstimateProvider(config, pricing) {
795
+ return {
796
+ complete: () => Promise.reject(
797
+ new Error("The --dry-run provider only estimates cost; it never calls a model.")
798
+ ),
799
+ estimateCost: (usage, reportedCostUsd) => ({
800
+ cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
801
+ })
802
+ };
803
+ }
804
+ var init_estimate = __esm({
805
+ "src/providers/estimate.ts"() {
806
+ "use strict";
807
+ init_cjs_shims();
808
+ init_cost();
809
+ }
810
+ });
811
+
779
812
  // src/providers/mock.ts
780
813
  function makeMockProvider() {
781
814
  return {
@@ -1878,11 +1911,16 @@ __export(run_exports, {
1878
1911
  function isMockMode(env) {
1879
1912
  return env["PLUNE_MOCK_PROVIDER"] === "1";
1880
1913
  }
1914
+ function resolveProviderFor(config, dryRun) {
1915
+ if (isMockMode(process.env)) return () => makeMockProvider();
1916
+ if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
1917
+ return (cfg) => getProvider(cfg, process.env, config.pricing);
1918
+ }
1881
1919
  function buildRealDeps(config, baseDir, dryRun) {
1882
1920
  const dir = path5.join(baseDir, ".plune");
1883
1921
  fs5.mkdirSync(dir, { recursive: true });
1884
1922
  return {
1885
- resolveProvider: isMockMode(process.env) ? () => makeMockProvider() : (cfg) => getProvider(cfg, process.env, config.pricing),
1923
+ resolveProvider: resolveProviderFor(config, dryRun),
1886
1924
  embedder: getDefaultEmbedder(),
1887
1925
  cache: dryRun ? NOOP_CACHE : openCache(path5.join(dir, "cache.db")),
1888
1926
  now: Date.now,
@@ -1929,6 +1967,7 @@ var init_run2 = __esm({
1929
1967
  path5 = __toESM(require("path"), 1);
1930
1968
  init_loader();
1931
1969
  init_providers();
1970
+ init_estimate();
1932
1971
  init_mock();
1933
1972
  init_embeddings();
1934
1973
  init_cache2();
@@ -2241,7 +2280,7 @@ function createClient(opts) {
2241
2280
  const wait = opts.wait ?? sleep;
2242
2281
  const base = opts.apiUrl.replace(/\/+$/, "");
2243
2282
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2244
- async function send(method, path14, payload) {
2283
+ async function send(method, path15, payload) {
2245
2284
  let last = {
2246
2285
  kind: "unavailable",
2247
2286
  status: null,
@@ -2250,7 +2289,7 @@ function createClient(opts) {
2250
2289
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2251
2290
  let res;
2252
2291
  try {
2253
- res = await doFetch(`${base}${path14}`, {
2292
+ res = await doFetch(`${base}${path15}`, {
2254
2293
  method,
2255
2294
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2256
2295
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2282,9 +2321,9 @@ function createClient(opts) {
2282
2321
  return { ok: false, ...last };
2283
2322
  }
2284
2323
  return {
2285
- post: (path14, body) => send("POST", path14, { body: JSON.stringify(body), type: "application/json" }),
2286
- upload: (path14, bytes, contentType) => send("POST", path14, { body: bytes, type: contentType }),
2287
- del: (path14) => send("DELETE", path14)
2324
+ post: (path15, body) => send("POST", path15, { body: JSON.stringify(body), type: "application/json" }),
2325
+ upload: (path15, bytes, contentType) => send("POST", path15, { body: bytes, type: contentType }),
2326
+ del: (path15) => send("DELETE", path15)
2288
2327
  };
2289
2328
  }
2290
2329
  var import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, sleep;
@@ -2360,13 +2399,35 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
2360
2399
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
2361
2400
  return `${path6.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
2362
2401
  }
2363
- var path6, UNSUPPORTED_VARS;
2402
+ function ciCommit(reported, env = process.env) {
2403
+ const said = typeof reported === "object" && reported !== null ? reported : {};
2404
+ const text2 = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
2405
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
2406
+ const job = {
2407
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
2408
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
2409
+ };
2410
+ const sha = text2(said["commitHash"]);
2411
+ const sameCommit = sha === void 0 || sha === job.sha;
2412
+ const commit = sha ?? job.sha;
2413
+ const branch = text2(said["branch"]) ?? (sameCommit ? job.branch : void 0);
2414
+ return {
2415
+ ...commit !== void 0 ? { sha: commit } : {},
2416
+ ...branch !== void 0 ? { branch } : {}
2417
+ };
2418
+ }
2419
+ var path6, UNSUPPORTED_VARS, PROVIDERS;
2364
2420
  var init_env2 = __esm({
2365
2421
  "src/reporter-core/env.ts"() {
2366
2422
  "use strict";
2367
2423
  init_cjs_shims();
2368
2424
  path6 = __toESM(require("path"), 1);
2369
2425
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2426
+ PROVIDERS = [
2427
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
2428
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
2429
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
2430
+ ];
2370
2431
  }
2371
2432
  });
2372
2433
 
@@ -2377,6 +2438,8 @@ function appendBatch(file, batch) {
2377
2438
  ts: (/* @__PURE__ */ new Date()).toISOString(),
2378
2439
  runId: batch.runId,
2379
2440
  externalKey: batch.externalKey,
2441
+ ...batch.session !== void 0 ? { session: batch.session } : {},
2442
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
2380
2443
  results: batch.results
2381
2444
  };
2382
2445
  fs6.appendFileSync(file, `${JSON.stringify(line)}
@@ -2393,11 +2456,149 @@ var init_fallback = __esm({
2393
2456
  }
2394
2457
  });
2395
2458
 
2459
+ // src/reporter-core/failure-detail.ts
2460
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
2461
+ const error = chosen(attempt);
2462
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
2463
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
2464
+ const steps = chainOf(attempt.steps);
2465
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
2466
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
2467
+ const ciUrl = webLink(attempt.buildHref);
2468
+ const failure = {
2469
+ ...headline !== void 0 ? { headline } : {},
2470
+ ...steps.length > 0 ? { steps } : {},
2471
+ ...location !== void 0 ? { location } : {},
2472
+ ...artifacts.length > 0 ? { artifacts } : {},
2473
+ ...ciUrl !== void 0 ? { ciUrl } : {}
2474
+ };
2475
+ return Object.keys(failure).length === 0 ? void 0 : failure;
2476
+ }
2477
+ function repoRootOf(dir, stopAt) {
2478
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
2479
+ if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2480
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2481
+ }
2482
+ }
2483
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
2484
+ const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
2485
+ return cut(machineless(text2, attempt.repoRoot, home));
2486
+ }
2487
+ function filesOf(attempt) {
2488
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
2489
+ }
2490
+ function machineless(raw, repoRoot, home) {
2491
+ let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
2492
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
2493
+ if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
2494
+ const own = pathIn(home);
2495
+ if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
2496
+ if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2497
+ return text2;
2498
+ }
2499
+ function pathIn(path15) {
2500
+ const parts = path15.split(/[\\/]+/);
2501
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2502
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2503
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2504
+ const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2505
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2506
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2507
+ }
2508
+ function cut(text2) {
2509
+ if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
2510
+ const lines = text2.split("\n");
2511
+ const size = lines.map((line) => jsonBytes(line) + 2);
2512
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
2513
+ let used = 0;
2514
+ let head = 0;
2515
+ let tail = lines.length;
2516
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
2517
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
2518
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
2519
+ }
2520
+ function chosen({ status, errors }) {
2521
+ if (status === "timedOut") {
2522
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
2523
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
2524
+ }
2525
+ return errors[0];
2526
+ }
2527
+ function chainOf(steps) {
2528
+ const chain = [];
2529
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
2530
+ chain.push(failed.title);
2531
+ }
2532
+ return chain;
2533
+ }
2534
+ function placeOf(error, testFile) {
2535
+ const own = normal(testFile);
2536
+ let first;
2537
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
2538
+ if (line.length > FRAME_MAX) continue;
2539
+ const frame = FRAME.exec(line);
2540
+ if (frame === null) continue;
2541
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
2542
+ if (normal(place.file) === own) return place;
2543
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
2544
+ }
2545
+ return error.location ?? first;
2546
+ }
2547
+ function fileOf(raw) {
2548
+ if (!raw.startsWith("file://")) return raw;
2549
+ let path15 = raw.slice("file://".length);
2550
+ try {
2551
+ path15 = decodeURIComponent(path15);
2552
+ } catch {
2553
+ }
2554
+ return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
2555
+ }
2556
+ function fromRoot(place, root) {
2557
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
2558
+ const base = normal(root).replace(/\/+$/, "");
2559
+ const file = normal(place.file);
2560
+ if (!file.startsWith(`${base}/`)) return void 0;
2561
+ const relative2 = file.slice(base.length + 1);
2562
+ if (!REPO_RELATIVE.test(relative2)) return void 0;
2563
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
2564
+ return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
2565
+ }
2566
+ function webLink(href) {
2567
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
2568
+ }
2569
+ var import_node_fs2, import_node_os2, import_node_path2, import_node_util, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2570
+ var init_failure_detail = __esm({
2571
+ "src/reporter-core/failure-detail.ts"() {
2572
+ "use strict";
2573
+ init_cjs_shims();
2574
+ import_node_fs2 = require("fs");
2575
+ import_node_os2 = require("os");
2576
+ import_node_path2 = require("path");
2577
+ import_node_util = require("util");
2578
+ mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
2579
+ TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
2580
+ isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
2581
+ PATH_START = "(?<![\\w.~%/\\\\-])";
2582
+ TEXT_LIMIT = 524288;
2583
+ HEADLINE_LIMIT = 8 * 1024;
2584
+ jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
2585
+ ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
2586
+ fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
2587
+ omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
2588
+ METADATA_TYPE = "application/plune.metadata+json";
2589
+ REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
2590
+ FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2591
+ FRAME_MAX = 8192;
2592
+ firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2593
+ normal = (path15) => import_node_path2.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2594
+ }
2595
+ });
2596
+
2396
2597
  // src/reporter-core/session.ts
2397
2598
  function nameOf(file) {
2398
- const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
2399
- const fileExt = (0, import_node_path2.extname)(file.path);
2400
- const ownExt = (0, import_node_path2.extname)(own);
2599
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
2600
+ const fileExt = file.path === void 0 ? "" : (0, import_node_path3.extname)(file.path);
2601
+ const ownExt = (0, import_node_path3.extname)(own);
2401
2602
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
2402
2603
  const ext = typed ? ownExt : fileExt;
2403
2604
  let stem = typed ? own.slice(0, -ownExt.length) : own;
@@ -2407,6 +2608,35 @@ function nameOf(file) {
2407
2608
  }
2408
2609
  return `${stem}${ext}`;
2409
2610
  }
2611
+ function kindOf(type) {
2612
+ if (type.startsWith("image/")) return "screenshot";
2613
+ return TEXT_TYPES.has(type) ? "text" : void 0;
2614
+ }
2615
+ function textTypeOf(contentType, type) {
2616
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
2617
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
2618
+ }
2619
+ function textRefusal(bytes) {
2620
+ if (bytes.length > TEXT_BYTES) return "textSize";
2621
+ if (bytes.length === 0) return "empty";
2622
+ if (bytes.includes(0)) return "binary";
2623
+ try {
2624
+ UTF8.decode(bytes);
2625
+ } catch {
2626
+ return "encoding";
2627
+ }
2628
+ return void 0;
2629
+ }
2630
+ function onDisk(result) {
2631
+ if (!("files" in result) || result.files === void 0) return result;
2632
+ const { files, ...rest } = result;
2633
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
2634
+ return kept.length === 0 ? rest : { ...rest, files: kept };
2635
+ }
2636
+ function tally(counts, noun) {
2637
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
2638
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
2639
+ }
2410
2640
  function boundKey(key) {
2411
2641
  if (key.value.length <= KEY_MAX) return key;
2412
2642
  const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -2414,6 +2644,12 @@ function boundKey(key) {
2414
2644
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
2415
2645
  return { ...key, value: `${head}#${digest}` };
2416
2646
  }
2647
+ function whyUnoffered(out) {
2648
+ if (out.ok || out.status !== 429) return "other";
2649
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
2650
+ if (/test case quota reached/i.test(out.detail)) return "cases";
2651
+ return "other";
2652
+ }
2417
2653
  function boundTitle(title) {
2418
2654
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
2419
2655
  }
@@ -2455,6 +2691,7 @@ async function startRun(passed, declared) {
2455
2691
  `));
2456
2692
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2457
2693
  const externalKey = cfg.externalKey ?? null;
2694
+ const sessionMarker = (0, import_node_crypto2.randomUUID)();
2458
2695
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2459
2696
  const offerDiscovered = cfg.offerDiscovered ?? false;
2460
2697
  const token = cfg.token ?? loadToken() ?? "";
@@ -2473,7 +2710,8 @@ async function startRun(passed, declared) {
2473
2710
  created: 0,
2474
2711
  unoffered: 0,
2475
2712
  deferred: 0,
2476
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
2713
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
2714
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 }
2477
2715
  };
2478
2716
  const resolved = /* @__PURE__ */ new Map();
2479
2717
  let buffer = [];
@@ -2484,6 +2722,7 @@ async function startRun(passed, declared) {
2484
2722
  let done = false;
2485
2723
  const shortened = /* @__PURE__ */ new Set();
2486
2724
  const skipsSaid = /* @__PURE__ */ new Set();
2725
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
2487
2726
  function bound(keys) {
2488
2727
  return keys.map((key) => {
2489
2728
  const fit = boundKey(key);
@@ -2506,7 +2745,13 @@ async function startRun(passed, declared) {
2506
2745
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
2507
2746
  }
2508
2747
  function defer(results) {
2509
- appendBatch(fallbackPath, { runId, externalKey, results });
2748
+ appendBatch(fallbackPath, {
2749
+ runId,
2750
+ externalKey,
2751
+ session: sessionMarker,
2752
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2753
+ results: results.map(onDisk)
2754
+ });
2510
2755
  stats.deferred += results.length;
2511
2756
  }
2512
2757
  async function resolveKeys(values) {
@@ -2603,6 +2848,7 @@ async function startRun(passed, declared) {
2603
2848
  });
2604
2849
  if (!out.ok || !Array.isArray(out.body?.results)) {
2605
2850
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
2851
+ stats.unofferedWhy = whyUnoffered(out);
2606
2852
  log(
2607
2853
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
2608
2854
  );
@@ -2612,57 +2858,74 @@ async function startRun(passed, declared) {
2612
2858
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
2613
2859
  }
2614
2860
  }
2615
- function skip(name, why) {
2616
- stats.screenshots.skipped += 1;
2617
- if (skipsSaid.has(why)) return;
2618
- skipsSaid.add(why);
2619
- log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2861
+ function skip(kind, name, why) {
2862
+ countsOf(kind).skipped += 1;
2863
+ if (skipsSaid.has(`${kind}/${why}`)) return;
2864
+ skipsSaid.add(`${kind}/${why}`);
2865
+ log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2620
2866
  }
2621
- async function uploadOne(shot) {
2867
+ async function uploadOne(job) {
2868
+ const { kind } = job;
2869
+ const { label, bytes: ceiling, size } = KINDS[kind];
2622
2870
  let bytes;
2623
- try {
2624
- if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
2625
- bytes = await import_node_fs2.promises.readFile(shot.path);
2626
- } catch {
2627
- return skip(shot.name, "missing");
2871
+ if (job.body !== void 0) bytes = job.body;
2872
+ else if (job.path === void 0) return skip(kind, job.name, "missing");
2873
+ else {
2874
+ try {
2875
+ if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
2876
+ bytes = await import_node_fs3.promises.readFile(job.path);
2877
+ } catch {
2878
+ return skip(kind, job.name, "missing");
2879
+ }
2628
2880
  }
2881
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
2882
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
2629
2883
  const out = await client.upload(
2630
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
2884
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
2631
2885
  bytes,
2632
- shot.type
2886
+ job.type
2633
2887
  );
2888
+ const counts = countsOf(kind);
2634
2889
  if (out.ok) {
2635
- stats.screenshots.uploaded += 1;
2890
+ counts.uploaded += 1;
2636
2891
  return;
2637
2892
  }
2638
- stats.screenshots.failed += 1;
2639
- if (stats.screenshots.failed === 1) {
2640
- log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
2893
+ counts.failed += 1;
2894
+ if (counts.failed === 1) {
2895
+ log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
2641
2896
  }
2642
2897
  }
2643
2898
  async function upload(items, filesByKey) {
2644
2899
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
2645
- const shots = [];
2900
+ const jobs = [];
2646
2901
  for (const item of items) {
2647
2902
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2648
- let room = SCREENSHOTS_PER_RESULT;
2903
+ const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
2649
2904
  for (const file of filesByKey.get(item.resultKey) ?? []) {
2650
- const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
2651
- if (!type?.startsWith("image/")) continue;
2905
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
2906
+ const kind = type === void 0 ? void 0 : kindOf(type);
2907
+ if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
2652
2908
  const name = nameOf(file);
2653
- if (file.name.startsWith("_")) skip(name, "hidden");
2654
- else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
2655
- else if (room === 0) skip(name, "limit");
2909
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
2910
+ else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
2911
+ else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
2656
2912
  else {
2657
- room -= 1;
2658
- shots.push({ id: item.id, name, type, path: file.path });
2913
+ room[kind] -= 1;
2914
+ jobs.push({
2915
+ id: item.id,
2916
+ kind,
2917
+ name,
2918
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
2919
+ ...file.body !== void 0 ? { body: file.body } : {},
2920
+ ...file.path !== void 0 ? { path: file.path } : {}
2921
+ });
2659
2922
  }
2660
2923
  }
2661
2924
  }
2662
2925
  let next = 0;
2663
2926
  await Promise.all(
2664
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
2665
- while (next < shots.length) await uploadOne(shots[next++]);
2927
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
2928
+ while (next < jobs.length) await uploadOne(jobs[next++]);
2666
2929
  })
2667
2930
  );
2668
2931
  }
@@ -2740,11 +3003,8 @@ async function startRun(passed, declared) {
2740
3003
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2741
3004
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2742
3005
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2743
- const shots = stats.screenshots;
2744
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
2745
- parts.push(
2746
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
2747
- );
3006
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3007
+ if (said !== void 0) parts.push(said);
2748
3008
  }
2749
3009
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
2750
3010
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -2797,18 +3057,20 @@ async function startRun(passed, declared) {
2797
3057
  }
2798
3058
  };
2799
3059
  }
2800
- var import_node_crypto2, import_node_fs2, import_node_path2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BATCH_BYTES, ENVELOPE_BYTES;
3060
+ var import_node_crypto2, import_node_fs3, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, KINDS, BATCH_BYTES, ENVELOPE_BYTES;
2801
3061
  var init_session = __esm({
2802
3062
  "src/reporter-core/session.ts"() {
2803
3063
  "use strict";
2804
3064
  init_cjs_shims();
2805
3065
  import_node_crypto2 = require("crypto");
2806
- import_node_fs2 = require("fs");
2807
- import_node_path2 = require("path");
3066
+ import_node_fs3 = require("fs");
3067
+ import_node_path3 = require("path");
3068
+ import_node_util2 = require("util");
2808
3069
  init_api_url();
2809
3070
  init_credentials();
2810
3071
  init_client();
2811
3072
  init_env2();
3073
+ init_failure_detail();
2812
3074
  init_fallback();
2813
3075
  RESOLVE_CHUNK = 500;
2814
3076
  BATCH_MAX = 500;
@@ -2821,6 +3083,9 @@ var init_session = __esm({
2821
3083
  SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
2822
3084
  SCREENSHOT_BYTES = 2 * 1024 * 1024;
2823
3085
  SCREENSHOTS_PER_RESULT = 20;
3086
+ TEXT_BYTES = 512 * 1024;
3087
+ TEXTS_PER_RESULT = 10;
3088
+ UTF8 = new import_node_util2.TextDecoder("utf-8", { fatal: true });
2824
3089
  TYPE_BY_EXTENSION = {
2825
3090
  ".png": "image/png",
2826
3091
  ".jpg": "image/jpeg",
@@ -2834,7 +3099,16 @@ var init_session = __esm({
2834
3099
  type: "Plune takes PNG, JPEG and WebP",
2835
3100
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
2836
3101
  missing: "the file cannot be read on this machine",
2837
- size: "Plune takes a file of at most 2 MB"
3102
+ size: "Plune takes a file of at most 2 MB",
3103
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
3104
+ textSize: "Plune takes a text file of at most 512 KiB",
3105
+ empty: "the file is empty",
3106
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
3107
+ encoding: "Plune takes UTF-8 text only"
3108
+ };
3109
+ KINDS = {
3110
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
3111
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
2838
3112
  };
2839
3113
  BATCH_BYTES = 8 * 1024 * 1024;
2840
3114
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
@@ -2959,23 +3233,42 @@ async function handleRunReport(options = {}) {
2959
3233
  if (lines.length === 0) throw new RunCommandError(`Nothing to send \u2014 ${file} is empty.`);
2960
3234
  const { client } = connect(options);
2961
3235
  const result = { batches: lines.length, sent: 0, failed: 0 };
3236
+ const groups = [];
3237
+ const bySession = /* @__PURE__ */ new Map();
2962
3238
  for (const line of lines) {
2963
- const pending = line.results.filter(isPending);
2964
- const ready = line.results.filter((r) => !isPending(r));
2965
- if (line.runId !== null && ready.length > 0) {
2966
- const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
2967
- if (out.ok) result.sent += ready.length;
2968
- else {
2969
- result.failed += ready.length;
2970
- write(`Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`);
3239
+ const group = line.session === void 0 ? void 0 : bySession.get(line.session);
3240
+ if (group !== void 0) {
3241
+ group.push(line);
3242
+ continue;
3243
+ }
3244
+ const alone = [line];
3245
+ groups.push(alone);
3246
+ if (line.session !== void 0) bySession.set(line.session, alone);
3247
+ }
3248
+ for (const group of groups) {
3249
+ for (const line of group) {
3250
+ const ready = line.results.filter((r) => !isPending(r));
3251
+ if (line.runId !== null && ready.length > 0) {
3252
+ const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
3253
+ if (out.ok) result.sent += ready.length;
3254
+ else {
3255
+ result.failed += ready.length;
3256
+ write(
3257
+ `Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`
3258
+ );
3259
+ }
2971
3260
  }
2972
3261
  }
3262
+ const pending = group.flatMap((line) => line.results.filter(isPending));
2973
3263
  if (pending.length > 0) {
3264
+ const first = group[0];
2974
3265
  const session = await startRun({
2975
3266
  ...options.apiUrl !== void 0 ? { apiUrl: options.apiUrl } : {},
2976
3267
  ...options.token !== void 0 ? { token: options.token } : {},
2977
3268
  ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2978
- ...line.externalKey !== null ? { externalKey: line.externalKey } : {},
3269
+ ...first.externalKey !== null ? { externalKey: first.externalKey } : {},
3270
+ // Where the run came from, as the reporter recorded it (plune#927) — never this machine's own.
3271
+ ...first.meta !== void 0 ? { meta: first.meta } : {},
2979
3272
  // The replay does not know whether the run is complete, so it must not say that it is.
2980
3273
  log: write
2981
3274
  });
@@ -2986,15 +3279,34 @@ async function handleRunReport(options = {}) {
2986
3279
  }
2987
3280
  }
2988
3281
  write(`Replayed ${result.batches} batch(es) from ${file}: ${result.sent} sent, ${result.failed} not.`);
3282
+ if (result.failed === 0) {
3283
+ const sentAs = sentName(file, (options.now ?? Date.now)());
3284
+ try {
3285
+ fs7.renameSync(file, sentAs);
3286
+ write(
3287
+ `Every result was sent \u2014 ${file} is now ${sentAs}, so a later replay does not send them twice.`
3288
+ );
3289
+ } catch (err) {
3290
+ const why = err instanceof Error ? err.message : String(err);
3291
+ write(
3292
+ `Every result was sent, but ${file} could not be renamed (${why}) \u2014 take it away by hand, or a later replay sends them again.`
3293
+ );
3294
+ }
3295
+ }
2989
3296
  return result;
2990
3297
  }
2991
- var import_node_child_process, fs7, NoTokenError, RunCommandError, isPending;
3298
+ function sentName(file, at) {
3299
+ const { dir, name, ext } = path8.parse(file);
3300
+ return path8.join(dir, `${name}.${new Date(at).toISOString().replace(/[:.]/g, "-")}.sent${ext}`);
3301
+ }
3302
+ var import_node_child_process, fs7, path8, NoTokenError, RunCommandError, isPending;
2992
3303
  var init_run_lifecycle = __esm({
2993
3304
  "src/cli/commands/run-lifecycle.ts"() {
2994
3305
  "use strict";
2995
3306
  init_cjs_shims();
2996
3307
  import_node_child_process = require("child_process");
2997
3308
  fs7 = __toESM(require("fs"), 1);
3309
+ path8 = __toESM(require("path"), 1);
2998
3310
  init_api_url();
2999
3311
  init_credentials();
3000
3312
  init_client();
@@ -3202,7 +3514,12 @@ function errorTextOf(detail) {
3202
3514
  const head = [detail.attrs["message"], detail.attrs["type"]].filter((s) => s !== void 0 && s !== "");
3203
3515
  return [...head, detail.text.trim()].filter((s) => s !== "").join("\n\n");
3204
3516
  }
3205
- function keyOf(classname, name) {
3517
+ function playwrightOf(classname, name) {
3518
+ if (!SPEC_FILE.test(classname)) return void 0;
3519
+ return { file: classname.replaceAll("\\", "/"), titles: name.split(" \u203A ") };
3520
+ }
3521
+ function keyOf(classname, name, playwright) {
3522
+ if (playwright !== void 0) return { kind: "path-title", value: [playwright.file, ...playwright.titles].join("#") };
3206
3523
  return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
3207
3524
  }
3208
3525
  function titleOf(classname, name) {
@@ -3242,12 +3559,15 @@ function readJUnit(source, file) {
3242
3559
  const name = test.attrs["name"] ?? "";
3243
3560
  const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
3244
3561
  const { status, detail } = outcomeOf(test);
3245
- const key = keyOf(classname, name);
3562
+ const playwright = playwrightOf(classname, name);
3563
+ const key = keyOf(classname, name, playwright);
3564
+ const stated = TOKEN.exec(playwright?.titles.at(-1) ?? name)?.[1];
3246
3565
  const specRef = specRefOf(test, suite, classname);
3247
3566
  const errorContext = errorTextOf(detail);
3248
3567
  const execution = executionOf(test, suite);
3249
3568
  return {
3250
3569
  resultKey: resultKey(key.value, 0),
3570
+ ...stated !== void 0 ? { testCaseId: stated } : {},
3251
3571
  keys: [key],
3252
3572
  source: SOURCE,
3253
3573
  rawStatus: status,
@@ -3261,7 +3581,7 @@ function readJUnit(source, file) {
3261
3581
  function looksLikeJUnit(source) {
3262
3582
  return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3263
3583
  }
3264
- var SOURCE;
3584
+ var SOURCE, TOKEN, SPEC_FILE;
3265
3585
  var init_junit = __esm({
3266
3586
  "src/importers/junit.ts"() {
3267
3587
  "use strict";
@@ -3269,141 +3589,8 @@ var init_junit = __esm({
3269
3589
  init_result_key();
3270
3590
  init_xml();
3271
3591
  SOURCE = "junit";
3272
- }
3273
- });
3274
-
3275
- // src/reporter-core/failure-detail.ts
3276
- function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
3277
- const error = chosen(attempt);
3278
- const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
3279
- const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
3280
- const steps = chainOf(attempt.steps);
3281
- const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
3282
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
3283
- const ciUrl = webLink(attempt.buildHref);
3284
- const failure = {
3285
- ...headline !== void 0 ? { headline } : {},
3286
- ...steps.length > 0 ? { steps } : {},
3287
- ...location !== void 0 ? { location } : {},
3288
- ...artifacts.length > 0 ? { artifacts } : {},
3289
- ...ciUrl !== void 0 ? { ciUrl } : {}
3290
- };
3291
- return Object.keys(failure).length === 0 ? void 0 : failure;
3292
- }
3293
- function repoRootOf(dir, stopAt) {
3294
- for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
3295
- if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
3296
- if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
3297
- }
3298
- }
3299
- function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
3300
- const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
3301
- return cut(machineless(text2, attempt.repoRoot, home));
3302
- }
3303
- function filesOf(attempt) {
3304
- return attempt.attachments.filter((a) => a.path !== void 0);
3305
- }
3306
- function machineless(raw, repoRoot, home) {
3307
- let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
3308
- const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
3309
- if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
3310
- const own = pathIn(home);
3311
- if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
3312
- if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3313
- return text2;
3314
- }
3315
- function pathIn(path14) {
3316
- const parts = path14.split(/[\\/]+/);
3317
- while (parts.length > 1 && parts.at(-1) === "") parts.pop();
3318
- if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
3319
- const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
3320
- const escaped = /^\/(?!\/)/.test(path14) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
3321
- const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
3322
- return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
3323
- }
3324
- function cut(text2) {
3325
- if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
3326
- const lines = text2.split("\n");
3327
- const size = lines.map((line) => jsonBytes(line) + 2);
3328
- const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
3329
- let used = 0;
3330
- let head = 0;
3331
- let tail = lines.length;
3332
- while (head < tail && used + size[head] <= budget / 2) used += size[head++];
3333
- while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
3334
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
3335
- }
3336
- function chosen({ status, errors }) {
3337
- if (status === "timedOut") {
3338
- const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
3339
- if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
3340
- }
3341
- return errors[0];
3342
- }
3343
- function chainOf(steps) {
3344
- const chain = [];
3345
- for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
3346
- chain.push(failed.title);
3347
- }
3348
- return chain;
3349
- }
3350
- function placeOf(error, testFile) {
3351
- const own = normal(testFile);
3352
- let first;
3353
- for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
3354
- if (line.length > FRAME_MAX) continue;
3355
- const frame = FRAME.exec(line);
3356
- if (frame === null) continue;
3357
- const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
3358
- if (normal(place.file) === own) return place;
3359
- if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
3360
- }
3361
- return error.location ?? first;
3362
- }
3363
- function fileOf(raw) {
3364
- if (!raw.startsWith("file://")) return raw;
3365
- let path14 = raw.slice("file://".length);
3366
- try {
3367
- path14 = decodeURIComponent(path14);
3368
- } catch {
3369
- }
3370
- return /^\/[A-Za-z]:\//.test(path14) ? path14.slice(1) : path14;
3371
- }
3372
- function fromRoot(place, root) {
3373
- if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
3374
- const base = normal(root).replace(/\/+$/, "");
3375
- const file = normal(place.file);
3376
- if (!file.startsWith(`${base}/`)) return void 0;
3377
- const relative2 = file.slice(base.length + 1);
3378
- if (!REPO_RELATIVE.test(relative2)) return void 0;
3379
- const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
3380
- return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
3381
- }
3382
- function webLink(href) {
3383
- return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
3384
- }
3385
- var import_node_fs3, import_node_os2, import_node_path3, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
3386
- var init_failure_detail = __esm({
3387
- "src/reporter-core/failure-detail.ts"() {
3388
- "use strict";
3389
- init_cjs_shims();
3390
- import_node_fs3 = require("fs");
3391
- import_node_os2 = require("os");
3392
- import_node_path3 = require("path");
3393
- import_node_util = require("util");
3394
- PATH_START = "(?<![\\w.~%/\\\\-])";
3395
- TEXT_LIMIT = 524288;
3396
- HEADLINE_LIMIT = 8 * 1024;
3397
- jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
3398
- ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
3399
- fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
3400
- omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
3401
- METADATA_TYPE = "application/plune.metadata+json";
3402
- REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
3403
- FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3404
- FRAME_MAX = 8192;
3405
- firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3406
- normal = (path14) => import_node_path3.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3592
+ TOKEN = /@P([A-Za-z0-9_-]+)/;
3593
+ SPEC_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
3407
3594
  }
3408
3595
  });
3409
3596
 
@@ -3411,7 +3598,7 @@ var init_failure_detail = __esm({
3411
3598
  function statedIdOf(test, title) {
3412
3599
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3413
3600
  if (annotation !== void 0 && annotation !== "") return annotation;
3414
- return TOKEN.exec(title)?.[1];
3601
+ return TOKEN2.exec(title)?.[1];
3415
3602
  }
3416
3603
  function attemptOf(result, file, ctx) {
3417
3604
  const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
@@ -3422,10 +3609,13 @@ function attemptOf(result, file, ctx) {
3422
3609
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3423
3610
  })),
3424
3611
  steps: steps(result.steps),
3425
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path14 }) => ({
3612
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path15, body }) => ({
3426
3613
  name: name ?? "",
3427
3614
  ...contentType !== void 0 ? { contentType } : {},
3428
- ...path14 !== void 0 ? { path: path14 } : {}
3615
+ ...path15 !== void 0 ? { path: path15 } : {},
3616
+ // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
3617
+ // bodies is megabytes of base64 that nothing here will read.
3618
+ ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
3429
3619
  })),
3430
3620
  ...ctx.buildHref !== void 0 ? { buildHref: ctx.buildHref } : {},
3431
3621
  // Spec files are posix and relative to the root dir; the core reads either slash.
@@ -3507,12 +3697,17 @@ function readPlaywrightJson(source, file) {
3507
3697
  const out = [];
3508
3698
  for (const suite of suites) walk(suite, [], 0, ctx, out);
3509
3699
  const ciUrl = webLink(ctx.buildHref);
3510
- return ciUrl === void 0 ? { results: out } : { results: out, ciUrl };
3700
+ const ci = config?.metadata?.ci;
3701
+ return {
3702
+ results: out,
3703
+ ...ciUrl !== void 0 ? { ciUrl } : {},
3704
+ ...ci !== void 0 ? { ci } : {}
3705
+ };
3511
3706
  }
3512
3707
  function looksLikePlaywrightJson(source) {
3513
3708
  return /^\s*\{/.test(source) && source.includes('"suites"');
3514
3709
  }
3515
- var import_node_fs4, SOURCE2, TOKEN, NOT_FAILED, JsonReportError;
3710
+ var import_node_fs4, SOURCE2, TOKEN2, NOT_FAILED, JsonReportError;
3516
3711
  var init_playwright_json = __esm({
3517
3712
  "src/importers/playwright-json.ts"() {
3518
3713
  "use strict";
@@ -3521,7 +3716,7 @@ var init_playwright_json = __esm({
3521
3716
  init_failure_detail();
3522
3717
  init_result_key();
3523
3718
  SOURCE2 = "playwright";
3524
- TOKEN = /@P([A-Za-z0-9_-]+)/;
3719
+ TOKEN2 = /@P([A-Za-z0-9_-]+)/;
3525
3720
  NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3526
3721
  JsonReportError = class extends Error {
3527
3722
  constructor(file, detail) {
@@ -3590,7 +3785,7 @@ async function handleRunImport(options) {
3590
3785
  } catch {
3591
3786
  throw new RunCommandError(`Cannot read ${options.file}.`);
3592
3787
  }
3593
- const { format, results, ciUrl } = readReport(source, options.file, options.format);
3788
+ const { format, results, ciUrl, ci } = readReport(source, options.file, options.format);
3594
3789
  if (results.length === 0) {
3595
3790
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3596
3791
  }
@@ -3607,7 +3802,10 @@ async function handleRunImport(options) {
3607
3802
  ...options.create === true ? { offerDiscovered: true } : {},
3608
3803
  kind: "automated",
3609
3804
  // The CI run the report names, never this machine's: whoever imports may be another job (#790).
3610
- meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {} },
3805
+ // The commit and branch likewise, where the report names a commit; a report that names none —
3806
+ // JUnit's never does — takes this job's own, which is where the tests ran when import follows
3807
+ // them in one job (plune-ai/plune#927).
3808
+ meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
3611
3809
  log: write
3612
3810
  },
3613
3811
  // The file is the complete list of what ran, so the platform can resolve every test in one
@@ -3631,8 +3829,10 @@ async function handleRunImport(options) {
3631
3829
  offered: stats.offered,
3632
3830
  created: stats.created,
3633
3831
  unoffered: stats.unoffered,
3832
+ ...stats.unofferedWhy !== void 0 ? { unofferedWhy: stats.unofferedWhy } : {},
3634
3833
  deferred: stats.deferred,
3635
3834
  screenshots: { ...stats.screenshots },
3835
+ textFiles: { ...stats.textFiles },
3636
3836
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3637
3837
  offering: options.create === true
3638
3838
  };
@@ -3656,11 +3856,15 @@ function describe(result, apiUrl) {
3656
3856
  if (result.conflict > 0 || result.rejected > 0) {
3657
3857
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3658
3858
  }
3659
- const shots = result.screenshots;
3660
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
3661
- lines.push(
3662
- `${shots.uploaded} screenshot(s) uploaded to their results` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} could not be uploaded` : "") + "."
3663
- );
3859
+ for (const [counts, noun] of [
3860
+ [result.screenshots, "screenshot"],
3861
+ [result.textFiles, "text file"]
3862
+ ]) {
3863
+ if (counts.uploaded + counts.skipped + counts.failed > 0) {
3864
+ lines.push(
3865
+ `${counts.uploaded} ${noun}(s) uploaded to their results` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} could not be uploaded` : "") + "."
3866
+ );
3867
+ }
3664
3868
  }
3665
3869
  if (result.deferred > 0) {
3666
3870
  lines.push(
@@ -3681,7 +3885,7 @@ function describe(result, apiUrl) {
3681
3885
  }
3682
3886
  if (result.unoffered > 0) {
3683
3887
  lines.push(
3684
- `${result.unoffered} more could not be offered \u2014 the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.`
3888
+ `${result.unoffered} more could not be offered \u2014 ${NEXT[result.unofferedWhy ?? "other"]}`
3685
3889
  );
3686
3890
  }
3687
3891
  if (result.unlocatable > 0) {
@@ -3692,7 +3896,7 @@ function describe(result, apiUrl) {
3692
3896
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3693
3897
  return lines;
3694
3898
  }
3695
- var fs8;
3899
+ var fs8, NEXT;
3696
3900
  var init_run_import = __esm({
3697
3901
  "src/cli/commands/run-import.ts"() {
3698
3902
  "use strict";
@@ -3704,6 +3908,11 @@ var init_run_import = __esm({
3704
3908
  init_api_url();
3705
3909
  init_credentials();
3706
3910
  init_env2();
3911
+ NEXT = {
3912
+ queue: "the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.",
3913
+ cases: "the project is at its test case limit. Delete cases you no longer need, or ask an operator to raise the limit, then import this report again to offer the rest.",
3914
+ other: 'the reason is in the "could not offer" line above.'
3915
+ };
3707
3916
  }
3708
3917
  });
3709
3918
 
@@ -3720,7 +3929,7 @@ function isRunResult(v) {
3720
3929
  }
3721
3930
  function handleReport(opts = {}) {
3722
3931
  const cwd = opts.cwd ?? process.cwd();
3723
- const file = path8.join(cwd, ".plune", "last-run.json");
3932
+ const file = path9.join(cwd, ".plune", "last-run.json");
3724
3933
  let raw;
3725
3934
  try {
3726
3935
  raw = fs9.readFileSync(file, "utf8");
@@ -3738,13 +3947,13 @@ function handleReport(opts = {}) {
3738
3947
  }
3739
3948
  return parsed;
3740
3949
  }
3741
- var fs9, path8, ReportNotFoundError;
3950
+ var fs9, path9, ReportNotFoundError;
3742
3951
  var init_report = __esm({
3743
3952
  "src/cli/commands/report.ts"() {
3744
3953
  "use strict";
3745
3954
  init_cjs_shims();
3746
3955
  fs9 = __toESM(require("fs"), 1);
3747
- path8 = __toESM(require("path"), 1);
3956
+ path9 = __toESM(require("path"), 1);
3748
3957
  ReportNotFoundError = class extends Error {
3749
3958
  code = "NO_SAVED_RUN";
3750
3959
  constructor(message) {
@@ -3948,7 +4157,7 @@ async function runInitWizard(cwd) {
3948
4157
  process.exit(1);
3949
4158
  return;
3950
4159
  }
3951
- const defaultModel = provider === "anthropic" ? "claude-3-opus" : "gpt-4o";
4160
+ const defaultModel = provider === "anthropic" ? "claude-sonnet-5-5" : "gpt-4o-mini";
3952
4161
  const model = await clack.text({
3953
4162
  message: "Model name:",
3954
4163
  defaultValue: defaultModel,
@@ -3968,7 +4177,7 @@ async function runInitWizard(cwd) {
3968
4177
  process.exit(1);
3969
4178
  return;
3970
4179
  }
3971
- const outputPath = path9.join(cwd, "plune.yaml");
4180
+ const outputPath = path10.join(cwd, "plune.yaml");
3972
4181
  if (fs11.existsSync(outputPath)) {
3973
4182
  const overwrite = await clack.confirm({
3974
4183
  message: "plune.yaml already exists. Overwrite?"
@@ -4012,14 +4221,14 @@ async function runInitWizard(cwd) {
4012
4221
  spin.stop("plune.yaml created!");
4013
4222
  clack.outro("plune.yaml is ready.");
4014
4223
  }
4015
- var clack, fs11, path9, import_yaml2;
4224
+ var clack, fs11, path10, import_yaml2;
4016
4225
  var init_wizard = __esm({
4017
4226
  "src/config/init/wizard.ts"() {
4018
4227
  "use strict";
4019
4228
  init_cjs_shims();
4020
4229
  clack = __toESM(require("@clack/prompts"), 1);
4021
4230
  fs11 = __toESM(require("fs"), 1);
4022
- path9 = __toESM(require("path"), 1);
4231
+ path10 = __toESM(require("path"), 1);
4023
4232
  import_yaml2 = require("yaml");
4024
4233
  init_errors();
4025
4234
  }
@@ -4027,21 +4236,21 @@ var init_wizard = __esm({
4027
4236
 
4028
4237
  // src/cli/templates/index.ts
4029
4238
  function writeTemplateFile(cwd, rel, content, force) {
4030
- const target = path10.join(cwd, rel);
4239
+ const target = path11.join(cwd, rel);
4031
4240
  if (!force && fs12.existsSync(target)) {
4032
4241
  return "skipped";
4033
4242
  }
4034
- fs12.mkdirSync(path10.dirname(target), { recursive: true });
4243
+ fs12.mkdirSync(path11.dirname(target), { recursive: true });
4035
4244
  fs12.writeFileSync(target, content, "utf8");
4036
4245
  return "written";
4037
4246
  }
4038
- var fs12, path10, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4247
+ var fs12, path11, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4039
4248
  var init_templates = __esm({
4040
4249
  "src/cli/templates/index.ts"() {
4041
4250
  "use strict";
4042
4251
  init_cjs_shims();
4043
4252
  fs12 = __toESM(require("fs"), 1);
4044
- path10 = __toESM(require("path"), 1);
4253
+ path11 = __toESM(require("path"), 1);
4045
4254
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
4046
4255
  # .env is gitignored; .env.example is safe to commit.
4047
4256
  # Plune reads whichever key matches your configured provider.
@@ -4062,7 +4271,7 @@ OPENROUTER_API_KEY=YOUR_OPENROUTER_API_KEY_HERE
4062
4271
  version: 1
4063
4272
  provider:
4064
4273
  type: anthropic
4065
- model: claude-3-5-sonnet-latest
4274
+ model: claude-sonnet-5-5
4066
4275
  evals:
4067
4276
  - id: example
4068
4277
  prompt: "Answer concisely. {{question}}"
@@ -4297,7 +4506,7 @@ __export(sync_exports, {
4297
4506
  reportSyncFailure: () => reportSyncFailure
4298
4507
  });
4299
4508
  function readRun(cwd, file) {
4300
- const target = file !== void 0 ? path11.resolve(cwd, file) : path11.join(cwd, ".plune", "last-run.json");
4509
+ const target = file !== void 0 ? path12.resolve(cwd, file) : path12.join(cwd, ".plune", "last-run.json");
4301
4510
  let raw;
4302
4511
  try {
4303
4512
  raw = fs13.readFileSync(target, "utf8");
@@ -4364,13 +4573,13 @@ function reportSyncFailure(err, write) {
4364
4573
  }
4365
4574
  return null;
4366
4575
  }
4367
- var fs13, path11, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4576
+ var fs13, path12, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4368
4577
  var init_sync = __esm({
4369
4578
  "src/cli/commands/sync.ts"() {
4370
4579
  "use strict";
4371
4580
  init_cjs_shims();
4372
4581
  fs13 = __toESM(require("fs"), 1);
4373
- path11 = __toESM(require("path"), 1);
4582
+ path12 = __toESM(require("path"), 1);
4374
4583
  init_run_result_schema();
4375
4584
  init_api_url();
4376
4585
  init_credentials();
@@ -4420,9 +4629,9 @@ async function findLatestCairnRun(runsDir = "runs") {
4420
4629
  let newest;
4421
4630
  for (const entry of entries) {
4422
4631
  if (!entry.isDirectory()) continue;
4423
- const dir = path12.join(runsDir, entry.name);
4632
+ const dir = path13.join(runsDir, entry.name);
4424
4633
  try {
4425
- const report = await fs14.lstat(path12.join(dir, "report.json"));
4634
+ const report = await fs14.lstat(path13.join(dir, "report.json"));
4426
4635
  if (!report.isFile()) continue;
4427
4636
  if (newest === void 0 || report.mtimeMs > newest.at) {
4428
4637
  newest = { dir, at: report.mtimeMs };
@@ -4441,7 +4650,7 @@ async function readJson(file) {
4441
4650
  }
4442
4651
  }
4443
4652
  async function defaultReadEvidence(dir) {
4444
- const raw = await readJson(path12.join(dir, "api-evidence.json"));
4653
+ const raw = await readJson(path13.join(dir, "api-evidence.json"));
4445
4654
  if (!Array.isArray(raw)) return void 0;
4446
4655
  return raw.map((r) => {
4447
4656
  const rec = asRecord(r);
@@ -4483,7 +4692,7 @@ function casesFromApi(report, verdicts) {
4483
4692
  });
4484
4693
  }
4485
4694
  async function readCairnRun(dir, deps = {}) {
4486
- const raw = await readJson(path12.join(dir, "report.json"));
4695
+ const raw = await readJson(path13.join(dir, "report.json"));
4487
4696
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4488
4697
  const report = asRecord(raw);
4489
4698
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4513,20 +4722,20 @@ async function readCairnRun(dir, deps = {}) {
4513
4722
  return {
4514
4723
  schemaVersion: INGEST_SCHEMA_VERSION,
4515
4724
  run: {
4516
- runId: str(report["runId"], path12.basename(dir)),
4725
+ runId: str(report["runId"], path13.basename(dir)),
4517
4726
  mode,
4518
4727
  target: str(report["url"], "unknown")
4519
4728
  },
4520
4729
  cases
4521
4730
  };
4522
4731
  }
4523
- var fs14, path12, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4732
+ var fs14, path13, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4524
4733
  var init_cairn_artifact = __esm({
4525
4734
  "src/cli/commands/cairn-artifact.ts"() {
4526
4735
  "use strict";
4527
4736
  init_cjs_shims();
4528
4737
  fs14 = __toESM(require("fs/promises"), 1);
4529
- path12 = __toESM(require("path"), 1);
4738
+ path13 = __toESM(require("path"), 1);
4530
4739
  SUPPORTED_ARTIFACT_VERSION = 1;
4531
4740
  INGEST_SCHEMA_VERSION = 1;
4532
4741
  NoCairnRunsError = class extends Error {
@@ -4693,7 +4902,7 @@ __export(cases_exports, {
4693
4902
  function gitDirty(file) {
4694
4903
  try {
4695
4904
  const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4696
- cwd: path13.dirname(file),
4905
+ cwd: path14.dirname(file),
4697
4906
  stdio: ["ignore", "pipe", "ignore"],
4698
4907
  encoding: "utf8"
4699
4908
  });
@@ -4719,9 +4928,9 @@ async function handlePull(deps = {}) {
4719
4928
  const cwd = deps.cwd ?? process.cwd();
4720
4929
  const apiUrl = resolveApiUrl(deps.apiUrl);
4721
4930
  const token = tokenOf(deps);
4722
- const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4931
+ const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4723
4932
  if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4724
- throw new DirtyFileError(path13.relative(cwd, file) || file);
4933
+ throw new DirtyFileError(path14.relative(cwd, file) || file);
4725
4934
  const doFetch = deps.fetchImpl ?? fetch;
4726
4935
  const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4727
4936
  let res;
@@ -4736,7 +4945,7 @@ async function handlePull(deps = {}) {
4736
4945
  throw new SyncHttpError(res.status, detail);
4737
4946
  }
4738
4947
  const text2 = await res.text();
4739
- fs15.mkdirSync(path13.dirname(file), { recursive: true });
4948
+ fs15.mkdirSync(path14.dirname(file), { recursive: true });
4740
4949
  fs15.writeFileSync(file, text2, "utf8");
4741
4950
  return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4742
4951
  }
@@ -4744,7 +4953,7 @@ async function handlePush(deps = {}) {
4744
4953
  const cwd = deps.cwd ?? process.cwd();
4745
4954
  const apiUrl = resolveApiUrl(deps.apiUrl);
4746
4955
  const token = tokenOf(deps);
4747
- const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4956
+ const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4748
4957
  let text2;
4749
4958
  try {
4750
4959
  text2 = fs15.readFileSync(file, "utf8");
@@ -4798,18 +5007,18 @@ function reportCasesFailure(err, write) {
4798
5007
  }
4799
5008
  return null;
4800
5009
  }
4801
- var import_node_child_process2, fs15, path13, DEFAULT_CASES_FILE, DirtyFileError;
5010
+ var import_node_child_process2, fs15, path14, DEFAULT_CASES_FILE, DirtyFileError;
4802
5011
  var init_cases = __esm({
4803
5012
  "src/cli/commands/cases.ts"() {
4804
5013
  "use strict";
4805
5014
  init_cjs_shims();
4806
5015
  import_node_child_process2 = require("child_process");
4807
5016
  fs15 = __toESM(require("fs"), 1);
4808
- path13 = __toESM(require("path"), 1);
5017
+ path14 = __toESM(require("path"), 1);
4809
5018
  init_api_url();
4810
5019
  init_credentials();
4811
5020
  init_sync();
4812
- DEFAULT_CASES_FILE = path13.join("plune", "cases.md");
5021
+ DEFAULT_CASES_FILE = path14.join("plune", "cases.md");
4813
5022
  DirtyFileError = class extends Error {
4814
5023
  constructor(file) {
4815
5024
  super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
@@ -4945,6 +5154,12 @@ function createProgram() {
4945
5154
  const program = new import_commander.Command();
4946
5155
  program.name("plune").description("Plune \u2014 AI-powered assertion testing CLI").version(pkg.version);
4947
5156
  program.option("-c, --config <path>", "Path to plune.yaml config file (applies to all commands)").option("-v, --verbose", "Verbose output, including stack traces on unexpected errors", false).option("--no-color", "Disable colored output regardless of TTY");
5157
+ program.hook("preAction", async (_program, action) => {
5158
+ const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
5159
+ const { config } = action.optsWithGlobals();
5160
+ if (config !== void 0) loadEnv2((0, import_node_path4.dirname)((0, import_node_path4.resolve)(config)));
5161
+ loadEnv2(process.cwd());
5162
+ });
4948
5163
  const runCommand = program.command("run").description("Run assertions against a dataset").option("--dry-run", "Estimate cost/tokens; do not call the model", false).option("--config <path>", "Path to plune.yaml config file").option(
4949
5164
  "--only <selector>",
4950
5165
  "Run a subset by eval id or tag (repeatable)",
@@ -4955,21 +5170,18 @@ function createProgram() {
4955
5170
  const globals = command.optsWithGlobals();
4956
5171
  const configPath = globals.config;
4957
5172
  const [
4958
- { loadEnv: loadEnv2 },
4959
5173
  { handleRun: handleRun2 },
4960
5174
  { exitCodeFor: exitCodeFor2, RunConfigError: RunConfigError2 },
4961
5175
  { renderReport: renderReport2 },
4962
5176
  { ConfigNotFoundError: ConfigNotFoundError2, ConfigValidationError: ConfigValidationError2, YamlParseError: YamlParseError2 },
4963
5177
  { AuthError: AuthError2 }
4964
5178
  ] = await Promise.all([
4965
- Promise.resolve().then(() => (init_env(), env_exports)),
4966
5179
  Promise.resolve().then(() => (init_run2(), run_exports)),
4967
5180
  Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports)),
4968
5181
  Promise.resolve().then(() => (init_reporters(), reporters_exports)),
4969
5182
  Promise.resolve().then(() => (init_errors(), errors_exports)),
4970
5183
  Promise.resolve().then(() => (init_errors2(), errors_exports2))
4971
5184
  ]);
4972
- loadEnv2(configPath !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(configPath)) : process.cwd());
4973
5185
  try {
4974
5186
  const result = await handleRun2({
4975
5187
  dryRun: options.dryRun,
@@ -5129,13 +5341,11 @@ function createProgram() {
5129
5341
  );
5130
5342
  program.command("report").description("Render the most recent run in a chosen format").option("--format <fmt>", "console | json | markdown", "console").option("-o, --output <path>", "Write the report to a file instead of stdout").action(async (options, command) => {
5131
5343
  const globals = command.optsWithGlobals();
5132
- const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
5133
- Promise.resolve().then(() => (init_env(), env_exports)),
5344
+ const [{ handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
5134
5345
  Promise.resolve().then(() => (init_report(), report_exports)),
5135
5346
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
5136
5347
  ]);
5137
5348
  const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
5138
- loadEnv2(cwd);
5139
5349
  try {
5140
5350
  const result = handleReport2({ cwd });
5141
5351
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -5199,10 +5409,10 @@ function createProgram() {
5199
5409
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
5200
5410
  try {
5201
5411
  const token = options.token ?? await readTokenFromStdin();
5202
- const { path: path14, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5412
+ const { path: path15, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5203
5413
  process.stdout.write(
5204
- verified ? `Logged in. Token checked against the API and saved to ${path14}
5205
- ` : `Token saved to ${path14} WITHOUT being checked (--skip-verify).
5414
+ verified ? `Logged in. Token checked against the API and saved to ${path15}
5415
+ ` : `Token saved to ${path15} WITHOUT being checked (--skip-verify).
5206
5416
  `
5207
5417
  );
5208
5418
  } catch (err) {
@@ -5219,9 +5429,9 @@ function createProgram() {
5219
5429
  const verbose = command.optsWithGlobals().verbose === true;
5220
5430
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
5221
5431
  try {
5222
- const { removed, path: path14 } = handleLogout2();
5432
+ const { removed, path: path15 } = handleLogout2();
5223
5433
  process.stdout.write(
5224
- removed ? `Logged out. Removed ${path14}
5434
+ removed ? `Logged out. Removed ${path15}
5225
5435
  ` : "Not logged in \u2014 nothing to remove.\n"
5226
5436
  );
5227
5437
  } catch (err) {