@plune-ai/cli 0.24.0 → 0.26.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
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
515
+ defaultSleep = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
516
516
  }
517
517
  });
518
518
 
@@ -555,22 +555,22 @@ var init_prices = __esm({
555
555
  });
556
556
 
557
557
  // src/providers/cost.ts
558
- function priceFrom(price, usage) {
559
- return usage.input_tokens / 1e3 * price.input_per_1k_usd + usage.output_tokens / 1e3 * price.output_per_1k_usd;
558
+ function priceFrom(price, usage2) {
559
+ return usage2.input_tokens / 1e3 * price.input_per_1k_usd + usage2.output_tokens / 1e3 * price.output_per_1k_usd;
560
560
  }
561
- function resolveCost(usage, model, reportedCostUsd, overrides, deps = {}) {
561
+ function resolveCost(usage2, model, reportedCostUsd, overrides, deps = {}) {
562
562
  const table = deps.table ?? PRICE_TABLE;
563
563
  const warn = deps.warn ?? defaultWarn;
564
564
  const override = overrides?.[model];
565
565
  if (override !== void 0) {
566
- return priceFrom(override, usage);
566
+ return priceFrom(override, usage2);
567
567
  }
568
568
  if (reportedCostUsd !== void 0) {
569
569
  return reportedCostUsd;
570
570
  }
571
571
  const tablePrice = table[model];
572
572
  if (tablePrice !== void 0) {
573
- return priceFrom(tablePrice, usage);
573
+ return priceFrom(tablePrice, usage2);
574
574
  }
575
575
  warn(
576
576
  `plune: no price for model "${model}" \u2014 reporting cost_usd=0. Set pricing["${model}"] in plune.yaml to track its cost.`
@@ -628,8 +628,8 @@ function makeAnthropicProvider(config, env, pricing) {
628
628
  throw normalizeProviderError(err, apiKey, ENV_VAR);
629
629
  }
630
630
  },
631
- estimateCost(usage, reportedCostUsd) {
632
- return { cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing) };
631
+ estimateCost(usage2, reportedCostUsd) {
632
+ return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
633
633
  }
634
634
  };
635
635
  }
@@ -692,8 +692,8 @@ function makeOpenAiCompatibleProvider(opts) {
692
692
  throw normalizeProviderError(err, apiKey, apiKeyEnv);
693
693
  }
694
694
  },
695
- estimateCost(usage, reportedCostUsd) {
696
- return { cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing) };
695
+ estimateCost(usage2, reportedCostUsd) {
696
+ return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
697
697
  }
698
698
  };
699
699
  }
@@ -796,8 +796,8 @@ function makeEstimateProvider(config, pricing) {
796
796
  complete: () => Promise.reject(
797
797
  new Error("The --dry-run provider only estimates cost; it never calls a model.")
798
798
  ),
799
- estimateCost: (usage, reportedCostUsd) => ({
800
- cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
799
+ estimateCost: (usage2, reportedCostUsd) => ({
800
+ cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing)
801
801
  })
802
802
  };
803
803
  }
