@plune-ai/cli 0.14.0 → 0.14.1

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
@@ -2265,7 +2265,11 @@ function createClient(opts) {
2265
2265
  }
2266
2266
  if (res.ok) {
2267
2267
  if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
2268
- return { ok: true, status: res.status, body: await res.json() };
2268
+ try {
2269
+ return { ok: true, status: res.status, body: await res.json() };
2270
+ } catch {
2271
+ return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
2272
+ }
2269
2273
  }
2270
2274
  const detail = scrub(await detailOf(res));
2271
2275
  const kind = classify3(res.status);
@@ -2625,15 +2629,18 @@ async function startRun(passed, declared) {
2625
2629
  const out = await client.post(`/v1/runs/${runId}/results`, {
2626
2630
  results: part
2627
2631
  });
2628
- if (out.ok) {
2629
- stats.accepted += out.body.counts.accepted ?? 0;
2630
- stats.duplicate += out.body.counts.duplicate ?? 0;
2631
- stats.conflict += out.body.counts.conflict ?? 0;
2632
- stats.rejected += out.body.counts.rejected ?? 0;
2632
+ const counts = out.ok ? out.body?.counts : void 0;
2633
+ if (counts !== void 0) {
2634
+ stats.accepted += counts.accepted ?? 0;
2635
+ stats.duplicate += counts.duplicate ?? 0;
2636
+ stats.conflict += counts.conflict ?? 0;
2637
+ stats.rejected += counts.rejected ?? 0;
2633
2638
  progress();
2634
2639
  continue;
2635
2640
  }
2636
- if (out.kind === "auth") {
2641
+ if (out.ok) {
2642
+ log("plune: could not send results (the answer carried no counts).");
2643
+ } else if (out.kind === "auth") {
2637
2644
  log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
2638
2645
  offline = true;
2639
2646
  } else if (out.kind === "conflict") {
@@ -3164,12 +3171,13 @@ var init_junit = __esm({
3164
3171
  });
3165
3172
 
3166
3173
  // src/reporter-core/failure-detail.ts
3167
- function failureOf(attempt) {
3174
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
3168
3175
  const error = chosen(attempt);
3169
- const headline = error === void 0 ? void 0 : firstLine(error.text);
3176
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
3177
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
3170
3178
  const steps = chainOf(attempt.steps);
3171
3179
  const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
3172
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && a.name !== "" && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => contentType === void 0 ? { name } : { name, contentType });
3180
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
3173
3181
  const ciUrl = webLink(attempt.buildHref);
3174
3182
  const failure = {
3175
3183
  ...headline !== void 0 ? { headline } : {},
@@ -3187,28 +3195,30 @@ function repoRootOf(dir, stopAt) {
3187
3195
  }
3188
3196
  }
3189
3197
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
3190
- let text2 = (0, import_node_util.stripVTControlCharacters)(
3191
- attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n")
3192
- ).replaceAll("\x1B", "");
3193
- const root = attempt.repoRoot === void 0 ? void 0 : pathIn(attempt.repoRoot);
3194
- if (root !== void 0) text2 = text2.replace(new RegExp(`${root.source}[\\\\/]`, root.flags), "");
3198
+ const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
3199
+ return cut(machineless(text2, attempt.repoRoot, home));
3200
+ }
3201
+ function machineless(raw, repoRoot, home) {
3202
+ let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
3203
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
3204
+ if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
3195
3205
  const own = pathIn(home);
3196
- if (own !== void 0) text2 = text2.replace(new RegExp(`${own.source}(?![\\w.-])`, own.flags), "~");
3197
- if (attempt.repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3198
- return cut(text2);
3206
+ if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
3207
+ if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3208
+ return text2;
3199
3209
  }
3200
3210
  function pathIn(path14) {
3201
3211
  const parts = path14.split(/[\\/]+/);
3202
3212
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
3203
3213
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
3204
- const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]");
3214
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
3205
3215
  return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
3206
3216
  }
3207
3217
  function cut(text2) {
3208
- if (Buffer.byteLength(text2) <= TEXT_LIMIT) return text2;
3218
+ if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
3209
3219
  const lines = text2.split("\n");
3210
- const size = lines.map((line) => Buffer.byteLength(line) + 1);
3211
- const budget = TEXT_LIMIT - Buffer.byteLength(omitted(lines.length));
3220
+ const size = lines.map((line) => jsonBytes(line) + 2);
3221
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
3212
3222
  let used = 0;
3213
3223
  let head = 0;
3214
3224
  let tail = lines.length;
@@ -3234,6 +3244,7 @@ function placeOf(error, testFile) {
3234
3244
  const own = normal(testFile);
3235
3245
  let first;
3236
3246
  for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
3247
+ if (line.length > FRAME_MAX) continue;
3237
3248
  const frame = FRAME.exec(line);
3238
3249
  if (frame === null) continue;
3239
3250
  const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
@@ -3244,7 +3255,11 @@ function placeOf(error, testFile) {
3244
3255
  }
3245
3256
  function fileOf(raw) {
3246
3257
  if (!raw.startsWith("file://")) return raw;
3247
- const path14 = decodeURIComponent(raw.slice("file://".length));
3258
+ let path14 = raw.slice("file://".length);
3259
+ try {
3260
+ path14 = decodeURIComponent(path14);
3261
+ } catch {
3262
+ }
3248
3263
  return /^\/[A-Za-z]:\//.test(path14) ? path14.slice(1) : path14;
3249
3264
  }
3250
3265
  function fromRoot(place, root) {
@@ -3258,15 +3273,9 @@ function fromRoot(place, root) {
3258
3273
  return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
3259
3274
  }
3260
3275
  function webLink(href) {
3261
- if (href === void 0) return void 0;
3262
- try {
3263
- const { protocol } = new URL(href);
3264
- return protocol === "http:" || protocol === "https:" ? href : void 0;
3265
- } catch {
3266
- return void 0;
3267
- }
3276
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
3268
3277
  }
3269
- var import_node_fs2, import_node_os2, import_node_path2, import_node_util, TEXT_LIMIT, ANY_HOME, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, firstLine, normal;
3278
+ var import_node_fs2, import_node_os2, import_node_path2, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
3270
3279
  var init_failure_detail = __esm({
3271
3280
  "src/reporter-core/failure-detail.ts"() {
3272
3281
  "use strict";
@@ -3275,12 +3284,17 @@ var init_failure_detail = __esm({
3275
3284
  import_node_os2 = require("os");
3276
3285
  import_node_path2 = require("path");
3277
3286
  import_node_util = require("util");
3287
+ PATH_START = "(?<![\\w.~%/\\\\-])";
3278
3288
  TEXT_LIMIT = 524288;
3279
- ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]Users[\\/])[^\\/\s'"`:*?<>|]+/g;
3289
+ HEADLINE_LIMIT = 8 * 1024;
3290
+ jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
3291
+ ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
3292
+ fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
3280
3293
  omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
3281
3294
  METADATA_TYPE = "application/plune.metadata+json";
3282
3295
  REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
3283
3296
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3297
+ FRAME_MAX = 8192;
3284
3298
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3285
3299
  normal = (path14) => import_node_path2.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3286
3300
  }
@@ -159,7 +159,11 @@ function createClient(opts) {
159
159
  }
160
160
  if (res.ok) {
161
161
  if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
162
- return { ok: true, status: res.status, body: await res.json() };
162
+ try {
163
+ return { ok: true, status: res.status, body: await res.json() };
164
+ } catch {
165
+ return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
166
+ }
163
167
  }
164
168
  const detail = scrub(await detailOf(res));
165
169
  const kind = classify(res.status);
@@ -502,15 +506,18 @@ async function startRun(passed, declared) {
502
506
  const out = await client.post(`/v1/runs/${runId}/results`, {
503
507
  results: part
504
508
  });
505
- if (out.ok) {
506
- stats.accepted += out.body.counts.accepted ?? 0;
507
- stats.duplicate += out.body.counts.duplicate ?? 0;
508
- stats.conflict += out.body.counts.conflict ?? 0;
509
- stats.rejected += out.body.counts.rejected ?? 0;
509
+ const counts = out.ok ? out.body?.counts : void 0;
510
+ if (counts !== void 0) {
511
+ stats.accepted += counts.accepted ?? 0;
512
+ stats.duplicate += counts.duplicate ?? 0;
513
+ stats.conflict += counts.conflict ?? 0;
514
+ stats.rejected += counts.rejected ?? 0;
510
515
  progress();
511
516
  continue;
512
517
  }
513
- if (out.kind === "auth") {
518
+ if (out.ok) {
519
+ log("plune: could not send results (the answer carried no counts).");
520
+ } else if (out.kind === "auth") {
514
521
  log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
515
522
  offline = true;
516
523
  } else if (out.kind === "conflict") {
@@ -606,12 +613,13 @@ var import_node_fs2 = require("fs");
606
613
  var import_node_os2 = require("os");
607
614
  var import_node_path2 = require("path");
608
615
  var import_node_util = require("util");
609
- function failureOf(attempt) {
616
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
610
617
  const error = chosen(attempt);
611
- const headline = error === void 0 ? void 0 : firstLine(error.text);
618
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
619
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
612
620
  const steps = chainOf(attempt.steps);
613
621
  const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
614
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && a.name !== "" && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => contentType === void 0 ? { name } : { name, contentType });
622
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
615
623
  const ciUrl = webLink(attempt.buildHref);
616
624
  const failure = {
617
625
  ...headline !== void 0 ? { headline } : {},
@@ -629,30 +637,36 @@ function repoRootOf(dir, stopAt) {
629
637
  }
630
638
  }
631
639
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
632
- let text = (0, import_node_util.stripVTControlCharacters)(
633
- attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n")
634
- ).replaceAll("\x1B", "");
635
- const root = attempt.repoRoot === void 0 ? void 0 : pathIn(attempt.repoRoot);
636
- if (root !== void 0) text = text.replace(new RegExp(`${root.source}[\\\\/]`, root.flags), "");
640
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
641
+ return cut(machineless(text, attempt.repoRoot, home));
642
+ }
643
+ function machineless(raw, repoRoot, home) {
644
+ let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
645
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
646
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
637
647
  const own = pathIn(home);
638
- if (own !== void 0) text = text.replace(new RegExp(`${own.source}(?![\\w.-])`, own.flags), "~");
639
- if (attempt.repoRoot === void 0) text = text.replace(ANY_HOME, "~");
640
- return cut(text);
648
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
649
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
650
+ return text;
641
651
  }
652
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
642
653
  var TEXT_LIMIT = 524288;
643
- var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]Users[\\/])[^\\/\s'"`:*?<>|]+/g;
654
+ var HEADLINE_LIMIT = 8 * 1024;
655
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
656
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
644
657
  function pathIn(path3) {
645
658
  const parts = path3.split(/[\\/]+/);
646
659
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
647
660
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
648
- const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]");
661
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
649
662
  return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
650
663
  }
664
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
651
665
  function cut(text) {
652
- if (Buffer.byteLength(text) <= TEXT_LIMIT) return text;
666
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
653
667
  const lines = text.split("\n");
654
- const size = lines.map((line) => Buffer.byteLength(line) + 1);
655
- const budget = TEXT_LIMIT - Buffer.byteLength(omitted(lines.length));
668
+ const size = lines.map((line) => jsonBytes(line) + 2);
669
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
656
670
  let used = 0;
657
671
  let head = 0;
658
672
  let tail = lines.length;
@@ -664,6 +678,7 @@ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
664
678
  var METADATA_TYPE = "application/plune.metadata+json";
665
679
  var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
666
680
  var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
681
+ var FRAME_MAX = 8192;
667
682
  var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
668
683
  function chosen({ status, errors }) {
669
684
  if (status === "timedOut") {
@@ -683,6 +698,7 @@ function placeOf(error, testFile) {
683
698
  const own = normal(testFile);
684
699
  let first;
685
700
  for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
701
+ if (line.length > FRAME_MAX) continue;
686
702
  const frame = FRAME.exec(line);
687
703
  if (frame === null) continue;
688
704
  const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
@@ -693,7 +709,11 @@ function placeOf(error, testFile) {
693
709
  }
694
710
  function fileOf(raw) {
695
711
  if (!raw.startsWith("file://")) return raw;
696
- const path3 = decodeURIComponent(raw.slice("file://".length));
712
+ let path3 = raw.slice("file://".length);
713
+ try {
714
+ path3 = decodeURIComponent(path3);
715
+ } catch {
716
+ }
697
717
  return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
698
718
  }
699
719
  var normal = (path3) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
@@ -708,13 +728,7 @@ function fromRoot(place, root) {
708
728
  return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
709
729
  }
710
730
  function webLink(href) {
711
- if (href === void 0) return void 0;
712
- try {
713
- const { protocol } = new URL(href);
714
- return protocol === "http:" || protocol === "https:" ? href : void 0;
715
- } catch {
716
- return void 0;
717
- }
731
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
718
732
  }
719
733
  // Annotate the CommonJS export names for ESM import in node:
720
734
  0 && (module.exports = {
@@ -402,7 +402,7 @@ declare function appendBatch(file: string, batch: DeferredBatch): void;
402
402
  * and nothing is searched for credentials — the platform does both after its own cleaning, and a cut
403
403
  * here could halve a credential it would then not recognise.
404
404
  */
405
- declare function failureOf(attempt: FailedAttempt): FailureDetail | undefined;
405
+ declare function failureOf(attempt: FailedAttempt, home?: string): FailureDetail | undefined;
406
406
  /**
407
407
  * The nearest folder with a `.git` above the runner's root dir — a folder, or the file a worktree
408
408
  * keeps instead. Found once per run by the caller; `stopAt` bounds the walk for a test.
@@ -415,7 +415,11 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
415
415
  * searched for credentials: the platform does that before its own cut (ADR-0005).
416
416
  */
417
417
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
418
- /** A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL. */
418
+ /**
419
+ * A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
420
+ * As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
421
+ * link the platform refuses costs the run's start or the whole batch.
422
+ */
419
423
  declare function webLink(href: string | undefined): string | undefined;
420
424
 
421
425
  type ClientFailureKind =
@@ -402,7 +402,7 @@ declare function appendBatch(file: string, batch: DeferredBatch): void;
402
402
  * and nothing is searched for credentials — the platform does both after its own cleaning, and a cut
403
403
  * here could halve a credential it would then not recognise.
404
404
  */
405
- declare function failureOf(attempt: FailedAttempt): FailureDetail | undefined;
405
+ declare function failureOf(attempt: FailedAttempt, home?: string): FailureDetail | undefined;
406
406
  /**
407
407
  * The nearest folder with a `.git` above the runner's root dir — a folder, or the file a worktree
408
408
  * keeps instead. Found once per run by the caller; `stopAt` bounds the walk for a test.
@@ -415,7 +415,11 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
415
415
  * searched for credentials: the platform does that before its own cut (ADR-0005).
416
416
  */
417
417
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
418
- /** A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL. */
418
+ /**
419
+ * A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
420
+ * As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
421
+ * link the platform refuses costs the run's start or the whole batch.
422
+ */
419
423
  declare function webLink(href: string | undefined): string | undefined;
420
424
 
421
425
  type ClientFailureKind =
@@ -112,7 +112,11 @@ function createClient(opts) {
112
112
  }
113
113
  if (res.ok) {
114
114
  if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
115
- return { ok: true, status: res.status, body: await res.json() };
115
+ try {
116
+ return { ok: true, status: res.status, body: await res.json() };
117
+ } catch {
118
+ return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
119
+ }
116
120
  }
117
121
  const detail = scrub(await detailOf(res));
118
122
  const kind = classify(res.status);
@@ -455,15 +459,18 @@ async function startRun(passed, declared) {
455
459
  const out = await client.post(`/v1/runs/${runId}/results`, {
456
460
  results: part
457
461
  });
458
- if (out.ok) {
459
- stats.accepted += out.body.counts.accepted ?? 0;
460
- stats.duplicate += out.body.counts.duplicate ?? 0;
461
- stats.conflict += out.body.counts.conflict ?? 0;
462
- stats.rejected += out.body.counts.rejected ?? 0;
462
+ const counts = out.ok ? out.body?.counts : void 0;
463
+ if (counts !== void 0) {
464
+ stats.accepted += counts.accepted ?? 0;
465
+ stats.duplicate += counts.duplicate ?? 0;
466
+ stats.conflict += counts.conflict ?? 0;
467
+ stats.rejected += counts.rejected ?? 0;
463
468
  progress();
464
469
  continue;
465
470
  }
466
- if (out.kind === "auth") {
471
+ if (out.ok) {
472
+ log("plune: could not send results (the answer carried no counts).");
473
+ } else if (out.kind === "auth") {
467
474
  log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
468
475
  offline = true;
469
476
  } else if (out.kind === "conflict") {
@@ -559,12 +566,13 @@ import { existsSync as existsSync2 } from "fs";
559
566
  import { homedir as homedir2 } from "os";
560
567
  import { dirname as dirname3, join as join3, posix, resolve } from "path";
561
568
  import { stripVTControlCharacters } from "util";
562
- function failureOf(attempt) {
569
+ function failureOf(attempt, home = homedir2()) {
563
570
  const error = chosen(attempt);
564
- const headline = error === void 0 ? void 0 : firstLine(error.text);
571
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
572
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
565
573
  const steps = chainOf(attempt.steps);
566
574
  const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
567
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && a.name !== "" && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => contentType === void 0 ? { name } : { name, contentType });
575
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
568
576
  const ciUrl = webLink(attempt.buildHref);
569
577
  const failure = {
570
578
  ...headline !== void 0 ? { headline } : {},
@@ -582,30 +590,36 @@ function repoRootOf(dir, stopAt) {
582
590
  }
583
591
  }
584
592
  function errorContextOf(attempt, home = homedir2()) {
585
- let text = stripVTControlCharacters(
586
- attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n")
587
- ).replaceAll("\x1B", "");
588
- const root = attempt.repoRoot === void 0 ? void 0 : pathIn(attempt.repoRoot);
589
- if (root !== void 0) text = text.replace(new RegExp(`${root.source}[\\\\/]`, root.flags), "");
593
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
594
+ return cut(machineless(text, attempt.repoRoot, home));
595
+ }
596
+ function machineless(raw, repoRoot, home) {
597
+ let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
598
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
599
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
590
600
  const own = pathIn(home);
591
- if (own !== void 0) text = text.replace(new RegExp(`${own.source}(?![\\w.-])`, own.flags), "~");
592
- if (attempt.repoRoot === void 0) text = text.replace(ANY_HOME, "~");
593
- return cut(text);
601
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
602
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
603
+ return text;
594
604
  }
605
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
595
606
  var TEXT_LIMIT = 524288;
596
- var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]Users[\\/])[^\\/\s'"`:*?<>|]+/g;
607
+ var HEADLINE_LIMIT = 8 * 1024;
608
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
609
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
597
610
  function pathIn(path3) {
598
611
  const parts = path3.split(/[\\/]+/);
599
612
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
600
613
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
601
- const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]");
614
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
602
615
  return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
603
616
  }
617
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
604
618
  function cut(text) {
605
- if (Buffer.byteLength(text) <= TEXT_LIMIT) return text;
619
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
606
620
  const lines = text.split("\n");
607
- const size = lines.map((line) => Buffer.byteLength(line) + 1);
608
- const budget = TEXT_LIMIT - Buffer.byteLength(omitted(lines.length));
621
+ const size = lines.map((line) => jsonBytes(line) + 2);
622
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
609
623
  let used = 0;
610
624
  let head = 0;
611
625
  let tail = lines.length;
@@ -617,6 +631,7 @@ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
617
631
  var METADATA_TYPE = "application/plune.metadata+json";
618
632
  var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
619
633
  var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
634
+ var FRAME_MAX = 8192;
620
635
  var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
621
636
  function chosen({ status, errors }) {
622
637
  if (status === "timedOut") {
@@ -636,6 +651,7 @@ function placeOf(error, testFile) {
636
651
  const own = normal(testFile);
637
652
  let first;
638
653
  for (const line of stripVTControlCharacters(error.text).split("\n")) {
654
+ if (line.length > FRAME_MAX) continue;
639
655
  const frame = FRAME.exec(line);
640
656
  if (frame === null) continue;
641
657
  const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
@@ -646,7 +662,11 @@ function placeOf(error, testFile) {
646
662
  }
647
663
  function fileOf(raw) {
648
664
  if (!raw.startsWith("file://")) return raw;
649
- const path3 = decodeURIComponent(raw.slice("file://".length));
665
+ let path3 = raw.slice("file://".length);
666
+ try {
667
+ path3 = decodeURIComponent(path3);
668
+ } catch {
669
+ }
650
670
  return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
651
671
  }
652
672
  var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
@@ -661,13 +681,7 @@ function fromRoot(place, root) {
661
681
  return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
662
682
  }
663
683
  function webLink(href) {
664
- if (href === void 0) return void 0;
665
- try {
666
- const { protocol } = new URL(href);
667
- return protocol === "http:" || protocol === "https:" ? href : void 0;
668
- } catch {
669
- return void 0;
670
- }
684
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
671
685
  }
672
686
  export {
673
687
  DEFAULT_FALLBACK_PATH,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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",