@plune-ai/cli 0.7.0 → 0.8.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
@@ -2267,19 +2267,35 @@ function count(env, name) {
2267
2267
  const n = Number(raw);
2268
2268
  return Number.isInteger(n) && n > 0 ? n : void 0;
2269
2269
  }
2270
+ function list(env, name) {
2271
+ const raw = value(env, name);
2272
+ if (raw === void 0) return void 0;
2273
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2274
+ return items.length === 0 ? void 0 : items;
2275
+ }
2270
2276
  function readEnv(env = process.env) {
2271
2277
  const externalKey = value(env, "PLUNE_RUN");
2272
2278
  const token = value(env, "PLUNE_TOKEN");
2273
2279
  const apiUrl = value(env, "PLUNE_API_URL");
2274
2280
  const fallbackPath = value(env, "PLUNE_FALLBACK");
2275
2281
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
2282
+ const title = value(env, "PLUNE_RUN_TITLE");
2283
+ const environment = value(env, "PLUNE_ENV");
2284
+ const labels = list(env, "PLUNE_LABELS");
2285
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
2276
2286
  return {
2277
2287
  config: {
2278
2288
  ...externalKey !== void 0 ? { externalKey } : {},
2279
2289
  ...token !== void 0 ? { token } : {},
2280
2290
  ...apiUrl !== void 0 ? { apiUrl } : {},
2281
2291
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
2282
- ...batchSize !== void 0 ? { batchSize } : {}
2292
+ ...batchSize !== void 0 ? { batchSize } : {},
2293
+ ...title !== void 0 ? { title } : {},
2294
+ ...environment !== void 0 ? { environment } : {},
2295
+ ...labels !== void 0 ? { labels } : {},
2296
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
2297
+ // variable is not an instruction.
2298
+ ...offerDiscovered ? { offerDiscovered } : {}
2283
2299
  },
2284
2300
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2285
2301
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -2293,12 +2309,7 @@ var init_env2 = __esm({
2293
2309
  "src/reporter-core/env.ts"() {
2294
2310
  "use strict";
2295
2311
  init_cjs_shims();
2296
- UNSUPPORTED_VARS = [
2297
- "PLUNE_RUN_TITLE",
2298
- "PLUNE_ENV",
2299
- "PLUNE_LABELS",
2300
- "PLUNE_GROUP"
2301
- ];
2312
+ UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2302
2313
  }
2303
2314
  });
2304
2315
 
@@ -2331,6 +2342,14 @@ function chunk(items, size) {
2331
2342
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2332
2343
  return out;
2333
2344
  }
2345
+ function warnIfDropped(sent, run, log) {
2346
+ const names = Object.keys(sent);
2347
+ if (names.length === 0) return;
2348
+ if (names.some((name) => run[name] != null)) return;
2349
+ log(
2350
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2351
+ );
2352
+ }
2334
2353
  async function startRun(passed, expected) {
2335
2354
  const env = readEnv();
2336
2355
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -2340,6 +2359,7 @@ async function startRun(passed, expected) {
2340
2359
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2341
2360
  const externalKey = cfg.externalKey ?? null;
2342
2361
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2362
+ const offerDiscovered = cfg.offerDiscovered ?? false;
2343
2363
  const token = cfg.token ?? loadToken() ?? "";
2344
2364
  const client = createClient({
2345
2365
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -2352,10 +2372,12 @@ async function startRun(passed, expected) {
2352
2372
  conflict: 0,
2353
2373
  rejected: 0,
2354
2374
  unresolved: 0,
2375
+ offered: 0,
2355
2376
  deferred: 0
2356
2377
  };
2357
2378
  const resolved = /* @__PURE__ */ new Map();
2358
2379
  let buffer = [];
2380
+ const discoveries = /* @__PURE__ */ new Map();
2359
2381
  let runId = null;
2360
2382
  let joined = false;
2361
2383
  let offline = token === "";
@@ -2398,16 +2420,23 @@ async function startRun(passed, expected) {
2398
2420
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2399
2421
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
2400
2422
  }
2423
+ const described = {
2424
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
2425
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
2426
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
2427
+ };
2401
2428
  const start = await client.post("/v1/runs", {
2402
2429
  schemaVersion: 2,
2403
2430
  kind: cfg.kind ?? "automated",
2404
2431
  ...externalKey !== null ? { externalKey } : {},
2405
2432
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2406
- ...configuration !== void 0 ? { configuration } : {}
2433
+ ...configuration !== void 0 ? { configuration } : {},
2434
+ ...described
2407
2435
  });
2408
2436
  if (start.ok) {
2409
2437
  runId = start.body.run.id;
2410
2438
  joined = start.status === 200;
2439
+ warnIfDropped(described, start.body.run, log);
2411
2440
  } else {
2412
2441
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
2413
2442
  }
@@ -2422,6 +2451,32 @@ async function startRun(passed, expected) {
2422
2451
  }
2423
2452
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
2424
2453
  }
2454
+ function remember(result) {
2455
+ if (!offerDiscovered) return;
2456
+ if (result.title === void 0 || result.specRef === void 0) return;
2457
+ if (result.keys.length === 0) return;
2458
+ discoveries.set(result.keys[0].value, {
2459
+ keys: result.keys,
2460
+ title: result.title,
2461
+ source: result.source,
2462
+ specRef: result.specRef,
2463
+ rawStatus: result.rawStatus
2464
+ });
2465
+ }
2466
+ async function offer() {
2467
+ if (discoveries.size === 0 || offline || runId === null) return;
2468
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
2469
+ const out = await client.post("/v1/review-items/discovered", {
2470
+ discovered: batch,
2471
+ runId
2472
+ });
2473
+ if (!out.ok) {
2474
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
2475
+ return;
2476
+ }
2477
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2478
+ }
2479
+ }
2425
2480
  async function flush() {
2426
2481
  if (buffer.length === 0) return;
2427
2482
  const pending = buffer;
@@ -2440,6 +2495,7 @@ async function startRun(passed, expected) {
2440
2495
  submissions.push({ ...rest, testCaseId: match.testCaseId });
2441
2496
  } else if (match.kind === "unmatched") {
2442
2497
  stats.unresolved += 1;
2498
+ remember(result);
2443
2499
  } else {
2444
2500
  unanswered.push(result);
2445
2501
  }
@@ -2473,6 +2529,7 @@ async function startRun(passed, expected) {
2473
2529
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
2474
2530
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2475
2531
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2532
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2476
2533
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2477
2534
  log(`plune: ${parts.join(" \xB7 ")}`);
2478
2535
  }
@@ -2495,6 +2552,7 @@ async function startRun(passed, expected) {
2495
2552
  if (done) return;
2496
2553
  done = true;
2497
2554
  await flush();
2555
+ await offer();
2498
2556
  if (runId !== null && !offline) {
2499
2557
  const out = await client.post(`/v1/runs/${runId}/events`, {
2500
2558
  event: "finish",
@@ -2508,6 +2566,7 @@ async function startRun(passed, expected) {
2508
2566
  if (done) return;
2509
2567
  done = true;
2510
2568
  await flush();
2569
+ await offer();
2511
2570
  if (runId !== null) {
2512
2571
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
2513
2572
  }
@@ -2515,7 +2574,7 @@ async function startRun(passed, expected) {
2515
2574
  }
2516
2575
  };
2517
2576
  }
2518
- var RESOLVE_CHUNK, BATCH_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2577
+ var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2519
2578
  var init_session = __esm({
2520
2579
  "src/reporter-core/session.ts"() {
2521
2580
  "use strict";
@@ -2527,6 +2586,7 @@ var init_session = __esm({
2527
2586
  init_fallback();
2528
2587
  RESOLVE_CHUNK = 500;
2529
2588
  BATCH_MAX = 500;
2589
+ DISCOVER_MAX = 500;
2530
2590
  BATCH_DEFAULT = 100;
2531
2591
  EXPECTED_MAX = 1e4;
2532
2592
  }
@@ -3431,8 +3491,8 @@ async function defaultReadEvidence(dir) {
3431
3491
  });
3432
3492
  }
3433
3493
  function casesFromDesign(report) {
3434
- const list = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3435
- return list.map((raw) => {
3494
+ const list2 = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3495
+ return list2.map((raw) => {
3436
3496
  const c = asRecord(raw);
3437
3497
  const steps = (Array.isArray(c["steps"]) ? c["steps"] : []).map((s) => str(s)).filter((s) => s !== "");
3438
3498
  return {
@@ -3447,8 +3507,8 @@ function casesFromDesign(report) {
3447
3507
  });
3448
3508
  }
3449
3509
  function casesFromApi(report, verdicts) {
3450
- const list = Array.isArray(report["cases"]) ? report["cases"] : [];
3451
- return list.map((raw) => {
3510
+ const list2 = Array.isArray(report["cases"]) ? report["cases"] : [];
3511
+ return list2.map((raw) => {
3452
3512
  const c = asRecord(raw);
3453
3513
  const name = str(c["name"], "unnamed operation");
3454
3514
  const request = `${str(c["method"], "GET")} ${str(c["path"], "/")}`;
@@ -128,12 +128,7 @@ function createClient(opts) {
128
128
  }
129
129
 
130
130
  // src/reporter-core/env.ts
131
- var UNSUPPORTED_VARS = [
132
- "PLUNE_RUN_TITLE",
133
- "PLUNE_ENV",
134
- "PLUNE_LABELS",
135
- "PLUNE_GROUP"
136
- ];
131
+ var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
137
132
  function value(env, name) {
138
133
  const raw = env[name]?.trim();
139
134
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -148,19 +143,35 @@ function count(env, name) {
148
143
  const n = Number(raw);
149
144
  return Number.isInteger(n) && n > 0 ? n : void 0;
150
145
  }
146
+ function list(env, name) {
147
+ const raw = value(env, name);
148
+ if (raw === void 0) return void 0;
149
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
150
+ return items.length === 0 ? void 0 : items;
151
+ }
151
152
  function readEnv(env = process.env) {
152
153
  const externalKey = value(env, "PLUNE_RUN");
153
154
  const token = value(env, "PLUNE_TOKEN");
154
155
  const apiUrl = value(env, "PLUNE_API_URL");
155
156
  const fallbackPath = value(env, "PLUNE_FALLBACK");
156
157
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
158
+ const title = value(env, "PLUNE_RUN_TITLE");
159
+ const environment = value(env, "PLUNE_ENV");
160
+ const labels = list(env, "PLUNE_LABELS");
161
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
157
162
  return {
158
163
  config: {
159
164
  ...externalKey !== void 0 ? { externalKey } : {},
160
165
  ...token !== void 0 ? { token } : {},
161
166
  ...apiUrl !== void 0 ? { apiUrl } : {},
162
167
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
163
- ...batchSize !== void 0 ? { batchSize } : {}
168
+ ...batchSize !== void 0 ? { batchSize } : {},
169
+ ...title !== void 0 ? { title } : {},
170
+ ...environment !== void 0 ? { environment } : {},
171
+ ...labels !== void 0 ? { labels } : {},
172
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
173
+ // variable is not an instruction.
174
+ ...offerDiscovered ? { offerDiscovered } : {}
164
175
  },
165
176
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
166
177
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -189,6 +200,7 @@ function appendBatch(file, batch) {
189
200
  // src/reporter-core/session.ts
190
201
  var RESOLVE_CHUNK = 500;
191
202
  var BATCH_MAX = 500;
203
+ var DISCOVER_MAX = 500;
192
204
  var BATCH_DEFAULT = 100;
193
205
  var EXPECTED_MAX = 1e4;
194
206
  function chunk(items, size) {
@@ -196,6 +208,14 @@ function chunk(items, size) {
196
208
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
197
209
  return out;
198
210
  }
211
+ function warnIfDropped(sent, run, log) {
212
+ const names = Object.keys(sent);
213
+ if (names.length === 0) return;
214
+ if (names.some((name) => run[name] != null)) return;
215
+ log(
216
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
217
+ );
218
+ }
199
219
  async function startRun(passed, expected) {
200
220
  const env = readEnv();
201
221
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -205,6 +225,7 @@ async function startRun(passed, expected) {
205
225
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
206
226
  const externalKey = cfg.externalKey ?? null;
207
227
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
228
+ const offerDiscovered = cfg.offerDiscovered ?? false;
208
229
  const token = cfg.token ?? loadToken() ?? "";
209
230
  const client = createClient({
210
231
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -217,10 +238,12 @@ async function startRun(passed, expected) {
217
238
  conflict: 0,
218
239
  rejected: 0,
219
240
  unresolved: 0,
241
+ offered: 0,
220
242
  deferred: 0
221
243
  };
222
244
  const resolved = /* @__PURE__ */ new Map();
223
245
  let buffer = [];
246
+ const discoveries = /* @__PURE__ */ new Map();
224
247
  let runId = null;
225
248
  let joined = false;
226
249
  let offline = token === "";
@@ -263,16 +286,23 @@ async function startRun(passed, expected) {
263
286
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
264
287
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
265
288
  }
289
+ const described = {
290
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
291
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
292
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
293
+ };
266
294
  const start = await client.post("/v1/runs", {
267
295
  schemaVersion: 2,
268
296
  kind: cfg.kind ?? "automated",
269
297
  ...externalKey !== null ? { externalKey } : {},
270
298
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
271
- ...configuration !== void 0 ? { configuration } : {}
299
+ ...configuration !== void 0 ? { configuration } : {},
300
+ ...described
272
301
  });
273
302
  if (start.ok) {
274
303
  runId = start.body.run.id;
275
304
  joined = start.status === 200;
305
+ warnIfDropped(described, start.body.run, log);
276
306
  } else {
277
307
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
278
308
  }
@@ -287,6 +317,32 @@ async function startRun(passed, expected) {
287
317
  }
288
318
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
289
319
  }
320
+ function remember(result) {
321
+ if (!offerDiscovered) return;
322
+ if (result.title === void 0 || result.specRef === void 0) return;
323
+ if (result.keys.length === 0) return;
324
+ discoveries.set(result.keys[0].value, {
325
+ keys: result.keys,
326
+ title: result.title,
327
+ source: result.source,
328
+ specRef: result.specRef,
329
+ rawStatus: result.rawStatus
330
+ });
331
+ }
332
+ async function offer() {
333
+ if (discoveries.size === 0 || offline || runId === null) return;
334
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
335
+ const out = await client.post("/v1/review-items/discovered", {
336
+ discovered: batch,
337
+ runId
338
+ });
339
+ if (!out.ok) {
340
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
341
+ return;
342
+ }
343
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
344
+ }
345
+ }
290
346
  async function flush() {
291
347
  if (buffer.length === 0) return;
292
348
  const pending = buffer;
@@ -305,6 +361,7 @@ async function startRun(passed, expected) {
305
361
  submissions.push({ ...rest, testCaseId: match.testCaseId });
306
362
  } else if (match.kind === "unmatched") {
307
363
  stats.unresolved += 1;
364
+ remember(result);
308
365
  } else {
309
366
  unanswered.push(result);
310
367
  }
@@ -338,6 +395,7 @@ async function startRun(passed, expected) {
338
395
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
339
396
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
340
397
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
398
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
341
399
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
342
400
  log(`plune: ${parts.join(" \xB7 ")}`);
343
401
  }
@@ -360,6 +418,7 @@ async function startRun(passed, expected) {
360
418
  if (done) return;
361
419
  done = true;
362
420
  await flush();
421
+ await offer();
363
422
  if (runId !== null && !offline) {
364
423
  const out = await client.post(`/v1/runs/${runId}/events`, {
365
424
  event: "finish",
@@ -373,6 +432,7 @@ async function startRun(passed, expected) {
373
432
  if (done) return;
374
433
  done = true;
375
434
  await flush();
435
+ await offer();
376
436
  if (runId !== null) {
377
437
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
378
438
  }
@@ -88,6 +88,19 @@ interface PendingResult {
88
88
  rawStatus: string;
89
89
  /** What the author expected — `test.fail()` and friends. Absent means a pass was expected. */
90
90
  expectedStatus?: ResultStatus;
91
+ /**
92
+ * What to call this test, and where it lives — read ONLY when nothing resolved it (D14).
93
+ *
94
+ * A result that found its case needs neither: the case already has a title and a spec. These are
95
+ * for the other path, where the reporter offers the test to the review queue and the queue needs a
96
+ * name a person can judge and a spec a case can point at.
97
+ *
98
+ * Optional because an adapter that cannot say both simply does not participate. Deriving them from
99
+ * a key's internal spelling was the alternative and it is worse: it makes the core parse a string
100
+ * the adapter formatted, which is a contract nothing checks.
101
+ */
102
+ title?: string;
103
+ specRef?: string;
91
104
  execution?: Execution;
92
105
  errorContext?: string;
93
106
  params?: Record<string, unknown>;
@@ -121,8 +134,11 @@ interface SubmitCounts {
121
134
  /** What the session accumulated, for the one summary line a reporter prints. */
122
135
  interface RunStats extends SubmitCounts {
123
136
  /** Results whose keys matched no case — never sent, because inventing an id would write into
124
- * somebody else's history. What to do about them is C2's ladder. */
137
+ * somebody else's history. Rung 6 of C2's ladder is what to do about them: see `offered`. */
125
138
  unresolved: number;
139
+ /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
+ * from `unresolved` because they are the same tests seen twice, not a second population. */
141
+ offered: number;
126
142
  /** Results written to the fallback file instead of the platform. */
127
143
  deferred: number;
128
144
  }
@@ -136,10 +152,24 @@ interface ReporterConfig {
136
152
  externalKey?: string;
137
153
  kind?: RunKind;
138
154
  meta?: RunMeta;
155
+ /** What to call this run in a list. The first shard to declare one wins. */
156
+ title?: string;
157
+ /** Where it ran — staging, prod, a preview. What keeps two CI matrices from reading as one. */
158
+ environment?: string;
159
+ /** How the run is marked — smoke, nightly, a team. */
160
+ labels?: string[];
139
161
  /** Results per request. The platform's ceiling is 500. */
140
162
  batchSize?: number;
141
163
  /** Where unsent batches go. Defaults beside the run file the CLI already writes. */
142
164
  fallbackPath?: string;
165
+ /**
166
+ * Offer tests that resolved to nothing to the platform's review queue (`PLUNE_CREATE=1`, D14).
167
+ *
168
+ * Off by default, and that is the product decision rather than caution: a reporter that filled a
169
+ * stranger's review queue on first run would teach the team to ignore the queue. Turning it on is
170
+ * how somebody says "yes, tell me what we are running that you do not know about".
171
+ */
172
+ offerDiscovered?: boolean;
143
173
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
144
174
  fetchImpl?: typeof fetch;
145
175
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -167,15 +197,6 @@ interface RunSession {
167
197
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
168
198
  leaveOpen(): Promise<void>;
169
199
  }
170
- /**
171
- * Start a run, or join the one that already carries this key.
172
- *
173
- * `expected` — one list of candidate identifiers per test the runner knows about — is used twice
174
- * on purpose. It becomes `configuration.expected`, from which the platform derives what never ran
175
- * (AC-09); and it is the input to the single up-front resolve, so the per-test path afterwards is
176
- * a map lookup rather than a request (AC-10). A runner that cannot enumerate its tests passes
177
- * nothing and resolves lazily on the first flush — same function, later moment.
178
- */
179
200
  declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
180
201
 
181
202
  /**
@@ -209,14 +230,17 @@ declare function resultKey(testId: string, retry: number): string;
209
230
  * laptop the config was written on and the runner it executes on.
210
231
  */
211
232
  /**
212
- * Names the reporter promises but the platform cannot yet store (feature D13).
233
+ * Names the reporter promises but the platform cannot yet store.
213
234
  *
214
235
  * They are listed rather than ignored because of how they would fail otherwise: a run's `meta`
215
- * strips keys it does not know, so sending `PLUNE_ENV` there would be accepted, dropped, and look
216
- * exactly like success. A client believing it saved something the server discarded is the most
217
- * expensive kind of quiet — cheaper to say "not yet" out loud.
236
+ * strips keys it does not know, so sending one there would be accepted, dropped, and look exactly
237
+ * like success. A client believing it saved something the server discarded is the most expensive
238
+ * kind of quiet — cheaper to say "not yet" out loud.
239
+ *
240
+ * `PLUNE_RUN_TITLE`, `PLUNE_ENV` and `PLUNE_LABELS` left this list when D13 gave a run somewhere to
241
+ * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
218
242
  */
219
- declare const UNSUPPORTED_VARS: readonly ["PLUNE_RUN_TITLE", "PLUNE_ENV", "PLUNE_LABELS", "PLUNE_GROUP"];
243
+ declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
220
244
  interface EnvSettings {
221
245
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
222
246
  config: Partial<ReporterConfig>;
@@ -88,6 +88,19 @@ interface PendingResult {
88
88
  rawStatus: string;
89
89
  /** What the author expected — `test.fail()` and friends. Absent means a pass was expected. */
90
90
  expectedStatus?: ResultStatus;
91
+ /**
92
+ * What to call this test, and where it lives — read ONLY when nothing resolved it (D14).
93
+ *
94
+ * A result that found its case needs neither: the case already has a title and a spec. These are
95
+ * for the other path, where the reporter offers the test to the review queue and the queue needs a
96
+ * name a person can judge and a spec a case can point at.
97
+ *
98
+ * Optional because an adapter that cannot say both simply does not participate. Deriving them from
99
+ * a key's internal spelling was the alternative and it is worse: it makes the core parse a string
100
+ * the adapter formatted, which is a contract nothing checks.
101
+ */
102
+ title?: string;
103
+ specRef?: string;
91
104
  execution?: Execution;
92
105
  errorContext?: string;
93
106
  params?: Record<string, unknown>;
@@ -121,8 +134,11 @@ interface SubmitCounts {
121
134
  /** What the session accumulated, for the one summary line a reporter prints. */
122
135
  interface RunStats extends SubmitCounts {
123
136
  /** Results whose keys matched no case — never sent, because inventing an id would write into
124
- * somebody else's history. What to do about them is C2's ladder. */
137
+ * somebody else's history. Rung 6 of C2's ladder is what to do about them: see `offered`. */
125
138
  unresolved: number;
139
+ /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
+ * from `unresolved` because they are the same tests seen twice, not a second population. */
141
+ offered: number;
126
142
  /** Results written to the fallback file instead of the platform. */
127
143
  deferred: number;
128
144
  }
@@ -136,10 +152,24 @@ interface ReporterConfig {
136
152
  externalKey?: string;
137
153
  kind?: RunKind;
138
154
  meta?: RunMeta;
155
+ /** What to call this run in a list. The first shard to declare one wins. */
156
+ title?: string;
157
+ /** Where it ran — staging, prod, a preview. What keeps two CI matrices from reading as one. */
158
+ environment?: string;
159
+ /** How the run is marked — smoke, nightly, a team. */
160
+ labels?: string[];
139
161
  /** Results per request. The platform's ceiling is 500. */
140
162
  batchSize?: number;
141
163
  /** Where unsent batches go. Defaults beside the run file the CLI already writes. */
142
164
  fallbackPath?: string;
165
+ /**
166
+ * Offer tests that resolved to nothing to the platform's review queue (`PLUNE_CREATE=1`, D14).
167
+ *
168
+ * Off by default, and that is the product decision rather than caution: a reporter that filled a
169
+ * stranger's review queue on first run would teach the team to ignore the queue. Turning it on is
170
+ * how somebody says "yes, tell me what we are running that you do not know about".
171
+ */
172
+ offerDiscovered?: boolean;
143
173
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
144
174
  fetchImpl?: typeof fetch;
145
175
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -167,15 +197,6 @@ interface RunSession {
167
197
  /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
168
198
  leaveOpen(): Promise<void>;
169
199
  }
170
- /**
171
- * Start a run, or join the one that already carries this key.
172
- *
173
- * `expected` — one list of candidate identifiers per test the runner knows about — is used twice
174
- * on purpose. It becomes `configuration.expected`, from which the platform derives what never ran
175
- * (AC-09); and it is the input to the single up-front resolve, so the per-test path afterwards is
176
- * a map lookup rather than a request (AC-10). A runner that cannot enumerate its tests passes
177
- * nothing and resolves lazily on the first flush — same function, later moment.
178
- */
179
200
  declare function startRun(passed: ReporterConfig, expected?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
180
201
 
181
202
  /**
@@ -209,14 +230,17 @@ declare function resultKey(testId: string, retry: number): string;
209
230
  * laptop the config was written on and the runner it executes on.
210
231
  */
211
232
  /**
212
- * Names the reporter promises but the platform cannot yet store (feature D13).
233
+ * Names the reporter promises but the platform cannot yet store.
213
234
  *
214
235
  * They are listed rather than ignored because of how they would fail otherwise: a run's `meta`
215
- * strips keys it does not know, so sending `PLUNE_ENV` there would be accepted, dropped, and look
216
- * exactly like success. A client believing it saved something the server discarded is the most
217
- * expensive kind of quiet — cheaper to say "not yet" out loud.
236
+ * strips keys it does not know, so sending one there would be accepted, dropped, and look exactly
237
+ * like success. A client believing it saved something the server discarded is the most expensive
238
+ * kind of quiet — cheaper to say "not yet" out loud.
239
+ *
240
+ * `PLUNE_RUN_TITLE`, `PLUNE_ENV` and `PLUNE_LABELS` left this list when D13 gave a run somewhere to
241
+ * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
218
242
  */
219
- declare const UNSUPPORTED_VARS: readonly ["PLUNE_RUN_TITLE", "PLUNE_ENV", "PLUNE_LABELS", "PLUNE_GROUP"];
243
+ declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
220
244
  interface EnvSettings {
221
245
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
222
246
  config: Partial<ReporterConfig>;
@@ -85,12 +85,7 @@ function createClient(opts) {
85
85
  }
86
86
 
87
87
  // src/reporter-core/env.ts
88
- var UNSUPPORTED_VARS = [
89
- "PLUNE_RUN_TITLE",
90
- "PLUNE_ENV",
91
- "PLUNE_LABELS",
92
- "PLUNE_GROUP"
93
- ];
88
+ var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
94
89
  function value(env, name) {
95
90
  const raw = env[name]?.trim();
96
91
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -105,19 +100,35 @@ function count(env, name) {
105
100
  const n = Number(raw);
106
101
  return Number.isInteger(n) && n > 0 ? n : void 0;
107
102
  }
103
+ function list(env, name) {
104
+ const raw = value(env, name);
105
+ if (raw === void 0) return void 0;
106
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
107
+ return items.length === 0 ? void 0 : items;
108
+ }
108
109
  function readEnv(env = process.env) {
109
110
  const externalKey = value(env, "PLUNE_RUN");
110
111
  const token = value(env, "PLUNE_TOKEN");
111
112
  const apiUrl = value(env, "PLUNE_API_URL");
112
113
  const fallbackPath = value(env, "PLUNE_FALLBACK");
113
114
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
115
+ const title = value(env, "PLUNE_RUN_TITLE");
116
+ const environment = value(env, "PLUNE_ENV");
117
+ const labels = list(env, "PLUNE_LABELS");
118
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
114
119
  return {
115
120
  config: {
116
121
  ...externalKey !== void 0 ? { externalKey } : {},
117
122
  ...token !== void 0 ? { token } : {},
118
123
  ...apiUrl !== void 0 ? { apiUrl } : {},
119
124
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
120
- ...batchSize !== void 0 ? { batchSize } : {}
125
+ ...batchSize !== void 0 ? { batchSize } : {},
126
+ ...title !== void 0 ? { title } : {},
127
+ ...environment !== void 0 ? { environment } : {},
128
+ ...labels !== void 0 ? { labels } : {},
129
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
130
+ // variable is not an instruction.
131
+ ...offerDiscovered ? { offerDiscovered } : {}
121
132
  },
122
133
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
123
134
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -146,6 +157,7 @@ function appendBatch(file, batch) {
146
157
  // src/reporter-core/session.ts
147
158
  var RESOLVE_CHUNK = 500;
148
159
  var BATCH_MAX = 500;
160
+ var DISCOVER_MAX = 500;
149
161
  var BATCH_DEFAULT = 100;
150
162
  var EXPECTED_MAX = 1e4;
151
163
  function chunk(items, size) {
@@ -153,6 +165,14 @@ function chunk(items, size) {
153
165
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
154
166
  return out;
155
167
  }
168
+ function warnIfDropped(sent, run, log) {
169
+ const names = Object.keys(sent);
170
+ if (names.length === 0) return;
171
+ if (names.some((name) => run[name] != null)) return;
172
+ log(
173
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
174
+ );
175
+ }
156
176
  async function startRun(passed, expected) {
157
177
  const env = readEnv();
158
178
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -162,6 +182,7 @@ async function startRun(passed, expected) {
162
182
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
163
183
  const externalKey = cfg.externalKey ?? null;
164
184
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
185
+ const offerDiscovered = cfg.offerDiscovered ?? false;
165
186
  const token = cfg.token ?? loadToken() ?? "";
166
187
  const client = createClient({
167
188
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -174,10 +195,12 @@ async function startRun(passed, expected) {
174
195
  conflict: 0,
175
196
  rejected: 0,
176
197
  unresolved: 0,
198
+ offered: 0,
177
199
  deferred: 0
178
200
  };
179
201
  const resolved = /* @__PURE__ */ new Map();
180
202
  let buffer = [];
203
+ const discoveries = /* @__PURE__ */ new Map();
181
204
  let runId = null;
182
205
  let joined = false;
183
206
  let offline = token === "";
@@ -220,16 +243,23 @@ async function startRun(passed, expected) {
220
243
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
221
244
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
222
245
  }
246
+ const described = {
247
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
248
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
249
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
250
+ };
223
251
  const start = await client.post("/v1/runs", {
224
252
  schemaVersion: 2,
225
253
  kind: cfg.kind ?? "automated",
226
254
  ...externalKey !== null ? { externalKey } : {},
227
255
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
228
- ...configuration !== void 0 ? { configuration } : {}
256
+ ...configuration !== void 0 ? { configuration } : {},
257
+ ...described
229
258
  });
230
259
  if (start.ok) {
231
260
  runId = start.body.run.id;
232
261
  joined = start.status === 200;
262
+ warnIfDropped(described, start.body.run, log);
233
263
  } else {
234
264
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
235
265
  }
@@ -244,6 +274,32 @@ async function startRun(passed, expected) {
244
274
  }
245
275
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
246
276
  }
277
+ function remember(result) {
278
+ if (!offerDiscovered) return;
279
+ if (result.title === void 0 || result.specRef === void 0) return;
280
+ if (result.keys.length === 0) return;
281
+ discoveries.set(result.keys[0].value, {
282
+ keys: result.keys,
283
+ title: result.title,
284
+ source: result.source,
285
+ specRef: result.specRef,
286
+ rawStatus: result.rawStatus
287
+ });
288
+ }
289
+ async function offer() {
290
+ if (discoveries.size === 0 || offline || runId === null) return;
291
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
292
+ const out = await client.post("/v1/review-items/discovered", {
293
+ discovered: batch,
294
+ runId
295
+ });
296
+ if (!out.ok) {
297
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
298
+ return;
299
+ }
300
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
301
+ }
302
+ }
247
303
  async function flush() {
248
304
  if (buffer.length === 0) return;
249
305
  const pending = buffer;
@@ -262,6 +318,7 @@ async function startRun(passed, expected) {
262
318
  submissions.push({ ...rest, testCaseId: match.testCaseId });
263
319
  } else if (match.kind === "unmatched") {
264
320
  stats.unresolved += 1;
321
+ remember(result);
265
322
  } else {
266
323
  unanswered.push(result);
267
324
  }
@@ -295,6 +352,7 @@ async function startRun(passed, expected) {
295
352
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
296
353
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
297
354
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
355
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
298
356
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
299
357
  log(`plune: ${parts.join(" \xB7 ")}`);
300
358
  }
@@ -317,6 +375,7 @@ async function startRun(passed, expected) {
317
375
  if (done) return;
318
376
  done = true;
319
377
  await flush();
378
+ await offer();
320
379
  if (runId !== null && !offline) {
321
380
  const out = await client.post(`/v1/runs/${runId}/events`, {
322
381
  event: "finish",
@@ -330,6 +389,7 @@ async function startRun(passed, expected) {
330
389
  if (done) return;
331
390
  done = true;
332
391
  await flush();
392
+ await offer();
333
393
  if (runId !== null) {
334
394
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
335
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",