@plune-ai/cli 0.9.1 → 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
@@ -104,6 +104,11 @@ provider API key is read from the environment based on `provider.type`:
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
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`.
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,7 @@ async function startRun(passed, expected) {
2373
2392
  rejected: 0,
2374
2393
  unresolved: 0,
2375
2394
  offered: 0,
2395
+ created: 0,
2376
2396
  unoffered: 0,
2377
2397
  deferred: 0
2378
2398
  };
@@ -2383,6 +2403,22 @@ async function startRun(passed, expected) {
2383
2403
  let joined = false;
2384
2404
  let offline = token === "";
2385
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);
2386
2422
  if (offline) {
2387
2423
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2388
2424
  }
@@ -2410,9 +2446,9 @@ async function startRun(passed, expected) {
2410
2446
  }
2411
2447
  if (!offline) {
2412
2448
  if (expected !== void 0) await resolveKeys(expected.flat());
2413
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
2414
- const configuration = declared.length === 0 ? void 0 : {
2415
- 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) => {
2416
2452
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2417
2453
  if (typeof hit === "string") return [{ testCaseId: hit }];
2418
2454
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -2456,9 +2492,11 @@ async function startRun(passed, expected) {
2456
2492
  if (!offerDiscovered) return;
2457
2493
  if (result.title === void 0 || result.specRef === void 0) return;
2458
2494
  if (result.keys.length === 0) return;
2495
+ const title = boundTitle(result.title);
2496
+ if (title !== result.title) shortened.add(result.title);
2459
2497
  discoveries.set(result.keys[0].value, {
2460
2498
  keys: result.keys,
2461
- title: result.title,
2499
+ title,
2462
2500
  source: result.source,
2463
2501
  specRef: result.specRef,
2464
2502
  rawStatus: result.rawStatus
@@ -2480,6 +2518,7 @@ async function startRun(passed, expected) {
2480
2518
  return;
2481
2519
  }
2482
2520
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2521
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
2483
2522
  }
2484
2523
  }
2485
2524
  async function flush() {
@@ -2545,7 +2584,9 @@ async function startRun(passed, expected) {
2545
2584
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2546
2585
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2547
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)`);
2548
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`);
2549
2590
  log(`plune: ${parts.join(" \xB7 ")}`);
2550
2591
  }
2551
2592
  return {
@@ -2559,7 +2600,7 @@ async function startRun(passed, expected) {
2559
2600
  return stats;
2560
2601
  },
2561
2602
  async add(result) {
2562
- buffer.push(result);
2603
+ buffer.push({ ...result, keys: bound(result.keys) });
2563
2604
  if (buffer.length >= batchSize) await flush();
2564
2605
  },
2565
2606
  flush,
@@ -2589,11 +2630,12 @@ async function startRun(passed, expected) {
2589
2630
  }
2590
2631
  };
2591
2632
  }
2592
- 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;
2593
2634
  var init_session = __esm({
2594
2635
  "src/reporter-core/session.ts"() {
2595
2636
  "use strict";
2596
2637
  init_cjs_shims();
2638
+ import_node_crypto2 = require("crypto");
2597
2639
  init_api_url();
2598
2640
  init_credentials();
2599
2641
  init_client();
@@ -2604,6 +2646,8 @@ var init_session = __esm({
2604
2646
  DISCOVER_MAX = 500;
2605
2647
  BATCH_DEFAULT = 100;
2606
2648
  EXPECTED_MAX = 1e4;
2649
+ KEY_MAX = 1024;
2650
+ TITLE_MAX = 300;
2607
2651
  }
2608
2652
  });
2609
2653
 
@@ -2612,6 +2656,7 @@ var run_lifecycle_exports = {};
2612
2656
  __export(run_lifecycle_exports, {
2613
2657
  NoTokenError: () => NoTokenError,
2614
2658
  RunCommandError: () => RunCommandError,
2659
+ handleRunDelete: () => handleRunDelete,
2615
2660
  handleRunExec: () => handleRunExec,
2616
2661
  handleRunFinish: () => handleRunFinish,
2617
2662
  handleRunReport: () => handleRunReport,
@@ -2675,6 +2720,22 @@ async function handleRunFinish(id, options = {}) {
2675
2720
  }
2676
2721
  write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
2677
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
+ }
2678
2739
  async function handleRunExec(options) {
2679
2740
  const [command, ...args] = options.argv;
2680
2741
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
@@ -3208,6 +3269,7 @@ async function handleRunImport(options) {
3208
3269
  rejected: stats.rejected,
3209
3270
  unresolved: stats.unresolved,
3210
3271
  offered: stats.offered,
3272
+ created: stats.created,
3211
3273
  unoffered: stats.unoffered,
3212
3274
  deferred: stats.deferred,
3213
3275
  unlocatable: results.filter((r) => r.specRef === void 0).length,
@@ -3236,11 +3298,16 @@ function describe(result, apiUrl) {
3236
3298
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
3237
3299
  );
3238
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
+ }
3239
3306
  if (result.offered > 0) {
3240
3307
  lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
3241
- } else if (result.unresolved > 0 && !result.offering) {
3308
+ } else if (result.unresolved > 0 && result.created === 0 && !result.offering) {
3242
3309
  lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
3243
- } else if (result.unresolved > 0 && result.unoffered === 0) {
3310
+ } else if (result.unresolved > 0 && result.created === 0 && result.unoffered === 0) {
3244
3311
  lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
3245
3312
  }
3246
3313
  if (result.unoffered > 0) {
@@ -4428,6 +4495,14 @@ function createProgram() {
4428
4495
  await failRunCommand(err, verboseOf(command));
4429
4496
  }
4430
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
+ });
4431
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) => {
4432
4507
  try {
4433
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,7 @@ async function startRun(passed, expected) {
239
263
  rejected: 0,
240
264
  unresolved: 0,
241
265
  offered: 0,
266
+ created: 0,
242
267
  unoffered: 0,
243
268
  deferred: 0
244
269
  };
@@ -249,6 +274,22 @@ async function startRun(passed, expected) {
249
274
  let joined = false;
250
275
  let offline = token === "";
251
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);
252
293
  if (offline) {
253
294
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
254
295
  }
@@ -276,9 +317,9 @@ async function startRun(passed, expected) {
276
317
  }
277
318
  if (!offline) {
278
319
  if (expected !== void 0) await resolveKeys(expected.flat());
279
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
280
- const configuration = declared.length === 0 ? void 0 : {
281
- 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) => {
282
323
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
283
324
  if (typeof hit === "string") return [{ testCaseId: hit }];
284
325
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -322,9 +363,11 @@ async function startRun(passed, expected) {
322
363
  if (!offerDiscovered) return;
323
364
  if (result.title === void 0 || result.specRef === void 0) return;
324
365
  if (result.keys.length === 0) return;
366
+ const title = boundTitle(result.title);
367
+ if (title !== result.title) shortened.add(result.title);
325
368
  discoveries.set(result.keys[0].value, {
326
369
  keys: result.keys,
327
- title: result.title,
370
+ title,
328
371
  source: result.source,
329
372
  specRef: result.specRef,
330
373
  rawStatus: result.rawStatus
@@ -346,6 +389,7 @@ async function startRun(passed, expected) {
346
389
  return;
347
390
  }
348
391
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
392
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
349
393
  }
350
394
  }
351
395
  async function flush() {
@@ -411,7 +455,9 @@ async function startRun(passed, expected) {
411
455
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
412
456
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
413
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)`);
414
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`);
415
461
  log(`plune: ${parts.join(" \xB7 ")}`);
416
462
  }
417
463
  return {
@@ -425,7 +471,7 @@ async function startRun(passed, expected) {
425
471
  return stats;
426
472
  },
427
473
  async add(result) {
428
- buffer.push(result);
474
+ buffer.push({ ...result, keys: bound(result.keys) });
429
475
  if (buffer.length >= batchSize) await flush();
430
476
  },
431
477
  flush,
@@ -457,9 +503,9 @@ async function startRun(passed, expected) {
457
503
  }
458
504
 
459
505
  // src/util/hash.ts
460
- var import_node_crypto = require("crypto");
506
+ var import_node_crypto2 = require("crypto");
461
507
  function sha256(input) {
462
- 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");
463
509
  }
464
510
 
465
511
  // src/reporter-core/result-key.ts
@@ -139,6 +139,16 @@ 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;
142
152
  /**
143
153
  * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
154
  *
@@ -205,7 +215,7 @@ interface RunSession {
205
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
206
216
  leaveOpen(): Promise<void>;
207
217
  }
208
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
209
219
 
210
220
  /**
211
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -341,6 +351,13 @@ interface ClientOptions {
341
351
  }
342
352
  interface PlatformClient {
343
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>>;
344
361
  }
345
362
  declare function createClient(opts: ClientOptions): PlatformClient;
346
363
 
@@ -139,6 +139,16 @@ 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;
142
152
  /**
143
153
  * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
154
  *
@@ -205,7 +215,7 @@ interface RunSession {
205
215
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
206
216
  leaveOpen(): Promise<void>;
207
217
  }
208
- declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
218
+ declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
209
219
 
210
220
  /**
211
221
  * The ceiling the platform's contract puts on `resultKey` (`resultSubmissionSchema`).
@@ -341,6 +351,13 @@ interface ClientOptions {
341
351
  }
342
352
  interface PlatformClient {
343
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>>;
344
361
  }
345
362
  declare function createClient(opts: ClientOptions): PlatformClient;
346
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,7 @@ async function startRun(passed, expected) {
196
220
  rejected: 0,
197
221
  unresolved: 0,
198
222
  offered: 0,
223
+ created: 0,
199
224
  unoffered: 0,
200
225
  deferred: 0
201
226
  };
@@ -206,6 +231,22 @@ async function startRun(passed, expected) {
206
231
  let joined = false;
207
232
  let offline = token === "";
208
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);
209
250
  if (offline) {
210
251
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
211
252
  }
@@ -233,9 +274,9 @@ async function startRun(passed, expected) {
233
274
  }
234
275
  if (!offline) {
235
276
  if (expected !== void 0) await resolveKeys(expected.flat());
236
- const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
237
- const configuration = declared.length === 0 ? void 0 : {
238
- 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) => {
239
280
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
240
281
  if (typeof hit === "string") return [{ testCaseId: hit }];
241
282
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
@@ -279,9 +320,11 @@ async function startRun(passed, expected) {
279
320
  if (!offerDiscovered) return;
280
321
  if (result.title === void 0 || result.specRef === void 0) return;
281
322
  if (result.keys.length === 0) return;
323
+ const title = boundTitle(result.title);
324
+ if (title !== result.title) shortened.add(result.title);
282
325
  discoveries.set(result.keys[0].value, {
283
326
  keys: result.keys,
284
- title: result.title,
327
+ title,
285
328
  source: result.source,
286
329
  specRef: result.specRef,
287
330
  rawStatus: result.rawStatus
@@ -303,6 +346,7 @@ async function startRun(passed, expected) {
303
346
  return;
304
347
  }
305
348
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
349
+ stats.created += out.body.results.filter((r) => r.outcome === "created").length;
306
350
  }
307
351
  }
308
352
  async function flush() {
@@ -368,7 +412,9 @@ async function startRun(passed, expected) {
368
412
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
369
413
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
370
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)`);
371
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`);
372
418
  log(`plune: ${parts.join(" \xB7 ")}`);
373
419
  }
374
420
  return {
@@ -382,7 +428,7 @@ async function startRun(passed, expected) {
382
428
  return stats;
383
429
  },
384
430
  async add(result) {
385
- buffer.push(result);
431
+ buffer.push({ ...result, keys: bound(result.keys) });
386
432
  if (buffer.length >= batchSize) await flush();
387
433
  },
388
434
  flush,
@@ -414,9 +460,9 @@ async function startRun(passed, expected) {
414
460
  }
415
461
 
416
462
  // src/util/hash.ts
417
- import { createHash } from "crypto";
463
+ import { createHash as createHash2 } from "crypto";
418
464
  function sha256(input) {
419
- return createHash("sha256").update(input, "utf8").digest("hex");
465
+ return createHash2("sha256").update(input, "utf8").digest("hex");
420
466
  }
421
467
 
422
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.1",
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",