@@ -1716,8 +1716,8 @@ async function runRow(p) {
1716
1716
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1717
1717
  if (p.dryRun) {
1718
1718
  const est = estimateUsage(prompt5, maxTokens);
1719
- const usage2 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1720
- return { vars: p.row.vars, output: null, cached: false, usage: usage2, assertions: [] };
1719
+ const usage3 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1720
+ return { vars: p.row.vars, output: null, cached: false, usage: usage3, assertions: [] };
1721
1721
  }
1722
1722
  const key = cacheKey({
1723
1723
  provider: p.providerConfig.type,
@@ -1727,13 +1727,13 @@ async function runRow(p) {
1727
1727
  prompt_resolved: prompt5
1728
1728
  });
1729
1729
  let output;
1730
- let usage;
1730
+ let usage2;
1731
1731
  let cached = false;
1732
1732
  const hit = p.noCache ? void 0 : p.cache.get(key);
1733
1733
  if (hit !== void 0) {
1734
1734
  output = hit.output;
1735
1735
  cached = true;
1736
- usage = {
1736
+ usage2 = {
1737
1737
  input_tokens: hit.usage.input_tokens,
1738
1738
  output_tokens: hit.usage.output_tokens,
1739
1739
  cost_usd: 0
@@ -1748,7 +1748,7 @@ async function runRow(p) {
1748
1748
  prompt_resolved: prompt5
1749
1749
  });
1750
1750
  output = res.output;
1751
- usage = {
1751
+ usage2 = {
1752
1752
  input_tokens: res.usage.input_tokens,
1753
1753
  output_tokens: res.usage.output_tokens,
1754
1754
  // Prefer the provider-reported actual cost (res.cost_usd) when present; resolveCost still
@@ -1794,7 +1794,7 @@ async function runRow(p) {
1794
1794
  break;
1795
1795
  }
1796
1796
  }
1797
- const finalUsage = { ...usage, cost_usd: usage.cost_usd + judgeCost };
1797
+ const finalUsage = { ...usage2, cost_usd: usage2.cost_usd + judgeCost };
1798
1798
  return {
1799
1799
  vars: p.row.vars,
1800
1800
  output,
@@ -2312,17 +2312,18 @@ function parseRetryAfter(raw, now = Date.now()) {
2312
2312
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
2313
2313
  }
2314
2314
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2315
- return new Promise((resolve9, reject) => {
2315
+ return new Promise((resolve10, reject) => {
2316
2316
  const target = new URL(url);
2317
2317
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2318
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
2318
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
2319
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
2319
2320
  const chunks = [];
2320
2321
  res.on("data", (chunk2) => chunks.push(chunk2));
2321
2322
  res.on("error", reject);
2322
2323
  res.on("end", () => {
2323
2324
  const status = res.statusCode ?? 0;
2324
2325
  const text2 = Buffer.concat(chunks).toString("utf8");
2325
- resolve9({
2326
+ resolve10({
2326
2327
  ok: status >= 200 && status <= 299,
2327
2328
  status,
2328
2329
  headers: {
@@ -2341,12 +2342,12 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2341
2342
  });
2342
2343
  }
2343
2344
  function nodePut(url, headers, source, idleMs) {
2344
- return new Promise((resolve9, reject) => {
2345
+ return new Promise((resolve10, reject) => {
2345
2346
  const target = new URL(url);
2346
2347
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2347
2348
  const length = "body" in source ? source.body.length : source.size;
2348
2349
  let answered;
2349
- const settle = (err) => answered !== void 0 ? resolve9(answered) : reject(err);
2350
+ const settle = (err) => answered !== void 0 ? resolve10(answered) : reject(err);
2350
2351
  const req = send(
2351
2352
  target,
2352
2353
  { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
@@ -2355,7 +2356,7 @@ function nodePut(url, headers, source, idleMs) {
2355
2356
  answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
2356
2357
  res.on("error", settle);
2357
2358
  res.on("close", () => {
2358
- resolve9(answered);
2359
+ resolve10(answered);
2359
2360
  req.destroy();
2360
2361
  });
2361
2362
  res.resume();
@@ -2370,14 +2371,25 @@ function nodePut(url, headers, source, idleMs) {
2370
2371
  async function detailOf(res) {
2371
2372
  try {
2372
2373
  const body = await res.json();
2374
+ const q = body.quota;
2373
2375
  return {
2376
+ ...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
2374
2377
  detail: typeof body.error === "string" ? body.error : "",
2378
+ ...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
2375
2379
  ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
2376
2380
  };
2377
2381
  } catch {
2378
2382
  return { detail: "" };
2379
2383
  }
2380
2384
  }
2385
+ function describeQuota(q) {
2386
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
2387
+ const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
2388
+ const [used, limit] = [num(q["used"]), num(q["limit"])];
2389
+ const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
2390
+ if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
2391
+ return parts.join(", ");
2392
+ }
2381
2393
  function classify(status) {
2382
2394
  if (status === 401 || status === 403) return "auth";
2383
2395
  if (status === 409) return "conflict";
@@ -2442,12 +2454,12 @@ function createClient(opts) {
2442
2454
  }
2443
2455
  const said = await detailOf(res);
2444
2456
  const kind = classify(res.status);
2445
- last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
2457
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
2446
2458
  if (kind !== "unavailable") break;
2447
2459
  const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
2448
- if (res.status === 429 && said429 === null) {
2460
+ if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
2449
2461
  last.kind = "invalid";
2450
- last.detail = "quota exceeded (no Retry-After)";
2462
+ last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
2451
2463
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
2452
2464
  break;
2453
2465
  }
@@ -2460,6 +2472,7 @@ function createClient(opts) {
2460
2472
  post: (path17, body, retries) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }, retries),
2461
2473
  upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2462
2474
  del: (path17) => send("DELETE", path17),
2475
+ get: (path17) => send("GET", path17),
2463
2476
  async put(url, headers, source) {
2464
2477
  const putRetries = uploadRetries ?? 3;
2465
2478
  let last = { ok: false, status: null, code: "network" };
@@ -2494,7 +2507,7 @@ function createClient(opts) {
2494
2507
  }
2495
2508
  };
2496
2509
  }
2497
- var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, IDLE_MS, withoutLength, codeOfError, sleep;
2510
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, POOLS, IDLE_MS, withoutLength, codeOfError, sleep;
2498
2511
  var init_client = __esm({
2499
2512
  "src/reporter-core/client.ts"() {
2500
2513
  "use strict";
@@ -2507,6 +2520,7 @@ var init_client = __esm({
2507
2520
  BACKOFF_CAP_MS = 1e4;
2508
2521
  DEFAULT_RETRIES = 2;
2509
2522
  RETRY_AFTER_CAP_MS = 6e4;
2523
+ POOLS = { "http:": new import_node_http.Agent({ keepAlive: true }), "https:": new import_node_https.Agent({ keepAlive: true }) };
2510
2524
  IDLE_MS = 3e5;
2511
2525
  withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2512
2526
  codeOfError = (err) => {
@@ -2518,6 +2532,16 @@ var init_client = __esm({
2518
2532
  });
2519
2533
 
2520
2534
  // src/reporter-core/env.ts
2535
+ function shardOf(env) {
2536
+ const raw = value(env, "PLUNE_SHARD");
2537
+ if (raw === void 0) return {};
2538
+ const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
2539
+ const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
2540
+ if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
2541
+ return {
2542
+ error: `PLUNE_SHARD=${JSON.stringify(raw.slice(0, 40))} is not "k/N" with 1 <= k <= N <= ${SHARDS_MAX} (for example 2/4) - nothing was sent.`
2543
+ };
2544
+ }
2521
2545
  function value(env, name) {
2522
2546
  const raw = env[name]?.trim();
2523
2547
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -2560,6 +2584,7 @@ function readEnv(env = process.env) {
2560
2584
  const labels = list(env, "PLUNE_LABELS");
2561
2585
  const offerDiscovered = flag(env, "PLUNE_CREATE");
2562
2586
  const full = flag(env, "PLUNE_FULL_RUN");
2587
+ const { shard, error: shardError } = shardOf(env);
2563
2588
  return {
2564
2589
  config: {
2565
2590
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -2580,7 +2605,11 @@ function readEnv(env = process.env) {
2580
2605
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2581
2606
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
2582
2607
  // way this process must not be the one to call it finished.
2583
- keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
2608
+ // `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
2609
+ // by itself; `leaveOpen` reports the shard instead.
2610
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
2611
+ ...shard !== void 0 ? { shard } : {},
2612
+ ...shardError !== void 0 ? { shardError } : {},
2584
2613
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2585
2614
  warnings
2586
2615
  };
@@ -2608,14 +2637,15 @@ function ciCommit(reported, env = process.env) {
2608
2637
  ...branch !== void 0 ? { branch } : {}
2609
2638
  };
2610
2639
  }
2611
- var path6, UNSUPPORTED_VARS, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2640
+ var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2612
2641
  var init_env2 = __esm({
2613
2642
  "src/reporter-core/env.ts"() {
2614
2643
  "use strict";
2615
2644
  init_cjs_shims();
2616
2645
  path6 = __toESM(require("path"), 1);
2617
2646
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2618
- UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
2647
+ SHARDS_MAX = 1e3;
2648
+ UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
2619
2649
  UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
2620
2650
  PROVIDERS = [
2621
2651
  { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
@@ -2914,6 +2944,7 @@ function warnIfDropped(sent, run, log) {
2914
2944
  }
2915
2945
  async function startRun(passed, declared2) {
2916
2946
  const env = readEnv();
2947
+ if (env.shardError !== void 0) throw new Error(env.shardError);
2917
2948
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2918
2949
  const cfg = { ...env.config, ...stated };
2919
2950
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
@@ -2955,6 +2986,9 @@ async function startRun(passed, declared2) {
2955
2986
  const resolved = /* @__PURE__ */ new Map();
2956
2987
  let buffer = [];
2957
2988
  const discoveries = /* @__PURE__ */ new Map();
2989
+ const held = /* @__PURE__ */ new Map();
2990
+ let createdWithResult = 0;
2991
+ let createdUndelivered = 0;
2958
2992
  let runId = null;
2959
2993
  let joined = false;
2960
2994
  let offline = token === "";
@@ -3083,6 +3117,7 @@ async function startRun(passed, declared2) {
3083
3117
  specRef: result.specRef,
3084
3118
  rawStatus: result.rawStatus
3085
3119
  });
3120
+ held.set(result.keys[0].value, result);
3086
3121
  }
3087
3122
  async function offer() {
3088
3123
  if (discoveries.size === 0 || offline || runId === null) return;
@@ -3101,7 +3136,24 @@ async function startRun(passed, declared2) {
3101
3136
  return;
3102
3137
  }
3103
3138
  stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
3104
- stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
3139
+ const made = out.body.results.filter((r) => r?.outcome === "created");
3140
+ stats.created += made.length;
3141
+ const submissions = [];
3142
+ const filesByKey = /* @__PURE__ */ new Map();
3143
+ for (const r of made) {
3144
+ const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
3145
+ if (pending === void 0) continue;
3146
+ held.delete(r.key.value);
3147
+ const { keys: _keys, files, ...rest } = pending;
3148
+ submissions.push({ ...rest, testCaseId: r.id });
3149
+ if (files !== void 0) filesByKey.set(pending.resultKey, files);
3150
+ }
3151
+ if (submissions.length > 0) {
3152
+ const before = stats.deferred;
3153
+ await send(submissions, filesByKey);
3154
+ createdWithResult += submissions.length;
3155
+ createdUndelivered += stats.deferred - before;
3156
+ }
3105
3157
  }
3106
3158
  }
3107
3159
  function skip(kind, name, why) {
@@ -3154,8 +3206,8 @@ async function startRun(passed, declared2) {
3154
3206
  return skip(kind, job.name, "missing");
3155
3207
  }
3156
3208
  }
3157
- const refusal = kind === "text" ? textRefusal(bytes) : void 0;
3158
- if (refusal !== void 0) return skip(kind, job.name, refusal);
3209
+ const refusal2 = kind === "text" ? textRefusal(bytes) : void 0;
3210
+ if (refusal2 !== void 0) return skip(kind, job.name, refusal2);
3159
3211
  requests += 1;
3160
3212
  const out = await client.upload(
3161
3213
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
@@ -3187,7 +3239,7 @@ async function startRun(passed, declared2) {
3187
3239
  let free = uploadsAtOnce;
3188
3240
  const waiting = [];
3189
3241
  async function gated(work) {
3190
- if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
3242
+ if (free === 0) await new Promise((resolve10) => waiting.push(resolve10));
3191
3243
  else free -= 1;
3192
3244
  try {
3193
3245
  return await work();
@@ -3439,9 +3491,9 @@ async function startRun(passed, declared2) {
3439
3491
  skip(kind, job.name, "missing");
3440
3492
  continue;
3441
3493
  }
3442
- const refusal = kind === "text" ? textRefusal(data) : void 0;
3443
- if (refusal !== void 0) {
3444
- skip(kind, job.name, refusal);
3494
+ const refusal2 = kind === "text" ? textRefusal(data) : void 0;
3495
+ if (refusal2 !== void 0) {
3496
+ skip(kind, job.name, refusal2);
3445
3497
  continue;
3446
3498
  }
3447
3499
  body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
@@ -3508,7 +3560,29 @@ async function startRun(passed, declared2) {
3508
3560
  else await uploadBatched(jobs);
3509
3561
  uploadMs += Date.now() - started;
3510
3562
  }
3563
+ const sending = /* @__PURE__ */ new Set();
3564
+ let sendError;
3565
+ let answered = false;
3566
+ async function dispatch(work) {
3567
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
3568
+ const task = work().catch((err) => {
3569
+ sendError ??= err;
3570
+ }).finally(() => sending.delete(task));
3571
+ sending.add(task);
3572
+ }
3573
+ async function settle() {
3574
+ while (sending.size > 0) await Promise.all([...sending]);
3575
+ if (sendError !== void 0) {
3576
+ const err = sendError;
3577
+ sendError = void 0;
3578
+ throw err;
3579
+ }
3580
+ }
3511
3581
  async function flush() {
3582
+ await flushBuffer();
3583
+ await settle();
3584
+ }
3585
+ async function flushBuffer() {
3512
3586
  if (buffer.length === 0) return;
3513
3587
  const pending = buffer;
3514
3588
  buffer = [];
@@ -3516,6 +3590,9 @@ async function startRun(passed, declared2) {
3516
3590
  defer(pending);
3517
3591
  return;
3518
3592
  }
3593
+ await dispatch(() => deliver(pending));
3594
+ }
3595
+ async function deliver(pending) {
3519
3596
  await resolveKeys(pending.flatMap((p) => p.keys));
3520
3597
  const submissions = [];
3521
3598
  const unanswered = [];
@@ -3538,6 +3615,9 @@ async function startRun(passed, declared2) {
3538
3615
  progress();
3539
3616
  return;
3540
3617
  }
3618
+ await send(submissions, filesByKey);
3619
+ }
3620
+ async function send(submissions, filesByKey) {
3541
3621
  for (const part of packed(submissions)) {
3542
3622
  if (offline) {
3543
3623
  defer(part);
@@ -3553,6 +3633,7 @@ async function startRun(passed, declared2) {
3553
3633
  for (const k of told) stats[k] += counts[k];
3554
3634
  progress();
3555
3635
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
3636
+ answered = true;
3556
3637
  continue;
3557
3638
  }
3558
3639
  if (out.ok) {
@@ -3581,7 +3662,10 @@ async function startRun(passed, declared2) {
3581
3662
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
3582
3663
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
3583
3664
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
3584
- if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
3665
+ if (stats.created > 0) {
3666
+ const recorded = createdWithResult === stats.created && createdUndelivered === 0;
3667
+ parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
3668
+ }
3585
3669
  const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
3586
3670
  const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
3587
3671
  if (bucketSeen) {
@@ -3611,6 +3695,7 @@ async function startRun(passed, declared2) {
3611
3695
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
3612
3696
  }
3613
3697
  }
3698
+ const shardLines = [];
3614
3699
  return {
3615
3700
  get runId() {
3616
3701
  return runId;
@@ -3623,7 +3708,7 @@ async function startRun(passed, declared2) {
3623
3708
  },
3624
3709
  async add(result) {
3625
3710
  buffer.push({ ...result, keys: bound(result.keys) });
3626
- if (buffer.length >= batchSize) await flush();
3711
+ if (buffer.length >= batchSize) await flushBuffer();
3627
3712
  },
3628
3713
  flush,
3629
3714
  async finish(reason) {
@@ -3648,19 +3733,47 @@ async function startRun(passed, declared2) {
3648
3733
  summarise();
3649
3734
  return frozen;
3650
3735
  },
3651
- async leaveOpen() {
3652
- if (done) return;
3736
+ shardLines,
3737
+ async leaveOpen(opts) {
3738
+ if (done) return void 0;
3653
3739
  done = true;
3654
3740
  await flush();
3655
3741
  await offer();
3656
- if (runId !== null) {
3742
+ let frozen;
3743
+ const after = [];
3744
+ const shard = env.shard;
3745
+ let counted = false;
3746
+ let mismatch;
3747
+ if (runId !== null && shard !== void 0 && !offline) {
3748
+ const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
3749
+ if (out.ok && typeof out.body?.shardsReported === "number") {
3750
+ counted = true;
3751
+ frozen = out.body.summary ?? void 0;
3752
+ const n = out.body.shardsExpected ?? shard.total;
3753
+ if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
3754
+ after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
3755
+ } else if (out.ok || out.kind === "invalid") {
3756
+ log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
3757
+ } else if (out.code === "shards_mismatch") {
3758
+ mismatch = new Error(
3759
+ `PLUNE_SHARD=${shard.index}/${shard.total} says ${shard.total} shards, but run ${runId} already expects ${out.shardsExpected ?? "a different number of"} \u2014 every shard of one run must use the same N. The results are uploaded; the run is left open.`
3760
+ );
3761
+ } else {
3762
+ log(`plune: could not report shard ${shard.index} of ${shard.total} to run ${runId} (${out.detail || out.kind}) \u2014 the results are uploaded; close the run with "plune run finish ${runId}".`);
3763
+ }
3764
+ }
3765
+ if (runId !== null && !counted) {
3657
3766
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
3658
3767
  }
3659
3768
  summarise();
3769
+ shardLines.push(...after);
3770
+ if (opts?.holdShardLines !== true) for (const line of after) log(line);
3771
+ if (mismatch !== void 0) throw mismatch;
3772
+ return frozen;
3660
3773
  }
3661
3774
  };
3662
3775
  }
3663
- var import_node_crypto3, import_node_fs5, 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, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3776
+ var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, SEND_CONCURRENCY, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3664
3777
  var init_session = __esm({
3665
3778
  "src/reporter-core/session.ts"() {
3666
3779
  "use strict";
@@ -3680,6 +3793,7 @@ var init_session = __esm({
3680
3793
  BATCH_MAX = 500;
3681
3794
  DISCOVER_MAX = 500;
3682
3795
  BATCH_DEFAULT = 500;
3796
+ SEND_CONCURRENCY = 4;
3683
3797
  EXPECTED_MAX = 1e4;
3684
3798
  KEY_MAX = 1024;
3685
3799
  TITLE_MAX = 300;
@@ -3843,14 +3957,14 @@ async function handleRunExec(options) {
3843
3957
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
3844
3958
  const run = await handleRunStart(options);
3845
3959
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
3846
- const code = await new Promise((resolve9) => {
3960
+ const code = await new Promise((resolve10) => {
3847
3961
  const child = spawnImpl(command, args, {
3848
3962
  stdio: "inherit",
3849
3963
  shell: process.platform === "win32",
3850
3964
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
3851
3965
  });
3852
- child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3853
- child.on("error", () => resolve9(1));
3966
+ child.on("close", (exitCode) => resolve10(exitCode ?? 1));
3967
+ child.on("error", () => resolve10(1));
3854
3968
  });
3855
3969
  return handleRunFinish(run.id, options, code);
3856
3970
  }
@@ -5302,7 +5416,7 @@ async function handleRunImport(options) {
5302
5416
  );
5303
5417
  for (const result2 of results) await session.add(result2);
5304
5418
  let frozen;
5305
- if (env.keepOpen) await session.leaveOpen();
5419
+ if (env.keepOpen) frozen = await session.leaveOpen({ holdShardLines: true });
5306
5420
  else frozen = await session.finish();
5307
5421
  const stats = session.stats;
5308
5422
  const result = {
@@ -5333,13 +5447,14 @@ async function handleRunImport(options) {
5333
5447
  };
5334
5448
  for (const line of describe(result, apiUrl)) write(line);
5335
5449
  if (options.failOnFailures === true) {
5336
- if (env.keepOpen) {
5450
+ if (env.keepOpen && frozen === void 0) {
5337
5451
  write("plune: the run is left open for the jobs still reporting, so there is no verdict yet \u2014 gate on the step that finishes it.");
5338
5452
  }
5339
5453
  const gate = gateExit(frozen);
5340
5454
  write(gate.line);
5341
5455
  result.gate = gate.code;
5342
5456
  }
5457
+ for (const line of session.shardLines) write(line);
5343
5458
  return result;
5344
5459
  }
5345
5460
  function runUrl2(apiUrl, id) {
@@ -6195,6 +6310,304 @@ var init_ingest = __esm({
6195
6310
  }
6196
6311
  });
6197
6312
 
6313
+ // src/cli/commands/requirements-import.ts
6314
+ var requirements_import_exports = {};
6315
+ __export(requirements_import_exports, {
6316
+ MAX_MARKDOWN_BYTES: () => MAX_MARKDOWN_BYTES,
6317
+ RequirementsImportError: () => RequirementsImportError,
6318
+ buildImportBody: () => buildImportBody,
6319
+ formatDiscover: () => formatDiscover,
6320
+ formatReport: () => formatReport2,
6321
+ handleRequirementsImport: () => handleRequirementsImport,
6322
+ parseMap: () => parseMap,
6323
+ parseTableSpec: () => parseTableSpec,
6324
+ reportRequirementsImportFailure: () => reportRequirementsImportFailure
6325
+ });
6326
+ function parseMap(flag2, raw) {
6327
+ const out = {};
6328
+ for (const pair of raw.split(",")) {
6329
+ if (pair.trim() === "") continue;
6330
+ const at = pair.indexOf("=");
6331
+ const from = at < 0 ? "" : pair.slice(0, at).trim();
6332
+ const to = at < 0 ? "" : pair.slice(at + 1).trim();
6333
+ if (from === "" || to === "") usage(`${flag2}: "${pair.trim()}" is not Value=Value. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6334
+ out[from] = to;
6335
+ }
6336
+ if (Object.keys(out).length === 0) usage(`${flag2} is empty. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6337
+ return out;
6338
+ }
6339
+ function parseTableSpec(spec) {
6340
+ const at = spec.indexOf(":");
6341
+ const index = (at < 0 ? spec : spec.slice(0, at)).trim();
6342
+ if (!/^\d+$/.test(index)) usage(`--table "${spec}": "${index}" is not a table number. ${SPEC_HELP}`);
6343
+ const t = { table: Number(index) };
6344
+ for (const pair of (at < 0 ? "" : spec.slice(at + 1)).split(",")) {
6345
+ if (pair.trim() === "") continue;
6346
+ const eq = pair.indexOf("=");
6347
+ const name = (eq < 0 ? pair : pair.slice(0, eq)).trim().toLowerCase();
6348
+ const value2 = eq < 0 ? "" : pair.slice(eq + 1).trim();
6349
+ const field = TABLE_SPEC_KEYS[name];
6350
+ if (field === void 0) return usage(`--table "${spec}": "${name}" is not a column name. Use ${Object.keys(TABLE_SPEC_KEYS).join(", ")}.`);
6351
+ if (value2 === "") usage(`--table "${spec}": "${name}" has no value. ${SPEC_HELP}`);
6352
+ if (field in t) usage(`--table "${spec}": "${name}" is given twice.`);
6353
+ if (field === "fixedCategory") oneOf("fixed-category", value2, ["functional", "non-functional", "other"]);
6354
+ if (field === "fixedType") oneOf("fixed-type", value2, ["user_story", "feature", "epic"]);
6355
+ t[field] = value2;
6356
+ }
6357
+ if (t["titleColumn"] !== void 0 && t["textColumn"] !== void 0) usage(`--table "${spec}": give title= or text=, not both.`);
6358
+ if (t["titleColumn"] === void 0 && t["textColumn"] === void 0) usage(`--table "${spec}": each table needs title=<header> or text=<header>.`);
6359
+ return t;
6360
+ }
6361
+ function buildImportBody(f) {
6362
+ const tables = f.table === void 0 ? [] : Array.isArray(f.table) ? f.table : [f.table];
6363
+ const columnFlags = COLUMN_FLAGS.filter(([k]) => f[k] !== void 0);
6364
+ const sectionFlags = SECTION_FLAGS.filter(([k]) => f[k] !== void 0);
6365
+ const anyMapping = tables.length > 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6366
+ if (!anyMapping) return null;
6367
+ if (tables.length > 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6368
+ if (tables.length === 0 && f.sections === void 0) {
6369
+ usage("The mapping flags need --table <n> (a table) or --sections <heading|n> (a section). Run without flags to see which the file has.");
6370
+ }
6371
+ const body = {};
6372
+ if (f.mapPriority !== void 0) body["priorityMap"] = parseMap("--map-priority", f.mapPriority);
6373
+ if (f.type !== void 0) body["defaultType"] = oneOf("--type", f.type, ["user_story", "feature", "epic"]);
6374
+ if (tables.length > 1 || tables.some((t) => t.includes(":"))) {
6375
+ if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6376
+ if (columnFlags.length > 0) {
6377
+ usage(`${columnFlags.map(([, n]) => n).join(", ")} cannot be used with --table "<n>:...": put the column inside each --table, e.g. --table "3:key=ID,text=Requirement".`);
6378
+ }
6379
+ const specs = tables.map(parseTableSpec);
6380
+ const seen = /* @__PURE__ */ new Set();
6381
+ for (const t of specs) {
6382
+ if (seen.has(t["table"])) usage(`Table ${String(t["table"])} is given twice in --table.`);
6383
+ seen.add(t["table"]);
6384
+ }
6385
+ body["mode"] = "table";
6386
+ const categoryMap = f.mapCategory !== void 0 ? parseMap("--map-category", f.mapCategory) : void 0;
6387
+ body["tables"] = specs.map((t) => categoryMap === void 0 ? t : { ...t, categoryMap });
6388
+ } else if (tables.length === 1) {
6389
+ const only = tables[0];
6390
+ if (!/^\d+$/.test(only)) usage(`--table is the number of the table in the list (0, 1, ...), not "${only}".`);
6391
+ if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6392
+ if (f.titleColumn !== void 0 && f.textColumn !== void 0) usage("Give --title-column or --text-column, not both.");
6393
+ if (f.titleColumn === void 0 && f.textColumn === void 0) usage("--table needs --title-column <header> or --text-column <header>.");
6394
+ body["mode"] = "table";
6395
+ body["table"] = Number(only);
6396
+ for (const [k, , key] of columnFlags) body[key] = f[k];
6397
+ if (f.mapCategory !== void 0) body["categoryMap"] = parseMap("--map-category", f.mapCategory);
6398
+ } else {
6399
+ if (columnFlags.length > 0) usage(`${columnFlags.map(([, n]) => n).join(", ")} ${columnFlags.length > 1 ? "are" : "is"} for --table, not --sections.`);
6400
+ if (f.mapCategory !== void 0) usage("--map-category is for --table, not --sections.");
6401
+ body["mode"] = "sections";
6402
+ const section = (f.sections ?? "").trim();
6403
+ if (section === "") usage("--sections needs a heading or its number in the list.");
6404
+ body["section"] = /^\d+$/.test(section) ? Number(section) : section;
6405
+ if (f.unit !== void 0) body["unit"] = oneOf("--unit", f.unit, ["bullets", "subheadings"]);
6406
+ if (f.keyPrefix !== void 0) body["keyPrefix"] = f.keyPrefix;
6407
+ if (f.priorityFrom !== void 0) body["priorityFrom"] = oneOf("--priority-from", f.priorityFrom, ["subheading"]);
6408
+ }
6409
+ return body;
6410
+ }
6411
+ function readMarkdown(cwd, file) {
6412
+ const target = (0, import_node_path4.resolve)(cwd, file);
6413
+ let size;
6414
+ try {
6415
+ const st = (0, import_node_fs7.statSync)(target);
6416
+ if (!st.isFile()) return usage(`${target} is not a file.`);
6417
+ size = st.size;
6418
+ } catch {
6419
+ return usage(`File not found: ${target}`);
6420
+ }
6421
+ if (size > MAX_MARKDOWN_BYTES) {
6422
+ return usage(`${target} is ${size} bytes; the platform reads at most ${MAX_MARKDOWN_BYTES} bytes (1 MiB). Nothing was sent. Split the file.`);
6423
+ }
6424
+ let text2;
6425
+ try {
6426
+ text2 = new TextDecoder("utf-8", { fatal: true }).decode((0, import_node_fs7.readFileSync)(target));
6427
+ } catch {
6428
+ return usage(`${target} is not valid UTF-8. Save it as UTF-8 and try again.`);
6429
+ }
6430
+ text2 = text2.replace(/^/, "");
6431
+ if (text2.trim() === "") usage(`${target} is empty.`);
6432
+ return text2;
6433
+ }
6434
+ function refusal(out, apiUrl, projectId) {
6435
+ const d = out.detail;
6436
+ const said = d === "" ? "" : ` (${d})`;
6437
+ switch (out.status) {
6438
+ case null:
6439
+ return new RequirementsImportError(`Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`, 1);
6440
+ case 401:
6441
+ return new RequirementsImportError(`Your API token was rejected (revoked or invalid). ${whereToGetAToken(apiUrl)} Then run "plune login".`, 2);
6442
+ case 403:
6443
+ return new RequirementsImportError(`The platform refused: ${d || "read-only member"}. Importing needs an owner or a member of the project.`, 1);
6444
+ case 404:
6445
+ return /^project '.*' not found$/.test(d) ? new RequirementsImportError(`Project ${projectId} was not found for this token. A token only works in the project it was minted in; pass --project <id> or use a token from that project.`, 1) : new RequirementsImportError(SUPPORT, 1);
6446
+ case 409:
6447
+ return new RequirementsImportError(`A requirement key was taken by another change while the import ran. Nothing was written; run it again${said}.`, 1);
6448
+ case 413:
6449
+ return new RequirementsImportError(`The platform refused the file as too large${said}. Nothing was written.`, 1);
6450
+ case 400:
6451
+ return new RequirementsImportError(`The platform refused the mapping${said}.`, 1);
6452
+ default:
6453
+ return new RequirementsImportError(`The platform answered HTTP ${out.status}${said}. Nothing was written.`, 1);
6454
+ }
6455
+ }
6456
+ async function resolveProject(client, explicit, apiUrl) {
6457
+ const given = (explicit ?? process.env["PLUNE_PROJECT"])?.trim();
6458
+ if (given !== void 0 && given !== "") return given;
6459
+ const out = await client.get("/v1/projects");
6460
+ if (!out.ok) throw refusal(out, apiUrl, "");
6461
+ const projects = out.body.projects ?? [];
6462
+ if (projects.length === 1 && projects[0] !== void 0) return projects[0].id;
6463
+ const list2 = projects.map((p) => `${p.id}${p.name ? ` (${p.name})` : ""}`).join(", ");
6464
+ return usage(
6465
+ projects.length === 0 ? "No project found for this token. Pass --project <id>." : `This account is in ${projects.length} projects (${list2}). A token only works in the project it was minted in: pass --project <id> (or set PLUNE_PROJECT).`
6466
+ );
6467
+ }
6468
+ async function handleRequirementsImport(deps) {
6469
+ const apiUrl = resolveApiUrl(deps.apiUrl);
6470
+ const mapping = buildImportBody(deps);
6471
+ const markdown = readMarkdown(deps.cwd ?? process.cwd(), deps.file);
6472
+ const token = (deps.loadToken ?? loadToken)();
6473
+ if (token === null) throw new RequirementsImportError('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.', 2);
6474
+ const client = createClient({ apiUrl, token, ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {} });
6475
+ const projectId = await resolveProject(client, deps.project, apiUrl);
6476
+ const base = `/v1/projects/${encodeURIComponent(projectId)}/requirements/import`;
6477
+ if (mapping === null) {
6478
+ const out2 = await client.post(`${base}/discover`, { markdown });
6479
+ if (!out2.ok) throw refusal(out2, apiUrl, projectId);
6480
+ return { step: "discover", result: out2.body };
6481
+ }
6482
+ const out = await client.post(base, { markdown, dryRun: deps.yes !== true, ...mapping });
6483
+ if (!out.ok) throw refusal(out, apiUrl, projectId);
6484
+ return { step: "import", report: out.body };
6485
+ }
6486
+ function formatDiscover(file, r) {
6487
+ const out = [`${file}: ${plural(r.tables.length, "table")}, ${plural(r.sections.length, "section")}`];
6488
+ if (r.frontmatter.keys.length > 0) out.push(`Frontmatter keys: ${r.frontmatter.keys.join(", ")}`);
6489
+ out.push("", "Tables");
6490
+ if (r.tables.length === 0) out.push(" (none)");
6491
+ for (const t of r.tables) {
6492
+ const mark = t.unreadable !== void 0 ? ` cannot be read: ${t.unreadable}` : t.looksLikeRequirements ? " <- looks like requirements" : "";
6493
+ out.push(` #${t.index} line ${t.line} ${t.heading === null ? "(no heading)" : `"${flat(t.heading)}"`} ${cut2(t.headers.map(flat).join(" | "), 100)} ${plural(t.rowCount, "row")}${mark}`);
6494
+ }
6495
+ out.push("", "Sections");
6496
+ if (r.sections.length === 0) out.push(" (none)");
6497
+ for (const s of r.sections) {
6498
+ out.push(` #${s.index} line ${s.line} ${"#".repeat(s.level)} ${flat(s.heading)} ${plural(s.bulletCount, "bullet")}, ${plural(s.subheadingCount, "sub-heading")}`);
6499
+ }
6500
+ if (r.truncated) out.push("", "The file has more headings or tables than are listed here.");
6501
+ out.push(
6502
+ "",
6503
+ "Pick one and map it (a dry run, nothing is written):",
6504
+ ` plune requirements import ${file} --table <n> --key-column <header> --title-column <header>`,
6505
+ ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`,
6506
+ "Several tables in one run, each with its own columns:",
6507
+ ` plune requirements import ${file} --table "<n>:key=<header>,text=<header>,fixed-category=functional" --table "<m>:key=<header>,text=<header>"`
6508
+ );
6509
+ return out.join("\n") + "\n";
6510
+ }
6511
+ function formatReport2(report) {
6512
+ const s = report.summary;
6513
+ const out = [`create ${s.create} \xB7 update ${s.update} \xB7 unchanged ${s.unchanged} \xB7 skip ${s.skip}`];
6514
+ if (report.emptyTemplate === true) {
6515
+ const said = report.message !== void 0 && !/empty template/i.test(report.message) ? ` ${flat(report.message)}` : "";
6516
+ out.push("", `this file is an empty template: every row is a placeholder, nothing to import.${said}`);
6517
+ }
6518
+ for (const action of ["create", "update", "skip", "unchanged"]) {
6519
+ const rows = report.items.filter((i) => i.action === action);
6520
+ if (rows.length === 0) continue;
6521
+ out.push("", `${action} (${rows.length})`);
6522
+ for (const i of rows.slice(0, SHOWN)) {
6523
+ const changes = i.changes !== void 0 && i.changes.length > 0 ? `; changes: ${i.changes.join(", ")}` : "";
6524
+ const notes = i.notes !== void 0 && i.notes.length > 0 ? `; note: ${i.notes.join("; ")}` : "";
6525
+ const from = i.table === void 0 ? "" : `table #${i.table}${i.tableHeading ? ` "${cut2(flat(i.tableHeading), 40)}"` : ""} `;
6526
+ out.push(` ${from}line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6527
+ }
6528
+ if (rows.length > SHOWN) out.push(` ... and ${rows.length - SHOWN} more (--json lists every row)`);
6529
+ }
6530
+ for (const [label, values] of [["Priority values", report.values.priority], ["Category values", report.values.category]]) {
6531
+ if (values.length === 0) continue;
6532
+ out.push("", label);
6533
+ for (const v of values.slice(0, SHOWN)) out.push(` ${v.value} x${v.count} -> ${v.mappedTo ?? "(no mapping: kept as written)"}`);
6534
+ }
6535
+ const n = report.notImported;
6536
+ if (n.tables.length + n.sections.length + n.frontmatterKeys.length > 0) {
6537
+ out.push("", "Not imported");
6538
+ for (const t of n.tables) out.push(` table #${t.index} ${t.heading === null ? "" : `"${flat(t.heading)}" `}(${cut2(t.headers.map(flat).join(" | "), 80)}; ${plural(t.rowCount, "row")})`);
6539
+ for (const x of n.sections) out.push(` section #${x.index} "${flat(x.heading)}" (${plural(x.bulletCount, "bullet")})`);
6540
+ if (n.frontmatterKeys.length > 0) out.push(` frontmatter keys: ${n.frontmatterKeys.join(", ")}`);
6541
+ }
6542
+ out.push("", report.applied ? `applied \u2014 ${plural(s.create, "requirement")} created, ${s.update} updated.` : "dry run \u2014 nothing was written; add --yes to apply");
6543
+ return out.join("\n") + "\n";
6544
+ }
6545
+ function reportRequirementsImportFailure(err, write) {
6546
+ if (!(err instanceof RequirementsImportError)) return null;
6547
+ write(err.message + "\n");
6548
+ return err.exitCode;
6549
+ }
6550
+ var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, TABLE_SPEC_KEYS, SPEC_HELP, SUPPORT, SHOWN, flat, cut2, plural;
6551
+ var init_requirements_import = __esm({
6552
+ "src/cli/commands/requirements-import.ts"() {
6553
+ "use strict";
6554
+ init_cjs_shims();
6555
+ import_node_fs7 = require("fs");
6556
+ import_node_path4 = require("path");
6557
+ init_client();
6558
+ init_api_url();
6559
+ init_credentials();
6560
+ MAX_MARKDOWN_BYTES = 1048576;
6561
+ RequirementsImportError = class extends Error {
6562
+ constructor(message, exitCode) {
6563
+ super(message);
6564
+ this.exitCode = exitCode;
6565
+ this.name = "RequirementsImportError";
6566
+ }
6567
+ exitCode;
6568
+ };
6569
+ usage = (message) => {
6570
+ throw new RequirementsImportError(message, 2);
6571
+ };
6572
+ COLUMN_FLAGS = [
6573
+ ["keyColumn", "--key-column", "keyColumn"],
6574
+ ["titleColumn", "--title-column", "titleColumn"],
6575
+ ["textColumn", "--text-column", "textColumn"],
6576
+ ["descriptionColumn", "--description-column", "descriptionColumn"],
6577
+ ["priorityColumn", "--priority-column", "priorityColumn"],
6578
+ ["categoryColumn", "--category-column", "categoryColumn"],
6579
+ ["typeColumn", "--type-column", "typeColumn"],
6580
+ ["statusColumn", "--status-column", "statusColumn"],
6581
+ ["criteriaColumn", "--criteria-column", "acceptanceCriteriaColumn"]
6582
+ ];
6583
+ SECTION_FLAGS = [
6584
+ ["unit", "--unit"],
6585
+ ["keyPrefix", "--key-prefix"],
6586
+ ["priorityFrom", "--priority-from"]
6587
+ ];
6588
+ oneOf = (flag2, value2, allowed) => allowed.includes(value2) ? value2 : usage(`${flag2} must be ${allowed.join(" or ")}, not "${value2}".`);
6589
+ TABLE_SPEC_KEYS = {
6590
+ key: "keyColumn",
6591
+ title: "titleColumn",
6592
+ text: "textColumn",
6593
+ description: "descriptionColumn",
6594
+ priority: "priorityColumn",
6595
+ category: "categoryColumn",
6596
+ type: "typeColumn",
6597
+ status: "statusColumn",
6598
+ criteria: "acceptanceCriteriaColumn",
6599
+ "fixed-category": "fixedCategory",
6600
+ "fixed-type": "fixedType"
6601
+ };
6602
+ SPEC_HELP = 'Write it like --table "3:key=ID,text=Requirement,fixed-category=functional".';
6603
+ SUPPORT = "This platform does not support requirements import yet. Nothing was sent to a project; try a newer Plune platform.";
6604
+ SHOWN = 50;
6605
+ flat = (s) => s.replace(/\s+/g, " ").trim();
6606
+ cut2 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
6607
+ plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
6608
+ }
6609
+ });
6610
+
6198
6611
  // src/cli.ts
6199
6612
  var cli_exports = {};
6200
6613
  __export(cli_exports, {
@@ -6204,11 +6617,11 @@ module.exports = __toCommonJS(cli_exports);
6204
6617
  init_cjs_shims();
6205
6618
  init_api_url();
6206
6619
  var import_commander = require("commander");
6207
- var import_node_fs7 = require("fs");
6208
- var import_node_path4 = require("path");
6620
+ var import_node_fs8 = require("fs");
6621
+ var import_node_path5 = require("path");
6209
6622
  var import_node_url = require("url");
6210
- var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6211
- var pkg = JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
6623
+ var __dirname = (0, import_node_path5.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6624
+ var pkg = JSON.parse((0, import_node_fs8.readFileSync)((0, import_node_path5.join)(__dirname, "..", "package.json"), "utf-8"));
6212
6625
  function toFormat(s) {
6213
6626
  return s === "json" || s === "markdown" ? s : "console";
6214
6627
  }
@@ -6217,7 +6630,7 @@ function shouldColor(outputPath) {
6217
6630
  }
6218
6631
  function writeReport(text2, outputPath) {
6219
6632
  if (outputPath !== void 0) {
6220
- (0, import_node_fs7.writeFileSync)(outputPath, text2);
6633
+ (0, import_node_fs8.writeFileSync)(outputPath, text2);
6221
6634
  } else {
6222
6635
  process.stdout.write(text2 + "\n");
6223
6636
  }
@@ -6262,7 +6675,7 @@ function createProgram() {
6262
6675
  program.hook("preAction", async (_program, action) => {
6263
6676
  const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
6264
6677
  const { config } = action.optsWithGlobals();
6265
- if (config !== void 0) loadEnv2((0, import_node_path4.dirname)((0, import_node_path4.resolve)(config)));
6678
+ if (config !== void 0) loadEnv2((0, import_node_path5.dirname)((0, import_node_path5.resolve)(config)));
6266
6679
  loadEnv2(process.cwd());
6267
6680
  });
6268
6681
  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(
@@ -6444,7 +6857,7 @@ function createProgram() {
6444
6857
  false
6445
6858
  ).option(
6446
6859
  "--upload-concurrency <n>",
6447
- "File uploads in flight at once, 1 to 32 (default 4; PLUNE_UPLOAD_CONCURRENCY sets it too). Files are announced and confirmed up to 100 to a request; a 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
6860
+ "File uploads in flight at once, 1 to 32 (default 16; PLUNE_UPLOAD_CONCURRENCY sets it too). Files are announced and confirmed up to 100 to a request; a 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
6448
6861
  (v) => {
6449
6862
  if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
6450
6863
  return Number(v);
@@ -6495,7 +6908,7 @@ function createProgram() {
6495
6908
  Promise.resolve().then(() => (init_report(), report_exports)),
6496
6909
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
6497
6910
  ]);
6498
- const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
6911
+ const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
6499
6912
  try {
6500
6913
  const result = handleReport2({ cwd });
6501
6914
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -6592,7 +7005,7 @@ function createProgram() {
6592
7005
  const globals = command.optsWithGlobals();
6593
7006
  const verbose = globals.verbose === true;
6594
7007
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
6595
- const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
7008
+ const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
6596
7009
  try {
6597
7010
  const { id, url, gate } = await handleSync2({
6598
7011
  cwd,
@@ -6663,13 +7076,13 @@ function createProgram() {
6663
7076
  "Send a Markdown document of test cases back to Plune (POST /v1/test-cases/markdown)"
6664
7077
  ).option("--dry-run", "Show what would change and write nothing", false).action(async (file, options, command) => {
6665
7078
  const verbose = command.optsWithGlobals().verbose === true;
6666
- const { handlePush: handlePush2, formatReport: formatReport2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
7079
+ const { handlePush: handlePush2, formatReport: formatReport3, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
6667
7080
  try {
6668
7081
  const report = await handlePush2({
6669
7082
  ...file !== void 0 ? { file } : {},
6670
7083
  dryRun: options.dryRun
6671
7084
  });
6672
- process.stdout.write(formatReport2(report));
7085
+ process.stdout.write(formatReport3(report));
6673
7086
  if (report.refused.length > 0) process.exitCode = 4;
6674
7087
  } catch (err) {
6675
7088
  const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
@@ -6681,6 +7094,33 @@ function createProgram() {
6681
7094
  failUnexpected(err, verbose);
6682
7095
  }
6683
7096
  });
7097
+ const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7098
+ requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7099
+ "Import requirements from a Markdown file. With no mapping flags it lists the tables and sections found; with them it is a dry run until --yes"
7100
+ ).option(
7101
+ "--table <n|spec>",
7102
+ 'Read the table with this number (from the list); repeat as --table "<n>:key=ID,text=Requirement,fixed-category=functional" for several tables',
7103
+ (value2, previous = []) => [...previous, value2]
7104
+ ).option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7105
+ const verbose = command.optsWithGlobals().verbose === true;
7106
+ const { handleRequirementsImport: handleRequirementsImport2, formatDiscover: formatDiscover2, formatReport: formatReport3, reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7107
+ try {
7108
+ const res = await handleRequirementsImport2({ file, ...options });
7109
+ if (options.json === true) {
7110
+ process.stdout.write(JSON.stringify(res.step === "discover" ? res.result : res.report, null, 2) + "\n");
7111
+ } else {
7112
+ process.stdout.write(res.step === "discover" ? formatDiscover2(file, res.result) : formatReport3(res.report));
7113
+ }
7114
+ } catch (err) {
7115
+ const code = reportRequirementsImportFailure2(err, (s) => process.stderr.write(s));
7116
+ if (code !== null) {
7117
+ maybeStack(err, verbose);
7118
+ process.exitCode = code;
7119
+ return;
7120
+ }
7121
+ failUnexpected(err, verbose);
7122
+ }
7123
+ });
6684
7124
  const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
6685
7125
  planCommand.command("grep").argument("<id>", "The plan").description(
6686
7126
  "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
@@ -6716,7 +7156,7 @@ function isMainModule() {
6716
7156
  const entry = process.argv[1];
6717
7157
  if (entry === void 0) return false;
6718
7158
  try {
6719
- return (0, import_node_fs7.realpathSync)(entry) === (0, import_node_fs7.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7159
+ return (0, import_node_fs8.realpathSync)(entry) === (0, import_node_fs8.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6720
7160
  } catch {
6721
7161
  return false;
6722
7162
  }