@plune-ai/cli 0.28.0 → 0.29.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
@@ -132,7 +132,9 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
132
132
  | `plune pull [file]` | Write the project's test cases as **one Markdown document** — Testomat's classical format, plus Plune's own columns — to `plune/cases.md` (or `[file]`). Refuses to overwrite a file git sees as modified; `--force` overrides, `--suite <id>` takes one suite or folder and what is under it. |
133
133
  | `plune push [file] [--dry-run]` | Send the document back. Cases are matched by `id`; a block without one becomes a draft; an unknown id is refused by line; **nothing is deleted**. Prints the report — created, updated, unchanged, refused, warnings — and with `--dry-run` writes nothing. Exit `4` when anything was refused. |
134
134
  | `plune requirements import <file.md>` | Import requirements from a **Markdown file** (an SRS, a PRD, a page exported from a wiki). With no mapping flags it lists the tables and sections the file has and exits `0`; with `--table` or `--sections` it is a **dry run** that writes nothing until you add `--yes`. See [Requirements from a Markdown file](#requirements-from-a-markdown-file). Needs a Plune platform that serves `POST /v1/projects/:id/requirements/import` (plune-ai/plune#1621). |
135
+ | `plune requirements links <file.md>` | Import a **traceability matrix** (a Markdown table of requirement key to test) as links between requirements and cases. With no flags it lists the tables the file has and exits `0`; with `--table`, `--requirement-column` and `--test-column` it is a **dry run** that writes nothing until you add `--yes`. See [Links from a traceability matrix](#links-from-a-traceability-matrix). Needs a platform that serves `POST /v1/projects/:id/requirements/import/links` (plune-ai/plune#1608). |
135
136
  | `plune verify` | Compare the project's **automated cases** with the Playwright tests in the code, **read-only** (plune-ai/plune#1291). Prints both sides: cases in Plune that the code no longer has (already `detached`, or one a full run would detach) and tests in the code that Plune has no case for. A test is matched by the id it names (`PluneId` annotation or `@P<id>` in its title), else by its `path-title` key. Nothing is written to Plune and no id is added to your source. Playwright only. Flags: `--from <file>` (a saved `playwright test --list --reporter=json`, instead of running Playwright), `--playwright-config <path>`, `--json`, `--fail-on-drift` (exit `1` when either side has something the other lacks). |
137
+ | `plune ids [--write]` | Write the Plune **case id of each Playwright test that names none** into a mapfile, `plune/ids.json` (`{ "<path-title>": "<case id>" }`), instead of editing your test files (plune-ai/plune#1291). A **dry run** unless `--write`: it prints what it would add. Only tests whose case Plune already holds get an entry (matched by the `path-title` key); a test with no case yet is listed and skipped. Idempotent: entries are only added, never overwritten or removed (an entry for a test no longer in the code is kept and listed), the file is sorted so diffs stay small, and nothing is written when nothing changes. A mapfile that is not a JSON object of strings is refused (exit `2`) and left as it is. Playwright only. The Playwright reporter reads the mapfile: an entry names the case after the `PluneId` annotation, `@P<id>` and a metadata attachment, and before the `playwright-id` and `path-title` keys, so a renamed test keeps its case when the entry is updated with `plune ids --write` (override the path with `PLUNE_IDS_FILE` or the reporter option `idsFile`). Flags: `--write`, `--file <path>` (default `plune/ids.json`), `--from <file>`, `--playwright-config <path>`, `--json`. Exit codes as `plune verify`. |
136
138
  | `plune plan grep <id>` | Print a [test plan](https://docs.plune.ai/platform/plans/) as **one `--grep` pattern** — `npx playwright test --grep "$(plune plan grep <id>)"` or `npx vitest run -t "$(plune plan grep <id>)"` runs exactly what the plan collects. Each case contributes its title path from the `path-title` key the reporter wrote (the file left out, `[ >#]+` between the segments — a space for Playwright, jest, mocha and vitest ≤ 4, ` > ` for vitest 5), escaped, joined with `\|`; a case without one goes in by title. The pattern is all that reaches stdout; the summary — how many cases, how many keyed — goes to stderr. An empty plan prints `(?!)`, which matches nothing, because an empty `--grep` would run everything. |
137
139
  | `plune run grep <run> [--failed]` | Print a run's **automated** cases as one `--grep` pattern, built the way `plan grep` builds a plan's — so the commands Plune shows for a run work as they are: `npx playwright test --grep "$(plune run grep <run-id> --failed)"` re-runs an automated run's failures where your tests run (Plune starts no CI), and `PLUNE_RUN=<key> npx playwright test --grep "$(plune run grep <key>)"` runs the automated half of a mixed run launched without a plan. `<run>` is the run's id (a UUID) or its key (`rk_` and ten hex); anything else is refused with exit `2` before anything is sent, and a key no run carries exits `1`. The cases are the ones the run expected — cases added on its page among them, run or not — or holds a result of; manual cases are left out. `--failed` keeps the cases whose latest result the platform counts as failed or broken, so a muted case's failure stays out, as from **Re-run N failed**. Only the pattern reaches stdout; how many cases were taken and how many left out goes to stderr; nothing taken prints `(?!)`. Needs a Plune that serves `GET /v1/runs/:id/cases` (plune-ai/plune#1333). |
138
140
 
@@ -196,6 +198,20 @@ Inside a `--table` the names are `key`, `title` or `text` (one of the two), `des
196
198
 
197
199
  `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`, a `--table "<n>:..."` spec with the single-table column flags), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
198
200
 
201
+ ### Links from a traceability matrix
202
+
203
+ Requirements documents often end with a table of requirement id to test. `plune requirements links <file.md>` imports it as links between requirements and cases, in the same two steps as `requirements import`: it sends the file as it is, and you say which table and which two columns.
204
+
205
+ ```bash
206
+ plune requirements links matrix.md # lists the tables, exit 0
207
+ plune requirements links matrix.md --table 0 --requirement-column "Requirement" --test-column "Test" # dry run
208
+ plune requirements links matrix.md --table 0 --requirement-column "Requirement" --test-column "Test" --yes
209
+ ```
210
+
211
+ A requirement is matched by its key (import the requirements first, with `--key-column`). A test is matched by the exact title of a case, or for an automated case by its source path (`e2e/login.spec.ts` or `e2e/login.spec.ts#title`); put several tests in one cell on separate lines (`<br>`). A requirement cell with several keys is split by `--separator` (1 to 3 characters, default `,`; nothing else is guessed). The report lists what it would link, what is already linked, and every row that matched nothing, with the reason (unknown key, archived requirement, no such case, a title that names several cases). A second import changes nothing. Links never change a requirement's coverage, which still comes from each case's last result.
212
+
213
+ Exit `0` after a dry run or a write, even when rows are unmatched; `--fail-on-unmatched` makes any unmatched row exit `1`. A wrong flag or file is `2`; a read-only member, a rejected mapping or a matrix over 2000 rows (after splitting) is `1` with the platform's reason. `--json` prints the platform's answer; `--project <id>` / `PLUNE_PROJECT` picks the project.
214
+
199
215
  ### A release gate: `--fail-on-failures`
200
216
 
201
217
  `plune run finish`, `plune run exec`, `plune run import` and `plune sync` take `--fail-on-failures`. The exit
package/dist/cli.cjs CHANGED
@@ -555,22 +555,22 @@ var init_prices = __esm({
555
555
  });
556
556
 
557
557
  // src/providers/cost.ts
558
- function priceFrom(price, usage2) {
559
- return usage2.input_tokens / 1e3 * price.input_per_1k_usd + usage2.output_tokens / 1e3 * price.output_per_1k_usd;
558
+ function priceFrom(price, usage3) {
559
+ return usage3.input_tokens / 1e3 * price.input_per_1k_usd + usage3.output_tokens / 1e3 * price.output_per_1k_usd;
560
560
  }
561
- function resolveCost(usage2, model, reportedCostUsd, overrides, deps = {}) {
561
+ function resolveCost(usage3, model, reportedCostUsd, overrides, deps = {}) {
562
562
  const table = deps.table ?? PRICE_TABLE;
563
563
  const warn = deps.warn ?? defaultWarn;
564
564
  const override = overrides?.[model];
565
565
  if (override !== void 0) {
566
- return priceFrom(override, usage2);
566
+ return priceFrom(override, usage3);
567
567
  }
568
568
  if (reportedCostUsd !== void 0) {
569
569
  return reportedCostUsd;
570
570
  }
571
571
  const tablePrice = table[model];
572
572
  if (tablePrice !== void 0) {
573
- return priceFrom(tablePrice, usage2);
573
+ return priceFrom(tablePrice, usage3);
574
574
  }
575
575
  warn(
576
576
  `plune: no price for model "${model}" \u2014 reporting cost_usd=0. Set pricing["${model}"] in plune.yaml to track its cost.`
@@ -629,8 +629,8 @@ function makeAnthropicProvider(config, env, pricing) {
629
629
  throw normalizeProviderError(err, apiKey, ENV_VAR);
630
630
  }
631
631
  },
632
- estimateCost(usage2, reportedCostUsd) {
633
- return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
632
+ estimateCost(usage3, reportedCostUsd) {
633
+ return { cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing) };
634
634
  }
635
635
  };
636
636
  }
@@ -696,8 +696,8 @@ function makeOpenAiCompatibleProvider(opts) {
696
696
  throw normalizeProviderError(err, apiKey, apiKeyEnv);
697
697
  }
698
698
  },
699
- estimateCost(usage2, reportedCostUsd) {
700
- return { cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing) };
699
+ estimateCost(usage3, reportedCostUsd) {
700
+ return { cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing) };
701
701
  }
702
702
  };
703
703
  }
@@ -800,8 +800,8 @@ function makeEstimateProvider(config, pricing) {
800
800
  complete: () => Promise.reject(
801
801
  new Error("The --dry-run provider only estimates cost; it never calls a model.")
802
802
  ),
803
- estimateCost: (usage2, reportedCostUsd) => ({
804
- cost_usd: resolveCost(usage2, config.model, reportedCostUsd, pricing)
803
+ estimateCost: (usage3, reportedCostUsd) => ({
804
+ cost_usd: resolveCost(usage3, config.model, reportedCostUsd, pricing)
805
805
  })
806
806
  };
807
807
  }
@@ -1781,8 +1781,8 @@ async function runRow(p) {
1781
1781
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1782
1782
  if (p.dryRun) {
1783
1783
  const est = estimateUsage(prompt5, maxTokens);
1784
- const usage3 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1785
- return { vars: p.row.vars, output: null, cached: false, usage: usage3, assertions: [] };
1784
+ const usage4 = { ...est, cost_usd: p.provider.estimateCost(est).cost_usd };
1785
+ return { vars: p.row.vars, output: null, cached: false, usage: usage4, assertions: [] };
1786
1786
  }
1787
1787
  const key = cacheKey(
1788
1788
  {
@@ -1795,7 +1795,7 @@ async function runRow(p) {
1795
1795
  p.policyVersion
1796
1796
  );
1797
1797
  let output;
1798
- let usage2;
1798
+ let usage3;
1799
1799
  let cached = false;
1800
1800
  let cacheSaved;
1801
1801
  let hit = p.noCache ? void 0 : p.cache.get(key, p.cacheMaxAgeMs !== void 0 ? { maxAgeMs: p.cacheMaxAgeMs } : {});
@@ -1816,7 +1816,7 @@ async function runRow(p) {
1816
1816
  if (hit !== void 0) {
1817
1817
  output = hit.output;
1818
1818
  cached = true;
1819
- usage2 = {
1819
+ usage3 = {
1820
1820
  input_tokens: hit.usage.input_tokens,
1821
1821
  output_tokens: hit.usage.output_tokens,
1822
1822
  cost_usd: 0
@@ -1836,7 +1836,7 @@ async function runRow(p) {
1836
1836
  prompt_resolved: prompt5
1837
1837
  });
1838
1838
  output = res.output;
1839
- usage2 = {
1839
+ usage3 = {
1840
1840
  input_tokens: res.usage.input_tokens,
1841
1841
  output_tokens: res.usage.output_tokens,
1842
1842
  // Prefer the provider-reported actual cost (res.cost_usd) when present; resolveCost still
@@ -1884,7 +1884,7 @@ async function runRow(p) {
1884
1884
  break;
1885
1885
  }
1886
1886
  }
1887
- const finalUsage = { ...usage2, cost_usd: usage2.cost_usd + judgeCost };
1887
+ const finalUsage = { ...usage3, cost_usd: usage3.cost_usd + judgeCost };
1888
1888
  return {
1889
1889
  vars: p.row.vars,
1890
1890
  output,
@@ -2853,7 +2853,14 @@ function repoRootOf(dir, stopAt) {
2853
2853
  if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2854
2854
  }
2855
2855
  }
2856
- function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
2856
+ function errorContextFields(attempt, home = (0, import_node_os2.homedir)()) {
2857
+ const { text: text2, omitted: omitted2 } = cutErrorContext(attempt, home);
2858
+ return {
2859
+ ...text2 !== "" ? { errorContext: text2 } : {},
2860
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
2861
+ };
2862
+ }
2863
+ function cutErrorContext(attempt, home) {
2857
2864
  const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
2858
2865
  return cut(machineless(text2, attempt.repoRoot, home));
2859
2866
  }
@@ -2879,7 +2886,7 @@ function pathIn(path17) {
2879
2886
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2880
2887
  }
2881
2888
  function cut(text2) {
2882
- if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
2889
+ if (jsonBytes(text2) <= TEXT_LIMIT) return { text: text2, omitted: 0 };
2883
2890
  const lines = text2.split("\n");
2884
2891
  const size = lines.map((line) => jsonBytes(line) + 2);
2885
2892
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -2888,7 +2895,8 @@ function cut(text2) {
2888
2895
  let tail = lines.length;
2889
2896
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
2890
2897
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
2891
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
2898
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
2899
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text2.length - kept.length };
2892
2900
  }
2893
2901
  function chosen({ status, errors }) {
2894
2902
  if (status === "timedOut") {
@@ -5008,7 +5016,7 @@ function pendingOf(r, retry, ctx) {
5008
5016
  })),
5009
5017
  testFile: ""
5010
5018
  };
5011
- const errorContext = errorContextOf(attempt);
5019
+ const errorContext = errorContextFields(attempt);
5012
5020
  const failure = FAILED.has(status) ? failureOf(attempt) : void 0;
5013
5021
  const pkg2 = labelOf(r, "package");
5014
5022
  const where = (key.includes("#") ? key.split("#")[0] : pkg2)?.replace(/^[A-Za-z][A-Za-z0-9_-]+:(?![\\/])/, "");
@@ -5032,7 +5040,7 @@ function pendingOf(r, retry, ctx) {
5032
5040
  retry
5033
5041
  }
5034
5042
  } : {},
5035
- ...errorContext !== "" ? { errorContext } : {},
5043
+ ...errorContext,
5036
5044
  ...failure !== void 0 ? { failure } : {},
5037
5045
  ...files.length > 0 ? { files } : {}
5038
5046
  };
@@ -5362,7 +5370,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
5362
5370
  const stated = statedIdOf(test, spec.title ?? "");
5363
5371
  const expected = test.expectedStatus;
5364
5372
  const attempt = attemptOf(result, file, ctx);
5365
- const errorContext = errorContextOf(attempt);
5373
+ const errorContext = errorContextFields(attempt);
5366
5374
  const failure = NOT_FAILED.has(attempt.status) ? void 0 : failureOf(attempt);
5367
5375
  const files = filesOf(attempt);
5368
5376
  const keys = [
@@ -5392,7 +5400,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
5392
5400
  ...result.workerIndex !== void 0 ? { worker: String(result.workerIndex) } : {}
5393
5401
  }
5394
5402
  } : {},
5395
- ...errorContext !== "" ? { errorContext } : {},
5403
+ ...errorContext,
5396
5404
  ...failure !== void 0 ? { failure } : {},
5397
5405
  ...files.length > 0 ? { files } : {}
5398
5406
  };
@@ -6509,12 +6517,18 @@ __export(requirements_import_exports, {
6509
6517
  MAX_MARKDOWN_BYTES: () => MAX_MARKDOWN_BYTES,
6510
6518
  RequirementsImportError: () => RequirementsImportError,
6511
6519
  buildImportBody: () => buildImportBody,
6520
+ cut: () => cut2,
6521
+ flat: () => flat,
6512
6522
  formatDiscover: () => formatDiscover,
6513
6523
  formatReport: () => formatReport2,
6514
6524
  handleRequirementsImport: () => handleRequirementsImport,
6515
6525
  parseMap: () => parseMap,
6516
6526
  parseTableSpec: () => parseTableSpec,
6517
- reportRequirementsImportFailure: () => reportRequirementsImportFailure
6527
+ plural: () => plural,
6528
+ readMarkdown: () => readMarkdown,
6529
+ refusal: () => refusal,
6530
+ reportRequirementsImportFailure: () => reportRequirementsImportFailure,
6531
+ resolveProject: () => resolveProject
6518
6532
  });
6519
6533
  function parseMap(flag2, raw) {
6520
6534
  const out = {};
@@ -6801,12 +6815,94 @@ var init_requirements_import = __esm({
6801
6815
  }
6802
6816
  });
6803
6817
 
6818
+ // src/cli/commands/requirements-links.ts
6819
+ var requirements_links_exports = {};
6820
+ __export(requirements_links_exports, {
6821
+ buildLinksBody: () => buildLinksBody,
6822
+ formatLinks: () => formatLinks,
6823
+ formatLinksDiscover: () => formatLinksDiscover,
6824
+ handleRequirementsLinks: () => handleRequirementsLinks
6825
+ });
6826
+ function buildLinksBody(f) {
6827
+ if (f.table === void 0 && f.requirementColumn === void 0 && f.testColumn === void 0 && f.separator === void 0) return null;
6828
+ if (f.table === void 0) return usage2("--requirement-column, --test-column and --separator need --table <n>. Run without flags to see which tables the file has.");
6829
+ if (!/^\d+$/.test(f.table)) usage2(`--table is the number of the table in the list (0, 1, ...), not "${f.table}".`);
6830
+ if (f.requirementColumn === void 0 || f.requirementColumn.trim() === "") usage2("--table needs --requirement-column <header> (the column with the requirement keys).");
6831
+ if (f.testColumn === void 0 || f.testColumn.trim() === "") usage2("--table needs --test-column <header> (the column with the test titles or paths).");
6832
+ const body = { table: Number(f.table), requirementColumn: f.requirementColumn, testColumn: f.testColumn };
6833
+ if (f.separator !== void 0) {
6834
+ if (f.separator.length < 1 || f.separator.length > 3) usage2(`--separator is 1 to 3 characters, not "${f.separator}".`);
6835
+ body["requirementSeparator"] = f.separator;
6836
+ }
6837
+ return body;
6838
+ }
6839
+ async function handleRequirementsLinks(deps) {
6840
+ const apiUrl = resolveApiUrl(deps.apiUrl);
6841
+ const mapping = buildLinksBody(deps);
6842
+ const markdown = readMarkdown(deps.cwd ?? process.cwd(), deps.file);
6843
+ const token = (deps.loadToken ?? loadToken)();
6844
+ if (token === null) throw new RequirementsImportError('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.', 2);
6845
+ const client = createClient({ apiUrl, token, ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {} });
6846
+ const projectId = await resolveProject(client, deps.project, apiUrl);
6847
+ const base = `/v1/projects/${encodeURIComponent(projectId)}/requirements/import`;
6848
+ if (mapping === null) {
6849
+ const out2 = await client.post(`${base}/discover`, { markdown });
6850
+ if (!out2.ok) throw refusal(out2, apiUrl, projectId);
6851
+ return { step: "discover", result: out2.body };
6852
+ }
6853
+ const out = await client.post(`${base}/links`, { markdown, dryRun: deps.yes !== true, ...mapping });
6854
+ if (!out.ok) throw refusal(out, apiUrl, projectId);
6855
+ return { step: "links", report: out.body };
6856
+ }
6857
+ function formatLinksDiscover(file, r) {
6858
+ const out = [`${file}: ${plural(r.tables.length, "table")}`, ""];
6859
+ if (r.tables.length === 0) out.push(" (none)");
6860
+ for (const t of r.tables) {
6861
+ const mark = t.unreadable !== void 0 ? ` cannot be read: ${t.unreadable}` : "";
6862
+ out.push(` #${t.index} line ${t.line} ${t.heading === null ? "(no heading)" : `"${flat(t.heading)}"`} ${cut2(t.headers.map(flat).join(" | "), 100)} ${plural(t.rowCount, "row")}${mark}`);
6863
+ }
6864
+ if (r.truncated) out.push("", "The file has more tables than are listed here.");
6865
+ out.push("", "Pick the matrix and name its two columns (a dry run, nothing is written):", ` plune requirements links ${file} --table <n> --requirement-column <header> --test-column <header>`);
6866
+ return out.join("\n") + "\n";
6867
+ }
6868
+ function formatLinks(report) {
6869
+ const s = report.summary;
6870
+ const out = [`link ${s.link} \xB7 unchanged ${s.unchanged} \xB7 unmatched ${s.unmatched}`];
6871
+ for (const action of ["unmatched", "link", "unchanged"]) {
6872
+ const rows = report.items.filter((i) => i.action === action);
6873
+ if (rows.length === 0) continue;
6874
+ out.push("", `${action} (${rows.length})`);
6875
+ for (const i of rows.slice(0, SHOWN2)) {
6876
+ out.push(` line ${i.line} ${i.key === "" ? "-" : i.key} -> ${i.test === "" ? "-" : cut2(flat(i.test), 80)} - ${flat(i.reason)}`);
6877
+ }
6878
+ if (rows.length > SHOWN2) out.push(` ... and ${rows.length - SHOWN2} more (--json lists every row)`);
6879
+ }
6880
+ out.push("", report.applied ? `applied \u2014 ${plural(s.link, "link")} created.` : "dry run \u2014 nothing was written; add --yes to apply");
6881
+ return out.join("\n") + "\n";
6882
+ }
6883
+ var usage2, SHOWN2;
6884
+ var init_requirements_links = __esm({
6885
+ "src/cli/commands/requirements-links.ts"() {
6886
+ "use strict";
6887
+ init_cjs_shims();
6888
+ init_client();
6889
+ init_api_url();
6890
+ init_credentials();
6891
+ init_requirements_import();
6892
+ usage2 = (message) => {
6893
+ throw new RequirementsImportError(message, 2);
6894
+ };
6895
+ SHOWN2 = 50;
6896
+ }
6897
+ });
6898
+
6804
6899
  // src/cli/commands/verify.ts
6805
6900
  var verify_exports = {};
6806
6901
  __export(verify_exports, {
6807
6902
  compare: () => compare,
6808
6903
  formatVerify: () => formatVerify,
6809
6904
  handleVerify: () => handleVerify,
6905
+ loadSources: () => loadSources,
6810
6906
  testsOfListing: () => testsOfListing
6811
6907
  });
6812
6908
  function testsOfListing(listing) {
@@ -6859,7 +6955,7 @@ function runPlaywrightList(args, cwd) {
6859
6955
  throw new SyncFileError(`could not list the Playwright tests${stderr ? `: ${stderr}` : ""}`);
6860
6956
  }
6861
6957
  }
6862
- async function handleVerify(deps) {
6958
+ async function loadSources(deps) {
6863
6959
  const get = platformGet(deps);
6864
6960
  let raw;
6865
6961
  if (deps.from !== void 0) {
@@ -6891,7 +6987,11 @@ async function handleVerify(deps) {
6891
6987
  cases.push(...page.testCases);
6892
6988
  cursor = page.nextCursor;
6893
6989
  } while (cursor !== null);
6894
- return compare(testsOfListing(listing), cases);
6990
+ return { code: testsOfListing(listing), cases };
6991
+ }
6992
+ async function handleVerify(deps) {
6993
+ const { code, cases } = await loadSources(deps);
6994
+ return compare(code, cases);
6895
6995
  }
6896
6996
  function formatVerify(r) {
6897
6997
  const lines = [];
@@ -6925,6 +7025,111 @@ var init_verify = __esm({
6925
7025
  }
6926
7026
  });
6927
7027
 
7028
+ // src/cli/commands/ids.ts
7029
+ var ids_exports = {};
7030
+ __export(ids_exports, {
7031
+ DEFAULT_IDS_FILE: () => DEFAULT_IDS_FILE,
7032
+ formatIds: () => formatIds,
7033
+ handleIds: () => handleIds,
7034
+ planIds: () => planIds,
7035
+ readMap: () => readMap,
7036
+ serializeMap: () => serializeMap
7037
+ });
7038
+ function readMap(file) {
7039
+ if (!(0, import_node_fs9.existsSync)(file)) return {};
7040
+ let parsed;
7041
+ try {
7042
+ parsed = JSON.parse((0, import_node_fs9.readFileSync)(file, "utf8"));
7043
+ } catch {
7044
+ throw new SyncFileError(`${file} is not valid JSON; fix or remove it, nothing was written`);
7045
+ }
7046
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== "string" || v === ""))
7047
+ throw new SyncFileError(
7048
+ `${file} must be an object of path-title to case id strings; nothing was written`
7049
+ );
7050
+ return parsed;
7051
+ }
7052
+ function planIds(code, cases, current) {
7053
+ const byPath = /* @__PURE__ */ new Map();
7054
+ for (const c of cases)
7055
+ if (c.type === "automated" && c.state !== "deprecated") {
7056
+ for (const k of c.externalKeys ?? []) if (k.kind === "path-title") byPath.set(k.value, c);
7057
+ }
7058
+ const added = {};
7059
+ const noCase = [];
7060
+ let covered = 0;
7061
+ for (const t of code) {
7062
+ if (t.id !== void 0 || current[t.pathTitle] !== void 0) covered++;
7063
+ else {
7064
+ const hit = byPath.get(t.pathTitle);
7065
+ if (hit === void 0) noCase.push(t.pathTitle);
7066
+ else added[t.pathTitle] = hit.id;
7067
+ }
7068
+ }
7069
+ const inCode = new Set(code.map((t) => t.pathTitle));
7070
+ return {
7071
+ added,
7072
+ covered,
7073
+ noCase: noCase.sort(compareKeys),
7074
+ orphaned: Object.keys(current).filter((k) => !inCode.has(k)).sort(compareKeys)
7075
+ };
7076
+ }
7077
+ function serializeMap(map) {
7078
+ const sorted = {};
7079
+ for (const k of Object.keys(map).sort(compareKeys)) sorted[k] = map[k];
7080
+ return JSON.stringify(sorted, null, 2) + "\n";
7081
+ }
7082
+ async function handleIds(deps) {
7083
+ const file = deps.file ?? DEFAULT_IDS_FILE;
7084
+ const current = readMap(file);
7085
+ const { code, cases } = await loadSources(deps);
7086
+ const plan = planIds(code, cases, current);
7087
+ let written = false;
7088
+ if (deps.write === true && Object.keys(plan.added).length > 0) {
7089
+ try {
7090
+ (0, import_node_fs9.mkdirSync)((0, import_node_path5.dirname)(file), { recursive: true });
7091
+ (0, import_node_fs9.writeFileSync)(file, serializeMap({ ...current, ...plan.added }));
7092
+ } catch {
7093
+ throw new SyncFileError(`cannot write ${file}`);
7094
+ }
7095
+ written = true;
7096
+ }
7097
+ return { file, ...plan, written };
7098
+ }
7099
+ function formatIds(r, write) {
7100
+ const n = Object.keys(r.added).length;
7101
+ const lines = [];
7102
+ if (n > 0) {
7103
+ lines.push(`${write ? "Added to" : "Would add to"} ${r.file} (${n}):`);
7104
+ for (const k of Object.keys(r.added).sort(compareKeys)) lines.push(` ${k} -> ${r.added[k]}`);
7105
+ }
7106
+ if (r.noCase.length > 0) {
7107
+ lines.push(`In the code, no case in Plune yet, nothing to write (${r.noCase.length}):`);
7108
+ for (const k of r.noCase) lines.push(` ${k}`);
7109
+ }
7110
+ if (r.orphaned.length > 0) {
7111
+ lines.push(`In ${r.file}, test no longer in the code, kept (${r.orphaned.length}):`);
7112
+ for (const k of r.orphaned) lines.push(` ${k}`);
7113
+ }
7114
+ lines.push(
7115
+ n === 0 ? `Nothing to write: ${r.covered} test(s) already have an id.` : write ? `${r.covered} already had an id. ${r.file} written.` : `${r.covered} already had an id. Dry run: nothing was written; add --write.`
7116
+ );
7117
+ return lines.join("\n") + "\n";
7118
+ }
7119
+ var import_node_fs9, import_node_path5, DEFAULT_IDS_FILE, compareKeys;
7120
+ var init_ids = __esm({
7121
+ "src/cli/commands/ids.ts"() {
7122
+ "use strict";
7123
+ init_cjs_shims();
7124
+ import_node_fs9 = require("fs");
7125
+ import_node_path5 = require("path");
7126
+ init_verify();
7127
+ init_sync();
7128
+ DEFAULT_IDS_FILE = "plune/ids.json";
7129
+ compareKeys = (a, b) => a < b ? -1 : a > b ? 1 : 0;
7130
+ }
7131
+ });
7132
+
6928
7133
  // src/cli.ts
6929
7134
  var cli_exports = {};
6930
7135
  __export(cli_exports, {
@@ -6934,8 +7139,8 @@ module.exports = __toCommonJS(cli_exports);
6934
7139
  init_cjs_shims();
6935
7140
  init_api_url();
6936
7141
  var import_commander = require("commander");
6937
- var import_node_fs9 = require("fs");
6938
- var import_node_path5 = require("path");
7142
+ var import_node_fs10 = require("fs");
7143
+ var import_node_path6 = require("path");
6939
7144
  var import_node_url = require("url");
6940
7145
 
6941
7146
  // src/util/duration.ts
@@ -6951,8 +7156,8 @@ function parseDuration(text2) {
6951
7156
  }
6952
7157
 
6953
7158
  // src/cli.ts
6954
- var __dirname = (0, import_node_path5.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
6955
- var pkg = JSON.parse((0, import_node_fs9.readFileSync)((0, import_node_path5.join)(__dirname, "..", "package.json"), "utf-8"));
7159
+ var __dirname = (0, import_node_path6.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
7160
+ var pkg = JSON.parse((0, import_node_fs10.readFileSync)((0, import_node_path6.join)(__dirname, "..", "package.json"), "utf-8"));
6956
7161
  function toFormat(s) {
6957
7162
  return s === "json" || s === "markdown" ? s : "console";
6958
7163
  }
@@ -6961,7 +7166,7 @@ function shouldColor(outputPath) {
6961
7166
  }
6962
7167
  function writeReport(text2, outputPath) {
6963
7168
  if (outputPath !== void 0) {
6964
- (0, import_node_fs9.writeFileSync)(outputPath, text2);
7169
+ (0, import_node_fs10.writeFileSync)(outputPath, text2);
6965
7170
  } else {
6966
7171
  process.stdout.write(text2 + "\n");
6967
7172
  }
@@ -7006,7 +7211,7 @@ function createProgram() {
7006
7211
  program.hook("preAction", async (_program, action) => {
7007
7212
  const { loadEnv: loadEnv2 } = await Promise.resolve().then(() => (init_env(), env_exports));
7008
7213
  const { config } = action.optsWithGlobals();
7009
- if (config !== void 0) loadEnv2((0, import_node_path5.dirname)((0, import_node_path5.resolve)(config)));
7214
+ if (config !== void 0) loadEnv2((0, import_node_path6.dirname)((0, import_node_path6.resolve)(config)));
7010
7215
  loadEnv2(process.cwd());
7011
7216
  });
7012
7217
  const runCommand = program.command("run").description("Run assertions against a dataset").option("--dry-run", "Estimate cost/tokens; do not call the model", false).option("--config <path>", "Path to plune.yaml config file").option(
@@ -7286,7 +7491,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7286
7491
  Promise.resolve().then(() => (init_report(), report_exports)),
7287
7492
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
7288
7493
  ]);
7289
- const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
7494
+ const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
7290
7495
  try {
7291
7496
  const result = handleReport2({ cwd });
7292
7497
  const text2 = renderReport2(result, toFormat(options.format), {
@@ -7383,7 +7588,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7383
7588
  const globals = command.optsWithGlobals();
7384
7589
  const verbose = globals.verbose === true;
7385
7590
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
7386
- const cwd = globals.config !== void 0 ? (0, import_node_path5.dirname)((0, import_node_path5.resolve)(globals.config)) : process.cwd();
7591
+ const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
7387
7592
  try {
7388
7593
  const { id, url, gate } = await handleSync2({
7389
7594
  cwd,
@@ -7472,7 +7677,7 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7472
7677
  failUnexpected(err, verbose);
7473
7678
  }
7474
7679
  });
7475
- const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7680
+ const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them, or their links to tests, from a Markdown file");
7476
7681
  requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7477
7682
  "Import requirements from a Markdown file. With no mapping flags it lists the tables and sections found; with them it is a dry run until --yes"
7478
7683
  ).option(
@@ -7499,6 +7704,30 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7499
7704
  failUnexpected(err, verbose);
7500
7705
  }
7501
7706
  });
7707
+ requirementsCommand.command("links").argument("<file>", "A Markdown file with a traceability matrix (UTF-8, at most 1 MiB)").description(
7708
+ "Import a traceability matrix (requirement key -> test) as links. With no flags it lists the tables found; with them it is a dry run until --yes"
7709
+ ).option("--table <n>", "The table with the matrix (its number from the list)").option("--requirement-column <header>", "The column holding the requirement keys").option("--test-column <header>", "The column holding the tests: an exact case title or a source path, one per line").option("--separator <text>", 'Splits a requirement cell into several keys, 1 to 3 characters (default ",")').option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).option("--fail-on-unmatched", "Exit 1 when any requirement or test of the matrix matched nothing", false).action(async (file, options, command) => {
7710
+ const verbose = command.optsWithGlobals().verbose === true;
7711
+ const { handleRequirementsLinks: handleRequirementsLinks2, formatLinksDiscover: formatLinksDiscover2, formatLinks: formatLinks2 } = await Promise.resolve().then(() => (init_requirements_links(), requirements_links_exports));
7712
+ const { reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7713
+ try {
7714
+ const res = await handleRequirementsLinks2({ file, ...options });
7715
+ if (options.json === true) {
7716
+ process.stdout.write(JSON.stringify(res.step === "discover" ? res.result : res.report, null, 2) + "\n");
7717
+ } else {
7718
+ process.stdout.write(res.step === "discover" ? formatLinksDiscover2(file, res.result) : formatLinks2(res.report));
7719
+ }
7720
+ if (options.failOnUnmatched === true && res.step === "links" && res.report.summary.unmatched > 0) process.exitCode = 1;
7721
+ } catch (err) {
7722
+ const code = reportRequirementsImportFailure2(err, (s) => process.stderr.write(s));
7723
+ if (code !== null) {
7724
+ maybeStack(err, verbose);
7725
+ process.exitCode = code;
7726
+ return;
7727
+ }
7728
+ failUnexpected(err, verbose);
7729
+ }
7730
+ });
7502
7731
  const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
7503
7732
  planCommand.command("grep").argument("<id>", "The plan").description(
7504
7733
  "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
@@ -7548,6 +7777,32 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7548
7777
  }
7549
7778
  }
7550
7779
  );
7780
+ program.command("ids").description(
7781
+ "Write the Plune case id of each Playwright test that names none into plune/ids.json (dry run unless --write); test sources are not touched"
7782
+ ).option("--write", "Write the file; without it only shows what would be written", false).option("--file <path>", "The mapfile", "plune/ids.json").option("--from <file>", "A saved `playwright test --list --reporter=json` document instead of running Playwright").option("--playwright-config <path>", "Passed to `playwright test --config`").option("--json", "Print the report as JSON", false).action(
7783
+ async (options, command) => {
7784
+ const verbose = verboseOf(command);
7785
+ const { handleIds: handleIds2, formatIds: formatIds2 } = await Promise.resolve().then(() => (init_ids(), ids_exports));
7786
+ const { reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
7787
+ try {
7788
+ const report = await handleIds2({
7789
+ write: options.write,
7790
+ file: options.file,
7791
+ ...options.from === void 0 ? {} : { from: options.from },
7792
+ ...options.playwrightConfig === void 0 ? {} : { playwrightConfig: options.playwrightConfig }
7793
+ });
7794
+ process.stdout.write(options.json ? JSON.stringify(report, null, 2) + "\n" : formatIds2(report, options.write));
7795
+ } catch (err) {
7796
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
7797
+ if (code !== null) {
7798
+ maybeStack(err, verbose);
7799
+ process.exitCode = code;
7800
+ return;
7801
+ }
7802
+ failUnexpected(err, verbose);
7803
+ }
7804
+ }
7805
+ );
7551
7806
  program.on("command:*", (operands) => {
7552
7807
  process.stderr.write(`Unknown command: ${operands.join(" ")}
7553
7808
  `);
@@ -7559,7 +7814,7 @@ function isMainModule() {
7559
7814
  const entry = process.argv[1];
7560
7815
  if (entry === void 0) return false;
7561
7816
  try {
7562
- return (0, import_node_fs9.realpathSync)(entry) === (0, import_node_fs9.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7817
+ return (0, import_node_fs10.realpathSync)(entry) === (0, import_node_fs10.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
7563
7818
  } catch {
7564
7819
  return false;
7565
7820
  }
@@ -36,6 +36,7 @@ __export(reporter_core_exports, {
36
36
  appendBatch: () => appendBatch,
37
37
  ciCommit: () => ciCommit,
38
38
  createClient: () => createClient,
39
+ errorContextFields: () => errorContextFields,
39
40
  errorContextOf: () => errorContextOf,
40
41
  failureOf: () => failureOf,
41
42
  filesOf: () => filesOf,
@@ -449,6 +450,16 @@ function repoRootOf(dir, stopAt) {
449
450
  }
450
451
  }
451
452
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
453
+ return cutErrorContext(attempt, home).text;
454
+ }
455
+ function errorContextFields(attempt, home = (0, import_node_os2.homedir)()) {
456
+ const { text, omitted: omitted2 } = cutErrorContext(attempt, home);
457
+ return {
458
+ ...text !== "" ? { errorContext: text } : {},
459
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
460
+ };
461
+ }
462
+ function cutErrorContext(attempt, home) {
452
463
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
453
464
  return cut(machineless(text, attempt.repoRoot, home));
454
465
  }
@@ -484,7 +495,7 @@ function pathIn(path3) {
484
495
  }
485
496
  var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
486
497
  function cut(text) {
487
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
498
+ if (jsonBytes(text) <= TEXT_LIMIT) return { text, omitted: 0 };
488
499
  const lines = text.split("\n");
489
500
  const size = lines.map((line) => jsonBytes(line) + 2);
490
501
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -493,7 +504,8 @@ function cut(text) {
493
504
  let tail = lines.length;
494
505
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
495
506
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
496
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
507
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
508
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text.length - kept.length };
497
509
  }
498
510
  var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
499
511
  var METADATA_TYPE = "application/plune.metadata+json";
@@ -1585,6 +1597,7 @@ function resultKey(testId, retry) {
1585
1597
  appendBatch,
1586
1598
  ciCommit,
1587
1599
  createClient,
1600
+ errorContextFields,
1588
1601
  errorContextOf,
1589
1602
  failureOf,
1590
1603
  filesOf,
@@ -541,6 +541,16 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
541
541
  * searched for credentials: the platform does that before its own cut (ADR-0005).
542
542
  */
543
543
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
544
+ /**
545
+ * The `errorContext` and `errorContextOmitted` keys of a result (plune-ai/plune#1281 AC-03), ready to spread:
546
+ * none for an empty text, and `errorContextOmitted` only when the cut took something out, a whole number of at
547
+ * least 1 (the platform refuses 0). Counted in characters of the text as it would have been sent (`.length`,
548
+ * the unit the platform counts in), never bytes; the `…[omitted N lines]…` marker is not counted.
549
+ */
550
+ declare function errorContextFields(attempt: FailedAttempt, home?: string): {
551
+ errorContext?: string;
552
+ errorContextOmitted?: number;
553
+ };
544
554
  /**
545
555
  * The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
546
556
  * is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
@@ -642,4 +652,4 @@ interface PlatformClient {
642
652
  }
643
653
  declare function createClient(opts: ClientOptions): PlatformClient;
644
654
 
645
- export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
655
+ export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextFields, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
@@ -541,6 +541,16 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
541
541
  * searched for credentials: the platform does that before its own cut (ADR-0005).
542
542
  */
543
543
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
544
+ /**
545
+ * The `errorContext` and `errorContextOmitted` keys of a result (plune-ai/plune#1281 AC-03), ready to spread:
546
+ * none for an empty text, and `errorContextOmitted` only when the cut took something out, a whole number of at
547
+ * least 1 (the platform refuses 0). Counted in characters of the text as it would have been sent (`.length`,
548
+ * the unit the platform counts in), never bytes; the `…[omitted N lines]…` marker is not counted.
549
+ */
550
+ declare function errorContextFields(attempt: FailedAttempt, home?: string): {
551
+ errorContext?: string;
552
+ errorContextOmitted?: number;
553
+ };
544
554
  /**
545
555
  * The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
546
556
  * is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
@@ -642,4 +652,4 @@ interface PlatformClient {
642
652
  }
643
653
  declare function createClient(opts: ClientOptions): PlatformClient;
644
654
 
645
- export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
655
+ export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type FrozenSummary, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextFields, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
@@ -400,6 +400,16 @@ function repoRootOf(dir, stopAt) {
400
400
  }
401
401
  }
402
402
  function errorContextOf(attempt, home = homedir2()) {
403
+ return cutErrorContext(attempt, home).text;
404
+ }
405
+ function errorContextFields(attempt, home = homedir2()) {
406
+ const { text, omitted: omitted2 } = cutErrorContext(attempt, home);
407
+ return {
408
+ ...text !== "" ? { errorContext: text } : {},
409
+ ...omitted2 > 0 ? { errorContextOmitted: omitted2 } : {}
410
+ };
411
+ }
412
+ function cutErrorContext(attempt, home) {
403
413
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
404
414
  return cut(machineless(text, attempt.repoRoot, home));
405
415
  }
@@ -435,7 +445,7 @@ function pathIn(path3) {
435
445
  }
436
446
  var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
437
447
  function cut(text) {
438
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
448
+ if (jsonBytes(text) <= TEXT_LIMIT) return { text, omitted: 0 };
439
449
  const lines = text.split("\n");
440
450
  const size = lines.map((line) => jsonBytes(line) + 2);
441
451
  const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
@@ -444,7 +454,8 @@ function cut(text) {
444
454
  let tail = lines.length;
445
455
  while (head < tail && used + size[head] <= budget / 2) used += size[head++];
446
456
  while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
447
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
457
+ const kept = [...lines.slice(0, head), ...lines.slice(tail)].join("\n");
458
+ return { text: [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n"), omitted: text.length - kept.length };
448
459
  }
449
460
  var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
450
461
  var METADATA_TYPE = "application/plune.metadata+json";
@@ -1535,6 +1546,7 @@ export {
1535
1546
  appendBatch,
1536
1547
  ciCommit,
1537
1548
  createClient,
1549
+ errorContextFields,
1538
1550
  errorContextOf,
1539
1551
  failureOf,
1540
1552
  filesOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",