@plune-ai/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,7 +103,12 @@ provider API key is read from the environment based on `provider.type`:
103
103
  | `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). |
104
104
  | `plune logout` | Remove the saved token. |
105
105
  | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
106
- | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run, `--create` to offer unmatched tests to the review queue. |
106
+ | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
107
+ | `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--json` for one machine-readable line. |
108
+ | `plune run finish <id>` | Close a platform run. This is what the reporter tells you to do for a run it had to leave open. Flags: `--terminate` to record it as cut short, `--reason <text>`. |
109
+ | `plune run exec -- <command>` | Open a run, run the command inside it, close the run — and exit with whatever the command returned. Sets `PLUNE_SHARED_RUN` for you, so anything reporting inside joins that run. Flags: `--key <externalKey>`. |
110
+ | `plune run delete <id>` | Delete a run and everything it produced — its results and the review-queue entries it raised. Approved test cases and the audit log stay. **Recoverable for six months** (ask Plune to put it back), then gone for good. No prompt: it is your data, and this command belongs in scripts. |
111
+ | `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. Flags: `--file <path>`. |
107
112
  | `plune ingest [dir]` | Record a [Cairn](https://github.com/plune-ai/cairn) run in Plune. Omit `[dir]` for the newest run under `./runs`, or name the directory holding `report.json`. Generated cases arrive as **review proposals** — nothing is created until a person approves it. |
108
113
 
109
114
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
@@ -135,14 +140,30 @@ which reports as the run happens and needs no second step:
135
140
  reporter: [['list'], ['@plune-ai/playwright']],
136
141
  ```
137
142
 
138
- Sharded CI? Give every job the same `--key` (or `PLUNE_RUN`), and close the run with
139
- `plune run finish <id>` once they are all done.
143
+ ### Several jobs, one run
144
+
145
+ Two suites, or a sharded matrix, report into a single run when every job shares a key **and** knows
146
+ it is not the last one:
147
+
148
+ ```bash
149
+ PLUNE_SHARED_RUN=1 plune run import ./junit.xml --key "$GITHUB_RUN_ID"
150
+ PLUNE_SHARED_RUN=1 plune run import ./app/junit.xml --key "$GITHUB_RUN_ID"
151
+ plune run finish "$RUN_ID" # once, when they are all done
152
+ ```
153
+
154
+ `PLUNE_SHARED_RUN=1` is what stops a job closing a run its siblings are still reporting into; the
155
+ key alone says where the results go, not who ends the run. Leave it unset in the last job and that
156
+ job closes the run instead of the explicit `finish`.
157
+
158
+ `plune run exec` sets it for you — anything reporting inside it, this command included, joins
159
+ without closing.
140
160
 
141
161
  ## Optional: keep a history
142
162
 
143
- Everything above works with no account, no network, and no token — that does not change. If you
144
- also want run history, trends, and a shared dashboard, the three cloud commands push your local
145
- runs to the [Plune platform](https://plune.ai/platform):
163
+ Everything above works with no account, no network, and no token — that does not change. `run`,
164
+ `report`, `diff` and `init` never open a socket, and that is the half this promise is about. If you
165
+ also want run history, trends, and a shared dashboard, the commands below push your local runs to
166
+ the [Plune platform](https://plune.ai/platform):
146
167
 
147
168
  ```bash
148
169
  plune login # paste the API token from your platform settings page
package/dist/cli.cjs CHANGED
@@ -2210,7 +2210,7 @@ function createClient(opts) {
2210
2210
  const wait = opts.wait ?? sleep;
2211
2211
  const base = opts.apiUrl.replace(/\/+$/, "");
2212
2212
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2213
- async function post(path12, body) {
2213
+ async function send(method, path12, body) {
2214
2214
  let last = {
2215
2215
  kind: "unavailable",
2216
2216
  status: null,
@@ -2220,16 +2220,22 @@ function createClient(opts) {
2220
2220
  let res;
2221
2221
  try {
2222
2222
  res = await doFetch(`${base}${path12}`, {
2223
- method: "POST",
2223
+ method,
2224
2224
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2225
- body: JSON.stringify(body)
2225
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
2226
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
2227
+ // explain.
2228
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
2226
2229
  });
2227
2230
  } catch (err) {
2228
2231
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
2229
2232
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
2230
2233
  continue;
2231
2234
  }
2232
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
2235
+ if (res.ok) {
2236
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
2237
+ return { ok: true, status: res.status, body: await res.json() };
2238
+ }
2233
2239
  const detail = scrub(await detailOf(res));
2234
2240
  const kind = classify3(res.status);
2235
2241
  last = { kind, status: res.status, detail };
@@ -2240,7 +2246,10 @@ function createClient(opts) {
2240
2246
  }
2241
2247
  return { ok: false, ...last };
2242
2248
  }
2243
- return { post };
2249
+ return {
2250
+ post: (path12, body) => send("POST", path12, body),
2251
+ del: (path12) => send("DELETE", path12)
2252
+ };
2244
2253
  }
2245
2254
  var RETRY_DELAYS_MS, sleep;
2246
2255
  var init_client = __esm({
@@ -2337,6 +2346,16 @@ var init_fallback = __esm({
2337
2346
  });
2338
2347
 
2339
2348
  // src/reporter-core/session.ts
2349
+ function boundKey(key) {
2350
+ if (key.value.length <= KEY_MAX) return key;
2351
+ const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
2352
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
2353
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
2354
+ return { ...key, value: `${head}#${digest}` };
2355
+ }
2356
+ function boundTitle(title) {
2357
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
2358
+ }
2340
2359
  function chunk(items, size) {
2341
2360
  const out = [];
2342
2361
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -2350,7 +2369,7 @@ function warnIfDropped(sent, run, log) {
2350
2369
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2351
2370
  );
2352
2371
  }
2353
- async function startRun(passed, expected) {
2372
+ async function startRun(passed, declared) {
2354
2373
  const env = readEnv();
2355
2374
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2356
2375
  const cfg = { ...env.config, ...stated };
@@ -2373,6 +2392,8 @@ async function startRun(passed, expected) {
2373
2392
  rejected: 0,
2374
2393
  unresolved: 0,
2375
2394
  offered: 0,
2395
+ created: 0,
2396
+ unoffered: 0,
2376
2397
  deferred: 0
2377
2398
  };
2378
2399
  const resolved = /* @__PURE__ */ new Map();
@@ -2382,6 +2403,22 @@ async function startRun(passed, expected) {
2382
2403
  let joined = false;
2383
2404
  let offline = token === "";
2384
2405
  let done = false;
2406
+ const shortened = /* @__PURE__ */ new Set();
2407
+ function bound(keys) {
2408
+ return keys.map((key) => {
2409
+ const fit = boundKey(key);
2410
+ if (fit !== key && !shortened.has(key.value)) {
2411
+ shortened.add(key.value);
2412
+ if (shortened.size === 1) {
2413
+ log(
2414
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
2415
+ );
2416
+ }
2417
+ }
2418
+ return fit;
2419
+ });
2420
+ }
2421
+ const expected = declared?.map(bound);
2385
2422
  if (offline) {
2386
2423
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2387
2424
  }
@@ -2409,9 +2446,9 @@ async function startRun(passed, expected) {
2409
2446
  }
2410
2447
  if (!offline) {
2411
2448
  if (expected !== void 0) await resolveKeys(expected.flat());
2412
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
2413
- const configuration = declared.length === 0 ? void 0 : {
2414
- expected: declared.flatMap((keys) => {
2449
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
2450
+ const configuration = stated2.length === 0 ? void 0 : {
2451
+ expected: stated2.flatMap((keys) => {
2415
2452
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2416
2453
  if (typeof hit === "string") return [{ testCaseId: hit }];
2417
2454
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -2455,9 +2492,11 @@ async function startRun(passed, expected) {
2455
2492
  if (!offerDiscovered) return;
2456
2493
  if (result.title === void 0 || result.specRef === void 0) return;
2457
2494
  if (result.keys.length === 0) return;
2495
+ const title = boundTitle(result.title);
2496
+ if (title !== result.title) shortened.add(result.title);
2458
2497
  discoveries.set(result.keys[0].value, {
2459
2498
  keys: result.keys,
2460
- title: result.title,
2499
+ title,
2461
2500
  source: result.source,
2462
2501
  specRef: result.specRef,
2463
2502
  rawStatus: result.rawStatus
@@ -2465,16 +2504,21 @@ async function startRun(passed, expected) {
2465
2504
  }
2466
2505
  async function offer() {
2467
2506
  if (discoveries.size === 0 || offline || runId === null) return;
2468
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
2507
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
2508
+ for (const [index, batch] of batches.entries()) {
2469
2509
  const out = await client.post("/v1/review-items/discovered", {
2470
2510
  discovered: batch,
2471
2511
  runId
2472
2512
  });
2473
2513
  if (!out.ok) {
2474
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
2514
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
2515
+ log(
2516
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
2517
+ );
2475
2518
  return;
2476
2519
  }
2477
2520
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2521
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
2478
2522
  }
2479
2523
  }
2480
2524
  async function flush() {
@@ -2501,7 +2545,10 @@ async function startRun(passed, expected) {
2501
2545
  }
2502
2546
  }
2503
2547
  if (unanswered.length > 0) defer(unanswered);
2504
- if (submissions.length === 0) return;
2548
+ if (submissions.length === 0) {
2549
+ progress();
2550
+ return;
2551
+ }
2505
2552
  const out = await client.post(
2506
2553
  `/v1/runs/${runId}/results`,
2507
2554
  { results: submissions }
@@ -2511,6 +2558,7 @@ async function startRun(passed, expected) {
2511
2558
  stats.duplicate += out.body.counts.duplicate ?? 0;
2512
2559
  stats.conflict += out.body.counts.conflict ?? 0;
2513
2560
  stats.rejected += out.body.counts.rejected ?? 0;
2561
+ progress();
2514
2562
  return;
2515
2563
  }
2516
2564
  if (out.kind === "auth") {
@@ -2523,6 +2571,12 @@ async function startRun(passed, expected) {
2523
2571
  }
2524
2572
  defer(submissions);
2525
2573
  }
2574
+ function progress() {
2575
+ const total = expected?.length ?? 0;
2576
+ if (total <= batchSize) return;
2577
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
2578
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
2579
+ }
2526
2580
  function summarise() {
2527
2581
  const parts = [`${stats.accepted} accepted`];
2528
2582
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
@@ -2530,7 +2584,9 @@ async function startRun(passed, expected) {
2530
2584
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2531
2585
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2532
2586
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2587
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2533
2588
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2589
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
2534
2590
  log(`plune: ${parts.join(" \xB7 ")}`);
2535
2591
  }
2536
2592
  return {
@@ -2544,7 +2600,7 @@ async function startRun(passed, expected) {
2544
2600
  return stats;
2545
2601
  },
2546
2602
  async add(result) {
2547
- buffer.push(result);
2603
+ buffer.push({ ...result, keys: bound(result.keys) });
2548
2604
  if (buffer.length >= batchSize) await flush();
2549
2605
  },
2550
2606
  flush,
@@ -2574,11 +2630,12 @@ async function startRun(passed, expected) {
2574
2630
  }
2575
2631
  };
2576
2632
  }
2577
- var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2633
+ var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX;
2578
2634
  var init_session = __esm({
2579
2635
  "src/reporter-core/session.ts"() {
2580
2636
  "use strict";
2581
2637
  init_cjs_shims();
2638
+ import_node_crypto2 = require("crypto");
2582
2639
  init_api_url();
2583
2640
  init_credentials();
2584
2641
  init_client();
@@ -2589,6 +2646,8 @@ var init_session = __esm({
2589
2646
  DISCOVER_MAX = 500;
2590
2647
  BATCH_DEFAULT = 100;
2591
2648
  EXPECTED_MAX = 1e4;
2649
+ KEY_MAX = 1024;
2650
+ TITLE_MAX = 300;
2592
2651
  }
2593
2652
  });
2594
2653
 
@@ -2597,6 +2656,7 @@ var run_lifecycle_exports = {};
2597
2656
  __export(run_lifecycle_exports, {
2598
2657
  NoTokenError: () => NoTokenError,
2599
2658
  RunCommandError: () => RunCommandError,
2659
+ handleRunDelete: () => handleRunDelete,
2600
2660
  handleRunExec: () => handleRunExec,
2601
2661
  handleRunFinish: () => handleRunFinish,
2602
2662
  handleRunReport: () => handleRunReport,
@@ -2660,6 +2720,22 @@ async function handleRunFinish(id, options = {}) {
2660
2720
  }
2661
2721
  write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
2662
2722
  }
2723
+ async function handleRunDelete(id, options = {}) {
2724
+ const { client, write } = connect(options);
2725
+ const out = await client.del(`/v1/runs/${encodeURIComponent(id)}`);
2726
+ if (!out.ok) {
2727
+ throw new RunCommandError(
2728
+ out.status === 404 ? (
2729
+ // Three causes, one answer, and the message names all three because the platform will not:
2730
+ // a 404 that distinguished "not yours" from "not there" would tell a stranger which ids
2731
+ // exist. The person holding the terminal is better served by the list than by a guess.
2732
+ `No run '${id}' \u2014 check the id, whether the token belongs to the same project, or whether it is already deleted.`
2733
+ ) : `Could not delete run ${id} \u2014 ${out.detail || out.kind}.`
2734
+ );
2735
+ }
2736
+ write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
2737
+ write("Recoverable for six months \u2014 ask Plune to put it back.");
2738
+ }
2663
2739
  async function handleRunExec(options) {
2664
2740
  const [command, ...args] = options.argv;
2665
2741
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
@@ -3180,7 +3256,8 @@ async function handleRunImport(options) {
3180
3256
  results.map((r) => r.keys)
3181
3257
  );
3182
3258
  for (const result2 of results) await session.add(result2);
3183
- await session.finish();
3259
+ if (env.keepOpen) await session.leaveOpen();
3260
+ else await session.finish();
3184
3261
  const stats = session.stats;
3185
3262
  const result = {
3186
3263
  runId: session.runId,
@@ -3192,6 +3269,8 @@ async function handleRunImport(options) {
3192
3269
  rejected: stats.rejected,
3193
3270
  unresolved: stats.unresolved,
3194
3271
  offered: stats.offered,
3272
+ created: stats.created,
3273
+ unoffered: stats.unoffered,
3195
3274
  deferred: stats.deferred,
3196
3275
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3197
3276
  offering: options.create === true
@@ -3205,7 +3284,11 @@ function runUrl2(apiUrl, id) {
3205
3284
  }
3206
3285
  function describe(result, apiUrl) {
3207
3286
  const lines = [
3208
- `Imported ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
3287
+ // `Read`, not `Imported`: the verb describes the FILE, which was read whatever happened next.
3288
+ // «Imported 555 result(s): 0 accepted, 0 already there, 0 unmatched» was a sentence whose two
3289
+ // halves disagreed, and readers stop at the verb — a run that reached nobody read as one that
3290
+ // worked (#622).
3291
+ `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
3209
3292
  ];
3210
3293
  if (result.conflict > 0 || result.rejected > 0) {
3211
3294
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
@@ -3215,13 +3298,23 @@ function describe(result, apiUrl) {
3215
3298
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
3216
3299
  );
3217
3300
  }
3301
+ if (result.created > 0) {
3302
+ lines.push(
3303
+ `${result.created} unknown test(s) became cases without review \u2014 this project trusts the source. Change that under Settings \u2192 Trusted sources.`
3304
+ );
3305
+ }
3218
3306
  if (result.offered > 0) {
3219
3307
  lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
3220
- } else if (result.unresolved > 0 && !result.offering) {
3308
+ } else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
3221
3309
  lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
3222
- } else if (result.unresolved > 0) {
3310
+ } else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
3223
3311
  lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
3224
3312
  }
3313
+ if (result.unoffered > 0) {
3314
+ lines.push(
3315
+ `${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.`
3316
+ );
3317
+ }
3225
3318
  if (result.unlocatable > 0) {
3226
3319
  lines.push(
3227
3320
  `${result.unlocatable} test(s) carry no file or class in the report, so they cannot be offered for review \u2014 a reviewer would have nowhere to look.`
@@ -4402,6 +4495,14 @@ function createProgram() {
4402
4495
  await failRunCommand(err, verboseOf(command));
4403
4496
  }
4404
4497
  });
4498
+ runCommand.command("delete").argument("<id>", "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL").description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
4499
+ try {
4500
+ const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4501
+ await handleRunDelete2(id);
4502
+ } catch (err) {
4503
+ await failRunCommand(err, verboseOf(command));
4504
+ }
4505
+ });
4405
4506
  runCommand.command("report").description("Send what the reporter could not \u2014 replays .plune/pending-results.jsonl").option("--file <path>", "The fallback file to replay").action(async (options, command) => {
4406
4507
  try {
4407
4508
  const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
@@ -41,6 +41,9 @@ __export(reporter_core_exports, {
41
41
  });
42
42
  module.exports = __toCommonJS(reporter_core_exports);
43
43
 
44
+ // src/reporter-core/session.ts
45
+ var import_node_crypto = require("crypto");
46
+
44
47
  // src/cli/api-url.ts
45
48
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
46
49
  function resolveApiUrl(explicit) {
@@ -94,7 +97,7 @@ function createClient(opts) {
94
97
  const wait = opts.wait ?? sleep;
95
98
  const base = opts.apiUrl.replace(/\/+$/, "");
96
99
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
97
- async function post(path2, body) {
100
+ async function send(method, path2, body) {
98
101
  let last = {
99
102
  kind: "unavailable",
100
103
  status: null,
@@ -104,16 +107,22 @@ function createClient(opts) {
104
107
  let res;
105
108
  try {
106
109
  res = await doFetch(`${base}${path2}`, {
107
- method: "POST",
110
+ method,
108
111
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
109
- body: JSON.stringify(body)
112
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
113
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
114
+ // explain.
115
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
110
116
  });
111
117
  } catch (err) {
112
118
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
113
119
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
114
120
  continue;
115
121
  }
116
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
122
+ if (res.ok) {
123
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
124
+ return { ok: true, status: res.status, body: await res.json() };
125
+ }
117
126
  const detail = scrub(await detailOf(res));
118
127
  const kind = classify(res.status);
119
128
  last = { kind, status: res.status, detail };
@@ -124,7 +133,10 @@ function createClient(opts) {
124
133
  }
125
134
  return { ok: false, ...last };
126
135
  }
127
- return { post };
136
+ return {
137
+ post: (path2, body) => send("POST", path2, body),
138
+ del: (path2) => send("DELETE", path2)
139
+ };
128
140
  }
129
141
 
130
142
  // src/reporter-core/env.ts
@@ -203,6 +215,18 @@ var BATCH_MAX = 500;
203
215
  var DISCOVER_MAX = 500;
204
216
  var BATCH_DEFAULT = 100;
205
217
  var EXPECTED_MAX = 1e4;
218
+ var KEY_MAX = 1024;
219
+ var TITLE_MAX = 300;
220
+ function boundKey(key) {
221
+ if (key.value.length <= KEY_MAX) return key;
222
+ const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
223
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
224
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
225
+ return { ...key, value: `${head}#${digest}` };
226
+ }
227
+ function boundTitle(title) {
228
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
229
+ }
206
230
  function chunk(items, size) {
207
231
  const out = [];
208
232
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -216,7 +240,7 @@ function warnIfDropped(sent, run, log) {
216
240
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
217
241
  );
218
242
  }
219
- async function startRun(passed, expected) {
243
+ async function startRun(passed, declared) {
220
244
  const env = readEnv();
221
245
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
222
246
  const cfg = { ...env.config, ...stated };
@@ -239,6 +263,8 @@ async function startRun(passed, expected) {
239
263
  rejected: 0,
240
264
  unresolved: 0,
241
265
  offered: 0,
266
+ created: 0,
267
+ unoffered: 0,
242
268
  deferred: 0
243
269
  };
244
270
  const resolved = /* @__PURE__ */ new Map();
@@ -248,6 +274,22 @@ async function startRun(passed, expected) {
248
274
  let joined = false;
249
275
  let offline = token === "";
250
276
  let done = false;
277
+ const shortened = /* @__PURE__ */ new Set();
278
+ function bound(keys) {
279
+ return keys.map((key) => {
280
+ const fit = boundKey(key);
281
+ if (fit !== key && !shortened.has(key.value)) {
282
+ shortened.add(key.value);
283
+ if (shortened.size === 1) {
284
+ log(
285
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
286
+ );
287
+ }
288
+ }
289
+ return fit;
290
+ });
291
+ }
292
+ const expected = declared?.map(bound);
251
293
  if (offline) {
252
294
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
253
295
  }
@@ -275,9 +317,9 @@ async function startRun(passed, expected) {
275
317
  }
276
318
  if (!offline) {
277
319
  if (expected !== void 0) await resolveKeys(expected.flat());
278
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
279
- const configuration = declared.length === 0 ? void 0 : {
280
- expected: declared.flatMap((keys) => {
320
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
321
+ const configuration = stated2.length === 0 ? void 0 : {
322
+ expected: stated2.flatMap((keys) => {
281
323
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
282
324
  if (typeof hit === "string") return [{ testCaseId: hit }];
283
325
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -321,9 +363,11 @@ async function startRun(passed, expected) {
321
363
  if (!offerDiscovered) return;
322
364
  if (result.title === void 0 || result.specRef === void 0) return;
323
365
  if (result.keys.length === 0) return;
366
+ const title = boundTitle(result.title);
367
+ if (title !== result.title) shortened.add(result.title);
324
368
  discoveries.set(result.keys[0].value, {
325
369
  keys: result.keys,
326
- title: result.title,
370
+ title,
327
371
  source: result.source,
328
372
  specRef: result.specRef,
329
373
  rawStatus: result.rawStatus
@@ -331,16 +375,21 @@ async function startRun(passed, expected) {
331
375
  }
332
376
  async function offer() {
333
377
  if (discoveries.size === 0 || offline || runId === null) return;
334
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
378
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
379
+ for (const [index, batch] of batches.entries()) {
335
380
  const out = await client.post("/v1/review-items/discovered", {
336
381
  discovered: batch,
337
382
  runId
338
383
  });
339
384
  if (!out.ok) {
340
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
385
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
386
+ log(
387
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
388
+ );
341
389
  return;
342
390
  }
343
391
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
392
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
344
393
  }
345
394
  }
346
395
  async function flush() {
@@ -367,7 +416,10 @@ async function startRun(passed, expected) {
367
416
  }
368
417
  }
369
418
  if (unanswered.length > 0) defer(unanswered);
370
- if (submissions.length === 0) return;
419
+ if (submissions.length === 0) {
420
+ progress();
421
+ return;
422
+ }
371
423
  const out = await client.post(
372
424
  `/v1/runs/${runId}/results`,
373
425
  { results: submissions }
@@ -377,6 +429,7 @@ async function startRun(passed, expected) {
377
429
  stats.duplicate += out.body.counts.duplicate ?? 0;
378
430
  stats.conflict += out.body.counts.conflict ?? 0;
379
431
  stats.rejected += out.body.counts.rejected ?? 0;
432
+ progress();
380
433
  return;
381
434
  }
382
435
  if (out.kind === "auth") {
@@ -389,6 +442,12 @@ async function startRun(passed, expected) {
389
442
  }
390
443
  defer(submissions);
391
444
  }
445
+ function progress() {
446
+ const total = expected?.length ?? 0;
447
+ if (total <= batchSize) return;
448
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
449
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
450
+ }
392
451
  function summarise() {
393
452
  const parts = [`${stats.accepted} accepted`];
394
453
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
@@ -396,7 +455,9 @@ async function startRun(passed, expected) {
396
455
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
397
456
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
398
457
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
458
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
399
459
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
460
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
400
461
  log(`plune: ${parts.join(" \xB7 ")}`);
401
462
  }
402
463
  return {
@@ -410,7 +471,7 @@ async function startRun(passed, expected) {
410
471
  return stats;
411
472
  },
412
473
  async add(result) {
413
- buffer.push(result);
474
+ buffer.push({ ...result, keys: bound(result.keys) });
414
475
  if (buffer.length >= batchSize) await flush();
415
476
  },
416
477
  flush,
@@ -442,9 +503,9 @@ async function startRun(passed, expected) {
442
503
  }
443
504
 
444
505
  // src/util/hash.ts
445
- var import_node_crypto = require("crypto");
506
+ var import_node_crypto2 = require("crypto");
446
507
  function sha256(input) {
447
- return (0, import_node_crypto.createHash)("sha256").update(input, "utf8").digest("hex");
508
+ return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
448
509
  }
449
510
 
450
511
  // src/reporter-core/result-key.ts
@@ -139,6 +139,24 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Offered tests the platform turned into cases outright, because the project trusts this source
144
+ * (platform ADR 0030).
145
+ *
146
+ * Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
147
+ * means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
148
+ * in your project". Folding them together would hide the larger of the two facts under a word
149
+ * that asks for attention nobody owes.
150
+ */
151
+ created: number;
152
+ /**
153
+ * Unmatched tests that were NOT offered, because the queue would not take them (#627).
154
+ *
155
+ * Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
156
+ * test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
157
+ * queue at capacity means "empty it and import again"; nothing else here means that.
158
+ */
159
+ unoffered: number;
142
160
  /** Results written to the fallback file instead of the platform. */
143
161
  deferred: number;
144
162
  }
@@ -197,7 +215,7 @@ interface RunSession {
197
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
198
216
  leaveOpen(): Promise<void>;
199
217
  }
200
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
201
219
 
202
220
  /**
203
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -333,6 +351,13 @@ interface ClientOptions {
333
351
  }
334
352
  interface PlatformClient {
335
353
  post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
354
+ /**
355
+ * A DELETE, through the same retries, the same classification and the same token scrubbing as a
356
+ * POST. It is here rather than as a bare `fetch` in the one command that needs it because all
357
+ * three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
358
+ * a proxy error page that echoes the request would put a token in a CI log.
359
+ */
360
+ del<T>(path: string): Promise<ClientOutcome<T>>;
336
361
  }
337
362
  declare function createClient(opts: ClientOptions): PlatformClient;
338
363
 
@@ -139,6 +139,24 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Offered tests the platform turned into cases outright, because the project trusts this source
144
+ * (platform ADR 0030).
145
+ *
146
+ * Its own number rather than part of `offered`: these are the opposite of work to do. `offered`
147
+ * means "somebody has to look at this"; `created` means "nobody has to, and here is what appeared
148
+ * in your project". Folding them together would hide the larger of the two facts under a word
149
+ * that asks for attention nobody owes.
150
+ */
151
+ created: number;
152
+ /**
153
+ * Unmatched tests that were NOT offered, because the queue would not take them (#627).
154
+ *
155
+ * Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
156
+ * test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
157
+ * queue at capacity means "empty it and import again"; nothing else here means that.
158
+ */
159
+ unoffered: number;
142
160
  /** Results written to the fallback file instead of the platform. */
143
161
  deferred: number;
144
162
  }
@@ -197,7 +215,7 @@ interface RunSession {
197
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
198
216
  leaveOpen(): Promise<void>;
199
217
  }
200
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
201
219
 
202
220
  /**
203
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -333,6 +351,13 @@ interface ClientOptions {
333
351
  }
334
352
  interface PlatformClient {
335
353
  post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
354
+ /**
355
+ * A DELETE, through the same retries, the same classification and the same token scrubbing as a
356
+ * POST. It is here rather than as a bare `fetch` in the one command that needs it because all
357
+ * three of those matter at least as much for a delete: an unclassified 404 reads as an outage, and
358
+ * a proxy error page that echoes the request would put a token in a CI log.
359
+ */
360
+ del<T>(path: string): Promise<ClientOutcome<T>>;
336
361
  }
337
362
  declare function createClient(opts: ClientOptions): PlatformClient;
338
363
 
@@ -1,3 +1,6 @@
1
+ // src/reporter-core/session.ts
2
+ import { createHash } from "crypto";
3
+
1
4
  // src/cli/api-url.ts
2
5
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
3
6
  function resolveApiUrl(explicit) {
@@ -51,7 +54,7 @@ function createClient(opts) {
51
54
  const wait = opts.wait ?? sleep;
52
55
  const base = opts.apiUrl.replace(/\/+$/, "");
53
56
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
54
- async function post(path2, body) {
57
+ async function send(method, path2, body) {
55
58
  let last = {
56
59
  kind: "unavailable",
57
60
  status: null,
@@ -61,16 +64,22 @@ function createClient(opts) {
61
64
  let res;
62
65
  try {
63
66
  res = await doFetch(`${base}${path2}`, {
64
- method: "POST",
67
+ method,
65
68
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
66
- body: JSON.stringify(body)
69
+ // A DELETE carries none. Sending `"undefined"` as a body is what a naive
70
+ // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
71
+ // explain.
72
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
67
73
  });
68
74
  } catch (err) {
69
75
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
70
76
  if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
71
77
  continue;
72
78
  }
73
- if (res.ok) return { ok: true, status: res.status, body: await res.json() };
79
+ if (res.ok) {
80
+ if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
81
+ return { ok: true, status: res.status, body: await res.json() };
82
+ }
74
83
  const detail = scrub(await detailOf(res));
75
84
  const kind = classify(res.status);
76
85
  last = { kind, status: res.status, detail };
@@ -81,7 +90,10 @@ function createClient(opts) {
81
90
  }
82
91
  return { ok: false, ...last };
83
92
  }
84
- return { post };
93
+ return {
94
+ post: (path2, body) => send("POST", path2, body),
95
+ del: (path2) => send("DELETE", path2)
96
+ };
85
97
  }
86
98
 
87
99
  // src/reporter-core/env.ts
@@ -160,6 +172,18 @@ var BATCH_MAX = 500;
160
172
  var DISCOVER_MAX = 500;
161
173
  var BATCH_DEFAULT = 100;
162
174
  var EXPECTED_MAX = 1e4;
175
+ var KEY_MAX = 1024;
176
+ var TITLE_MAX = 300;
177
+ function boundKey(key) {
178
+ if (key.value.length <= KEY_MAX) return key;
179
+ const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
180
+ let head = key.value.slice(0, KEY_MAX - digest.length - 1);
181
+ if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
182
+ return { ...key, value: `${head}#${digest}` };
183
+ }
184
+ function boundTitle(title) {
185
+ return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
186
+ }
163
187
  function chunk(items, size) {
164
188
  const out = [];
165
189
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -173,7 +197,7 @@ function warnIfDropped(sent, run, log) {
173
197
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
174
198
  );
175
199
  }
176
- async function startRun(passed, expected) {
200
+ async function startRun(passed, declared) {
177
201
  const env = readEnv();
178
202
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
179
203
  const cfg = { ...env.config, ...stated };
@@ -196,6 +220,8 @@ async function startRun(passed, expected) {
196
220
  rejected: 0,
197
221
  unresolved: 0,
198
222
  offered: 0,
223
+ created: 0,
224
+ unoffered: 0,
199
225
  deferred: 0
200
226
  };
201
227
  const resolved = /* @__PURE__ */ new Map();
@@ -205,6 +231,22 @@ async function startRun(passed, expected) {
205
231
  let joined = false;
206
232
  let offline = token === "";
207
233
  let done = false;
234
+ const shortened = /* @__PURE__ */ new Set();
235
+ function bound(keys) {
236
+ return keys.map((key) => {
237
+ const fit = boundKey(key);
238
+ if (fit !== key && !shortened.has(key.value)) {
239
+ shortened.add(key.value);
240
+ if (shortened.size === 1) {
241
+ log(
242
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
243
+ );
244
+ }
245
+ }
246
+ return fit;
247
+ });
248
+ }
249
+ const expected = declared?.map(bound);
208
250
  if (offline) {
209
251
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
210
252
  }
@@ -232,9 +274,9 @@ async function startRun(passed, expected) {
232
274
  }
233
275
  if (!offline) {
234
276
  if (expected !== void 0) await resolveKeys(expected.flat());
235
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
236
- const configuration = declared.length === 0 ? void 0 : {
237
- expected: declared.flatMap((keys) => {
277
+ const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
278
+ const configuration = stated2.length === 0 ? void 0 : {
279
+ expected: stated2.flatMap((keys) => {
238
280
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
239
281
  if (typeof hit === "string") return [{ testCaseId: hit }];
240
282
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -278,9 +320,11 @@ async function startRun(passed, expected) {
278
320
  if (!offerDiscovered) return;
279
321
  if (result.title === void 0 || result.specRef === void 0) return;
280
322
  if (result.keys.length === 0) return;
323
+ const title = boundTitle(result.title);
324
+ if (title !== result.title) shortened.add(result.title);
281
325
  discoveries.set(result.keys[0].value, {
282
326
  keys: result.keys,
283
- title: result.title,
327
+ title,
284
328
  source: result.source,
285
329
  specRef: result.specRef,
286
330
  rawStatus: result.rawStatus
@@ -288,16 +332,21 @@ async function startRun(passed, expected) {
288
332
  }
289
333
  async function offer() {
290
334
  if (discoveries.size === 0 || offline || runId === null) return;
291
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
335
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
336
+ for (const [index, batch] of batches.entries()) {
292
337
  const out = await client.post("/v1/review-items/discovered", {
293
338
  discovered: batch,
294
339
  runId
295
340
  });
296
341
  if (!out.ok) {
297
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
342
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
343
+ log(
344
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
345
+ );
298
346
  return;
299
347
  }
300
348
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
349
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
301
350
  }
302
351
  }
303
352
  async function flush() {
@@ -324,7 +373,10 @@ async function startRun(passed, expected) {
324
373
  }
325
374
  }
326
375
  if (unanswered.length > 0) defer(unanswered);
327
- if (submissions.length === 0) return;
376
+ if (submissions.length === 0) {
377
+ progress();
378
+ return;
379
+ }
328
380
  const out = await client.post(
329
381
  `/v1/runs/${runId}/results`,
330
382
  { results: submissions }
@@ -334,6 +386,7 @@ async function startRun(passed, expected) {
334
386
  stats.duplicate += out.body.counts.duplicate ?? 0;
335
387
  stats.conflict += out.body.counts.conflict ?? 0;
336
388
  stats.rejected += out.body.counts.rejected ?? 0;
389
+ progress();
337
390
  return;
338
391
  }
339
392
  if (out.kind === "auth") {
@@ -346,6 +399,12 @@ async function startRun(passed, expected) {
346
399
  }
347
400
  defer(submissions);
348
401
  }
402
+ function progress() {
403
+ const total = expected?.length ?? 0;
404
+ if (total <= batchSize) return;
405
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
406
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
407
+ }
349
408
  function summarise() {
350
409
  const parts = [`${stats.accepted} accepted`];
351
410
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
@@ -353,7 +412,9 @@ async function startRun(passed, expected) {
353
412
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
354
413
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
355
414
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
415
+ if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
356
416
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
417
+ if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
357
418
  log(`plune: ${parts.join(" \xB7 ")}`);
358
419
  }
359
420
  return {
@@ -367,7 +428,7 @@ async function startRun(passed, expected) {
367
428
  return stats;
368
429
  },
369
430
  async add(result) {
370
- buffer.push(result);
431
+ buffer.push({ ...result, keys: bound(result.keys) });
371
432
  if (buffer.length >= batchSize) await flush();
372
433
  },
373
434
  flush,
@@ -399,9 +460,9 @@ async function startRun(passed, expected) {
399
460
  }
400
461
 
401
462
  // src/util/hash.ts
402
- import { createHash } from "crypto";
463
+ import { createHash as createHash2 } from "crypto";
403
464
  function sha256(input) {
404
- return createHash("sha256").update(input, "utf8").digest("hex");
465
+ return createHash2("sha256").update(input, "utf8").digest("hex");
405
466
  }
406
467
 
407
468
  // src/reporter-core/result-key.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",