@tested/cli 0.1.0 → 0.1.2

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.
Files changed (4) hide show
  1. package/README.md +28 -11
  2. package/dist/td.js +135 -32
  3. package/dist/tested.js +135 -32
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -2,29 +2,33 @@
2
2
 
3
3
  Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.
4
4
 
5
- **→ [Getting started (simple path)](docs/GETTING-STARTED.md)**
6
- **→ [GitHub Action](action/README.md)** (`action/action.yml` composite)
5
+ Docs: https://tested.dev/docs
7
6
 
8
7
  ## Install
9
8
 
10
- Node >= 20.19.
9
+ Node 24+.
11
10
 
12
11
  ```bash
13
12
  pnpm add -D @tested/cli
14
13
  # or
15
- npx @tested/cli
14
+ npx @tested/cli # runs `tested` (`td` is the same CLI after install)
16
15
  ```
17
16
 
18
- CI uses the composite Action (`tested-hq/cli/action@main`). The Action clones this repo and builds the CLI.
17
+ ```bash
18
+ npx @tested/cli --version
19
+ # after a local install, pnpm swallows --version unless you use exec:
20
+ pnpm exec -- tested --version
21
+ ```
22
+
23
+ CI uses the composite Action (`tested-hq/cli/action@main`). It installs `@tested/cli` from npm.
19
24
 
20
25
  ```yaml
21
26
  - uses: tested-hq/cli/action@main
22
27
  with:
28
+ version: 0.1.2
23
29
  token: ${{ secrets.TESTED_TOKEN }}
24
30
  ```
25
31
 
26
- Pin `cli-ref` to a SHA in production. See [action/README.md](action/README.md).
27
-
28
32
  ## First 10 minutes
29
33
 
30
34
  ```
@@ -85,7 +89,7 @@ The hook runs `tested run && tested diff` against the upstream branch (or `HEAD~
85
89
  ```yaml
86
90
  thresholds:
87
91
  patch: 80 # % of newly-added lines that must be covered
88
- project: 60 # % of the whole project that must be covered
92
+ project: 90 # % of the whole project that must be covered
89
93
  ```
90
94
 
91
95
  ```
@@ -93,7 +97,7 @@ $ tested check
93
97
  tested.dev — coverage gate [PASS]
94
98
 
95
99
  Patch 87.3% (threshold 80) [PASS]
96
- Project 64.1% (threshold 60) [PASS]
100
+ Project 92.1% (threshold 90) [PASS]
97
101
 
98
102
  thresholds met
99
103
  $ echo $?
@@ -103,13 +107,25 @@ $ tested check
103
107
  tested.dev — coverage gate [FAIL]
104
108
 
105
109
  Patch 42.7% (threshold 80) [FAIL]
106
- Project 64.1% (threshold 60) [PASS]
110
+ Project 92.1% (threshold 90) [PASS]
107
111
 
108
112
  → add tests for uncovered ranges: tested diff
109
113
  $ echo $?
110
114
  1
111
115
  ```
112
116
 
117
+ If the patch has no executable lines in scope (tests-only, comments-only, docs-only, or ignored files), the patch gate is **skipped** — not reported as 0% coverage. Project still applies.
118
+
119
+ ```
120
+ $ tested check
121
+ tested.dev — coverage gate [PASS]
122
+
123
+ Patch - no executable lines in the patch [SKIP]
124
+ Project 92.1% (threshold 90) [PASS]
125
+
126
+ No executable lines in the patch — patch gate skipped. Project threshold met.
127
+ ```
128
+
113
129
  If `thresholds` is missing from `.tested.yaml`, `tested check` prints a notice on stderr and exits 0 — configs that haven't opted in stay green.
114
130
 
115
131
  ### GitHub Actions
@@ -117,6 +133,7 @@ If `thresholds` is missing from `.tested.yaml`, `tested check` prints a notice o
117
133
  ```yaml
118
134
  - uses: tested-hq/cli/action@main
119
135
  with:
136
+ version: 0.1.2
120
137
  push: true
121
138
  pr-number: ${{ github.event.pull_request.number }}
122
139
  token: ${{ secrets.TESTED_TOKEN }}
@@ -128,7 +145,7 @@ For programmatic consumers:
128
145
 
129
146
  ```
130
147
  $ tested check --json
131
- {"patch":{"pct":87.3,"threshold":80,"pass":true},"project":{"pct":64.1,"threshold":60,"pass":true},"overall":"pass"}
148
+ {"patch":{"pct":87.3,"threshold":80,"pass":true},"project":{"pct":92.1,"threshold":90,"pass":true},"overall":"pass"}
132
149
  ```
133
150
 
134
151
  `--json` suppresses the human layout; the exit code is unchanged.
package/dist/td.js CHANGED
@@ -44,6 +44,8 @@ function badge(kind) {
44
44
  return pc.yellow("[WARN]");
45
45
  case "info":
46
46
  return pc.cyan("[INFO]");
47
+ case "skip":
48
+ return pc.cyan("[SKIP]");
47
49
  }
48
50
  }
49
51
  function metricBar(pct3, width = 10) {
@@ -130,6 +132,10 @@ function formatCliError(message) {
130
132
 
131
133
  // src/commands/init.ts
132
134
  import pc2 from "picocolors";
135
+ var DEFAULT_INIT_THRESHOLDS = {
136
+ patch: 80,
137
+ project: 90
138
+ };
133
139
  var INIT_YAML_HEADER = "# Config schema \u2014 see https://tested.dev/docs/config\n";
134
140
  var DEFAULT_INIT_IGNORES = [
135
141
  "**/*.test.ts",
@@ -179,9 +185,11 @@ function buildInitYaml(args) {
179
185
  if (args.testRunner) {
180
186
  lines.push(`testRunner: ${args.testRunner}`);
181
187
  }
188
+ lines.push("# Patch gate is skipped when the diff has no executable lines");
189
+ lines.push("# (tests-only, comments-only, docs-only, or ignored files).");
182
190
  lines.push("thresholds:");
183
- lines.push(" patch: 80");
184
- lines.push(" project: 60");
191
+ lines.push(` patch: ${DEFAULT_INIT_THRESHOLDS.patch}`);
192
+ lines.push(` project: ${DEFAULT_INIT_THRESHOLDS.project}`);
185
193
  lines.push("ignores:");
186
194
  for (const pattern of DEFAULT_INIT_IGNORES) {
187
195
  lines.push(` - "${pattern}"`);
@@ -545,7 +553,9 @@ var FileCoverageSchema = z2.object({
545
553
  var CoverageTotalsSchema = z2.object({
546
554
  executable: z2.number().int().nonnegative(),
547
555
  covered: z2.number().int().nonnegative(),
548
- pct: z2.number().min(0).max(100)
556
+ pct: z2.number().min(0).max(100),
557
+ /** Present when executable === 0 — not a 0% coverage result. */
558
+ empty: z2.literal(true).optional()
549
559
  });
550
560
  var ProjectTotalsSchema = CoverageTotalsSchema.extend({
551
561
  delta: z2.number().nullable()
@@ -614,7 +624,14 @@ async function headSha(ctx) {
614
624
  return (await ctx.git.revparse(["HEAD"])).trim();
615
625
  }
616
626
  async function unifiedDiff(ctx, base) {
617
- return ctx.git.diff([`${base}...HEAD`]);
627
+ try {
628
+ const mergeBase = (await ctx.git.raw(["merge-base", base, "HEAD"])).trim();
629
+ if (mergeBase) {
630
+ return ctx.git.diff([`${base}...HEAD`]);
631
+ }
632
+ } catch {
633
+ }
634
+ return ctx.git.diff([base, "HEAD"]);
618
635
  }
619
636
  async function remoteUrl(ctx, remote = "origin") {
620
637
  return (await ctx.git.raw(["remote", "get-url", remote])).trim();
@@ -763,6 +780,10 @@ function assertWithinRoot(root, resolvedPath) {
763
780
  }
764
781
 
765
782
  // src/core/patch.ts
783
+ var EMPTY_PATCH_REASON = "no executable lines in the patch";
784
+ function isEmptyPatch(totals) {
785
+ return totals.executable === 0;
786
+ }
766
787
  function pct(covered, executable) {
767
788
  if (executable === 0) return 0;
768
789
  return Math.round(covered / executable * 1e3) / 10;
@@ -862,10 +883,10 @@ function uncoveredRanges(file) {
862
883
  function buildDiffOutput(args) {
863
884
  const patch = computePatchCoverage(args.files, args.addedByFile);
864
885
  const project = computeProjectCoverage(args.files);
865
- const fileNames = /* @__PURE__ */ new Set([
866
- ...patch.byFile.keys(),
867
- ...project.byFile.keys()
868
- ]);
886
+ const emptyPatch = isEmptyPatch(patch.totals);
887
+ const fileNames = new Set(
888
+ emptyPatch ? [] : [...patch.byFile.keys(), ...project.byFile.keys()]
889
+ );
869
890
  const files = [];
870
891
  for (const name of fileNames) {
871
892
  const file = args.files.find((f) => f.path === name);
@@ -882,7 +903,7 @@ function buildDiffOutput(args) {
882
903
  schemaVersion: 1,
883
904
  base: args.base,
884
905
  head: args.head,
885
- patch: patch.totals,
906
+ patch: emptyPatch ? { ...patch.totals, empty: true } : patch.totals,
886
907
  project: { ...project.totals, delta: args.projectDelta ?? null },
887
908
  files,
888
909
  ignored: [...args.ignored]
@@ -1508,6 +1529,7 @@ function registerPushCommand(program2) {
1508
1529
 
1509
1530
  // src/commands/doctor.ts
1510
1531
  var TESTED_BIN_BASENAME_RE = /^tested(\.js)?$/;
1532
+ var MIN_NODE_MAJOR = 24;
1511
1533
  function statusBadge(status) {
1512
1534
  const kind = status === "pass" ? "pass" : status === "fail" ? "fail" : status === "warn" ? "warn" : "info";
1513
1535
  return badge(kind);
@@ -1574,20 +1596,19 @@ async function runDoctor(deps) {
1574
1596
  const checks = [];
1575
1597
  const major = parseNodeMajor(nodeVersion);
1576
1598
  const nodeDisplay = nodeVersion.replace(/^v/, "v");
1577
- if (major !== null && major >= 22) {
1599
+ if (major !== null && major >= MIN_NODE_MAJOR) {
1578
1600
  checks.push({
1579
1601
  id: "node",
1580
1602
  label: "Node.js",
1581
1603
  status: "pass",
1582
- detail: `${nodeDisplay} (>= 20.19; 22+ recommended)`
1604
+ detail: `${nodeDisplay} (>= ${MIN_NODE_MAJOR})`
1583
1605
  });
1584
1606
  } else {
1585
1607
  checks.push({
1586
1608
  id: "node",
1587
1609
  label: "Node.js",
1588
- status: "warn",
1589
- detail: `${nodeDisplay} runs this CLI. Node >= 22 recommended.`,
1590
- optional: true
1610
+ status: "fail",
1611
+ detail: `${nodeDisplay} is below ${MIN_NODE_MAJOR}. Node >= ${MIN_NODE_MAJOR} required.`
1591
1612
  });
1592
1613
  }
1593
1614
  let isRepo = false;
@@ -1809,7 +1830,7 @@ async function runDoctor(deps) {
1809
1830
  optional: true
1810
1831
  });
1811
1832
  }
1812
- const hardFailIds = /* @__PURE__ */ new Set(["git", "config", "origin"]);
1833
+ const hardFailIds = /* @__PURE__ */ new Set(["node", "git", "config", "origin"]);
1813
1834
  const safetyFailIds = /* @__PURE__ */ new Set(["api_url", "tested_bin", "token"]);
1814
1835
  const hasHardFail = checks.some(
1815
1836
  (c) => c.status === "fail" && hardFailIds.has(c.id)
@@ -1854,6 +1875,73 @@ function registerDoctorCommand(program2) {
1854
1875
 
1855
1876
  // src/commands/setup.ts
1856
1877
  import pc3 from "picocolors";
1878
+
1879
+ // package.json
1880
+ var package_default = {
1881
+ name: "@tested/cli",
1882
+ version: "0.1.2",
1883
+ description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
1884
+ license: "MIT",
1885
+ homepage: "https://tested.dev",
1886
+ repository: {
1887
+ type: "git",
1888
+ url: "git+https://github.com/tested-hq/cli.git"
1889
+ },
1890
+ bugs: {
1891
+ url: "https://github.com/tested-hq/cli/issues"
1892
+ },
1893
+ publishConfig: {
1894
+ access: "public"
1895
+ },
1896
+ type: "module",
1897
+ packageManager: "pnpm@11.3.0",
1898
+ bin: {
1899
+ tested: "./dist/tested.js",
1900
+ td: "./dist/tested.js"
1901
+ },
1902
+ files: [
1903
+ "dist",
1904
+ "README.md",
1905
+ "LICENSE"
1906
+ ],
1907
+ scripts: {
1908
+ build: "tsup",
1909
+ dev: "tsx bin/tested.ts",
1910
+ test: "vitest run",
1911
+ "test:watch": "vitest",
1912
+ "test:coverage": "vitest run --coverage",
1913
+ typecheck: "tsc --noEmit",
1914
+ prepare: "node ./scripts/prepare.mjs",
1915
+ prepublishOnly: "pnpm run build"
1916
+ },
1917
+ engines: {
1918
+ node: ">=24"
1919
+ },
1920
+ dependencies: {
1921
+ commander: "14.0.3",
1922
+ minimatch: "10.2.5",
1923
+ picocolors: "1.1.1",
1924
+ "simple-git": "3.36.0",
1925
+ yaml: "2.9.0",
1926
+ zod: "4.4.3"
1927
+ },
1928
+ devDependencies: {
1929
+ "@types/node": "25.9.1",
1930
+ "@vitest/coverage-v8": "4.1.7",
1931
+ husky: "9.1.7",
1932
+ "is-ci": "3.0.1",
1933
+ tsup: "8.5.1",
1934
+ tsx: "4.22.3",
1935
+ typescript: "6.0.3",
1936
+ vite: "8.0.14",
1937
+ vitest: "4.1.7"
1938
+ }
1939
+ };
1940
+
1941
+ // src/version.ts
1942
+ var CLI_VERSION = package_default.version;
1943
+
1944
+ // src/commands/setup.ts
1857
1945
  var PINNED_CLI = "@tested/cli";
1858
1946
  function buildCiSnippet() {
1859
1947
  return [
@@ -1865,12 +1953,9 @@ function buildCiSnippet() {
1865
1953
  " runs-on: ubuntu-latest",
1866
1954
  " steps:",
1867
1955
  " - uses: actions/checkout@v4",
1868
- " with:",
1869
- " fetch-depth: 0",
1870
1956
  " - uses: tested-hq/cli/action@main",
1871
1957
  " with:",
1872
- " # pin ref for reproducible installs (do not use floating tags in prod)",
1873
- " cli-ref: main",
1958
+ ` version: ${CLI_VERSION}`,
1874
1959
  " push: true",
1875
1960
  " pr-number: ${{ github.event.pull_request.number }}",
1876
1961
  " token: ${{ secrets.TESTED_TOKEN }}"
@@ -1887,6 +1972,7 @@ function buildTokenInstructions() {
1887
1972
  function buildInstallInstructions() {
1888
1973
  return [
1889
1974
  "Install CLI:",
1975
+ " Node 24+",
1890
1976
  ` pnpm add -D ${PINNED_CLI}`,
1891
1977
  ` # or: npx ${PINNED_CLI}`,
1892
1978
  "",
@@ -1931,6 +2017,12 @@ function formatSetupHuman(opts) {
1931
2017
  ])
1932
2018
  );
1933
2019
  lines.push("");
2020
+ lines.push(
2021
+ dim(
2022
+ "Patch gate is skipped when the diff has no executable lines (tests-only, docs, comments)."
2023
+ )
2024
+ );
2025
+ lines.push("");
1934
2026
  lines.push(tip("re-check anytime: tested doctor"));
1935
2027
  lines.push("");
1936
2028
  return lines.join("\n");
@@ -2196,8 +2288,8 @@ function coloredPctCell(pct3, width = 6) {
2196
2288
  function formatMetricRow(label, pct3, covered, executable, emptyNote) {
2197
2289
  const labelPad = label.padEnd(8);
2198
2290
  if (executable === 0) {
2199
- const note = emptyNote ?? "no executable lines";
2200
- return ` ${labelPad} ${dim("-".padEnd(6))} ${dim(note)}`;
2291
+ const note = emptyNote ?? EMPTY_PATCH_REASON;
2292
+ return ` ${labelPad} ${dim("-".padEnd(6))} ${note}`;
2201
2293
  }
2202
2294
  const pctStr = coloredPctCell(pct3);
2203
2295
  const bar = metricBar(pct3);
@@ -2216,7 +2308,7 @@ function formatHuman(out, opts = {}) {
2216
2308
  out.patch.pct,
2217
2309
  out.patch.covered,
2218
2310
  out.patch.executable,
2219
- "no executable lines in patch"
2311
+ EMPTY_PATCH_REASON
2220
2312
  )
2221
2313
  );
2222
2314
  const projectRow = formatMetricRow(
@@ -2229,7 +2321,7 @@ function formatHuman(out, opts = {}) {
2229
2321
  if (opts.thresholds) {
2230
2322
  const patchPass = out.patch.pct >= opts.thresholds.patch;
2231
2323
  const projectPass = out.project.pct >= opts.thresholds.project;
2232
- const patchOk = out.patch.executable === 0 ? true : patchPass;
2324
+ const patchOk = isEmptyPatch(out.patch) ? true : patchPass;
2233
2325
  const overall = patchOk && projectPass;
2234
2326
  const details = [];
2235
2327
  if (!patchOk) {
@@ -2240,7 +2332,7 @@ function formatHuman(out, opts = {}) {
2240
2332
  `project ${out.project.pct.toFixed(1)}% < ${opts.thresholds.project}%`
2241
2333
  );
2242
2334
  }
2243
- const detail = details.length > 0 ? dim(` ${details.join("; ")}`) : "";
2335
+ const detail = details.length > 0 ? dim(` ${details.join("; ")}`) : isEmptyPatch(out.patch) ? dim(` ${EMPTY_PATCH_REASON}`) : "";
2244
2336
  lines.push(
2245
2337
  ` ${"Gate".padEnd(8)} ${overall ? badge("pass") : badge("fail")}${detail}`
2246
2338
  );
@@ -2253,12 +2345,15 @@ function formatHuman(out, opts = {}) {
2253
2345
  );
2254
2346
  }
2255
2347
  }
2256
- if (out.files.length > 0) {
2348
+ if (isEmptyPatch(out.patch)) {
2349
+ lines.push("");
2350
+ lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
2351
+ } else if (out.files.length > 0) {
2257
2352
  lines.push("");
2258
2353
  lines.push(heading("Files in diff:"));
2259
2354
  const anyPatch = out.files.some((f) => f.patchCoverage !== null);
2260
2355
  if (!anyPatch) {
2261
- lines.push(dim(" (project coverage \u2014 no executable lines in patch)"));
2356
+ lines.push(dim(` (project coverage \u2014 ${EMPTY_PATCH_REASON})`));
2262
2357
  }
2263
2358
  for (const f of out.files) {
2264
2359
  const hasPatch = f.patchCoverage !== null;
@@ -2344,7 +2439,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2344
2439
  const projectPct = diff.project.pct;
2345
2440
  const patchThreshold = config.thresholds.patch;
2346
2441
  const projectThreshold = config.thresholds.project;
2347
- const patchSkipped = diff.patch.executable === 0;
2442
+ const patchSkipped = isEmptyPatch(diff.patch);
2348
2443
  const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
2349
2444
  const projectPass = projectPct >= projectThreshold;
2350
2445
  const overall = patchPass && projectPass ? "pass" : "fail";
@@ -2355,10 +2450,11 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2355
2450
  pct: patchPct,
2356
2451
  threshold: patchThreshold,
2357
2452
  pass: patchPass,
2358
- ...patchSkipped ? { skipped: true } : {}
2453
+ ...patchSkipped ? { skipped: true, reason: EMPTY_PATCH_REASON } : {}
2359
2454
  },
2360
2455
  project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
2361
- overall
2456
+ overall,
2457
+ ...patchSkipped ? { note: EMPTY_PATCH_REASON } : {}
2362
2458
  };
2363
2459
  return {
2364
2460
  skipped: false,
@@ -2377,7 +2473,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2377
2473
  lines.push("");
2378
2474
  if (patchSkipped) {
2379
2475
  lines.push(
2380
- ` ${"Patch".padEnd(8)} ${dim("-")} ${dim("(no executable lines \u2014 skipped)")} ${badge("info")}`
2476
+ ` ${"Patch".padEnd(8)} ${dim("-")} ${EMPTY_PATCH_REASON} ${badge("skip")}`
2381
2477
  );
2382
2478
  } else {
2383
2479
  lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
@@ -2385,10 +2481,17 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2385
2481
  lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
2386
2482
  if (overall === "fail") {
2387
2483
  lines.push("");
2484
+ if (patchSkipped) {
2485
+ lines.push(dim("No executable lines in the patch \u2014 patch gate skipped."));
2486
+ }
2388
2487
  lines.push(tip("add tests for uncovered ranges: tested diff"));
2389
2488
  } else {
2390
2489
  lines.push("");
2391
- lines.push(dim(patchSkipped ? "project thresholds met (patch skipped)" : "thresholds met"));
2490
+ lines.push(
2491
+ dim(
2492
+ patchSkipped ? "No executable lines in the patch \u2014 patch gate skipped. Project threshold met." : "thresholds met"
2493
+ )
2494
+ );
2392
2495
  }
2393
2496
  lines.push("");
2394
2497
  return {
@@ -2561,7 +2664,7 @@ function createProgram() {
2561
2664
  "Agent loop:",
2562
2665
  " tested setup \u2192 tested run \u2192 tested diff \u2192 tested check \u2192 tested push --pr <n>"
2563
2666
  ].join("\n")
2564
- ).version("0.1.0");
2667
+ ).version(CLI_VERSION);
2565
2668
  registerSetupCommand(program2);
2566
2669
  registerDoctorCommand(program2);
2567
2670
  registerInitCommand(program2);
package/dist/tested.js CHANGED
@@ -44,6 +44,8 @@ function badge(kind) {
44
44
  return pc.yellow("[WARN]");
45
45
  case "info":
46
46
  return pc.cyan("[INFO]");
47
+ case "skip":
48
+ return pc.cyan("[SKIP]");
47
49
  }
48
50
  }
49
51
  function metricBar(pct3, width = 10) {
@@ -130,6 +132,10 @@ function formatCliError(message) {
130
132
 
131
133
  // src/commands/init.ts
132
134
  import pc2 from "picocolors";
135
+ var DEFAULT_INIT_THRESHOLDS = {
136
+ patch: 80,
137
+ project: 90
138
+ };
133
139
  var INIT_YAML_HEADER = "# Config schema \u2014 see https://tested.dev/docs/config\n";
134
140
  var DEFAULT_INIT_IGNORES = [
135
141
  "**/*.test.ts",
@@ -179,9 +185,11 @@ function buildInitYaml(args) {
179
185
  if (args.testRunner) {
180
186
  lines.push(`testRunner: ${args.testRunner}`);
181
187
  }
188
+ lines.push("# Patch gate is skipped when the diff has no executable lines");
189
+ lines.push("# (tests-only, comments-only, docs-only, or ignored files).");
182
190
  lines.push("thresholds:");
183
- lines.push(" patch: 80");
184
- lines.push(" project: 60");
191
+ lines.push(` patch: ${DEFAULT_INIT_THRESHOLDS.patch}`);
192
+ lines.push(` project: ${DEFAULT_INIT_THRESHOLDS.project}`);
185
193
  lines.push("ignores:");
186
194
  for (const pattern of DEFAULT_INIT_IGNORES) {
187
195
  lines.push(` - "${pattern}"`);
@@ -545,7 +553,9 @@ var FileCoverageSchema = z2.object({
545
553
  var CoverageTotalsSchema = z2.object({
546
554
  executable: z2.number().int().nonnegative(),
547
555
  covered: z2.number().int().nonnegative(),
548
- pct: z2.number().min(0).max(100)
556
+ pct: z2.number().min(0).max(100),
557
+ /** Present when executable === 0 — not a 0% coverage result. */
558
+ empty: z2.literal(true).optional()
549
559
  });
550
560
  var ProjectTotalsSchema = CoverageTotalsSchema.extend({
551
561
  delta: z2.number().nullable()
@@ -614,7 +624,14 @@ async function headSha(ctx) {
614
624
  return (await ctx.git.revparse(["HEAD"])).trim();
615
625
  }
616
626
  async function unifiedDiff(ctx, base) {
617
- return ctx.git.diff([`${base}...HEAD`]);
627
+ try {
628
+ const mergeBase = (await ctx.git.raw(["merge-base", base, "HEAD"])).trim();
629
+ if (mergeBase) {
630
+ return ctx.git.diff([`${base}...HEAD`]);
631
+ }
632
+ } catch {
633
+ }
634
+ return ctx.git.diff([base, "HEAD"]);
618
635
  }
619
636
  async function remoteUrl(ctx, remote = "origin") {
620
637
  return (await ctx.git.raw(["remote", "get-url", remote])).trim();
@@ -763,6 +780,10 @@ function assertWithinRoot(root, resolvedPath) {
763
780
  }
764
781
 
765
782
  // src/core/patch.ts
783
+ var EMPTY_PATCH_REASON = "no executable lines in the patch";
784
+ function isEmptyPatch(totals) {
785
+ return totals.executable === 0;
786
+ }
766
787
  function pct(covered, executable) {
767
788
  if (executable === 0) return 0;
768
789
  return Math.round(covered / executable * 1e3) / 10;
@@ -862,10 +883,10 @@ function uncoveredRanges(file) {
862
883
  function buildDiffOutput(args) {
863
884
  const patch = computePatchCoverage(args.files, args.addedByFile);
864
885
  const project = computeProjectCoverage(args.files);
865
- const fileNames = /* @__PURE__ */ new Set([
866
- ...patch.byFile.keys(),
867
- ...project.byFile.keys()
868
- ]);
886
+ const emptyPatch = isEmptyPatch(patch.totals);
887
+ const fileNames = new Set(
888
+ emptyPatch ? [] : [...patch.byFile.keys(), ...project.byFile.keys()]
889
+ );
869
890
  const files = [];
870
891
  for (const name of fileNames) {
871
892
  const file = args.files.find((f) => f.path === name);
@@ -882,7 +903,7 @@ function buildDiffOutput(args) {
882
903
  schemaVersion: 1,
883
904
  base: args.base,
884
905
  head: args.head,
885
- patch: patch.totals,
906
+ patch: emptyPatch ? { ...patch.totals, empty: true } : patch.totals,
886
907
  project: { ...project.totals, delta: args.projectDelta ?? null },
887
908
  files,
888
909
  ignored: [...args.ignored]
@@ -1508,6 +1529,7 @@ function registerPushCommand(program2) {
1508
1529
 
1509
1530
  // src/commands/doctor.ts
1510
1531
  var TESTED_BIN_BASENAME_RE = /^tested(\.js)?$/;
1532
+ var MIN_NODE_MAJOR = 24;
1511
1533
  function statusBadge(status) {
1512
1534
  const kind = status === "pass" ? "pass" : status === "fail" ? "fail" : status === "warn" ? "warn" : "info";
1513
1535
  return badge(kind);
@@ -1574,20 +1596,19 @@ async function runDoctor(deps) {
1574
1596
  const checks = [];
1575
1597
  const major = parseNodeMajor(nodeVersion);
1576
1598
  const nodeDisplay = nodeVersion.replace(/^v/, "v");
1577
- if (major !== null && major >= 22) {
1599
+ if (major !== null && major >= MIN_NODE_MAJOR) {
1578
1600
  checks.push({
1579
1601
  id: "node",
1580
1602
  label: "Node.js",
1581
1603
  status: "pass",
1582
- detail: `${nodeDisplay} (>= 20.19; 22+ recommended)`
1604
+ detail: `${nodeDisplay} (>= ${MIN_NODE_MAJOR})`
1583
1605
  });
1584
1606
  } else {
1585
1607
  checks.push({
1586
1608
  id: "node",
1587
1609
  label: "Node.js",
1588
- status: "warn",
1589
- detail: `${nodeDisplay} runs this CLI. Node >= 22 recommended.`,
1590
- optional: true
1610
+ status: "fail",
1611
+ detail: `${nodeDisplay} is below ${MIN_NODE_MAJOR}. Node >= ${MIN_NODE_MAJOR} required.`
1591
1612
  });
1592
1613
  }
1593
1614
  let isRepo = false;
@@ -1809,7 +1830,7 @@ async function runDoctor(deps) {
1809
1830
  optional: true
1810
1831
  });
1811
1832
  }
1812
- const hardFailIds = /* @__PURE__ */ new Set(["git", "config", "origin"]);
1833
+ const hardFailIds = /* @__PURE__ */ new Set(["node", "git", "config", "origin"]);
1813
1834
  const safetyFailIds = /* @__PURE__ */ new Set(["api_url", "tested_bin", "token"]);
1814
1835
  const hasHardFail = checks.some(
1815
1836
  (c) => c.status === "fail" && hardFailIds.has(c.id)
@@ -1854,6 +1875,73 @@ function registerDoctorCommand(program2) {
1854
1875
 
1855
1876
  // src/commands/setup.ts
1856
1877
  import pc3 from "picocolors";
1878
+
1879
+ // package.json
1880
+ var package_default = {
1881
+ name: "@tested/cli",
1882
+ version: "0.1.2",
1883
+ description: "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
1884
+ license: "MIT",
1885
+ homepage: "https://tested.dev",
1886
+ repository: {
1887
+ type: "git",
1888
+ url: "git+https://github.com/tested-hq/cli.git"
1889
+ },
1890
+ bugs: {
1891
+ url: "https://github.com/tested-hq/cli/issues"
1892
+ },
1893
+ publishConfig: {
1894
+ access: "public"
1895
+ },
1896
+ type: "module",
1897
+ packageManager: "pnpm@11.3.0",
1898
+ bin: {
1899
+ tested: "./dist/tested.js",
1900
+ td: "./dist/tested.js"
1901
+ },
1902
+ files: [
1903
+ "dist",
1904
+ "README.md",
1905
+ "LICENSE"
1906
+ ],
1907
+ scripts: {
1908
+ build: "tsup",
1909
+ dev: "tsx bin/tested.ts",
1910
+ test: "vitest run",
1911
+ "test:watch": "vitest",
1912
+ "test:coverage": "vitest run --coverage",
1913
+ typecheck: "tsc --noEmit",
1914
+ prepare: "node ./scripts/prepare.mjs",
1915
+ prepublishOnly: "pnpm run build"
1916
+ },
1917
+ engines: {
1918
+ node: ">=24"
1919
+ },
1920
+ dependencies: {
1921
+ commander: "14.0.3",
1922
+ minimatch: "10.2.5",
1923
+ picocolors: "1.1.1",
1924
+ "simple-git": "3.36.0",
1925
+ yaml: "2.9.0",
1926
+ zod: "4.4.3"
1927
+ },
1928
+ devDependencies: {
1929
+ "@types/node": "25.9.1",
1930
+ "@vitest/coverage-v8": "4.1.7",
1931
+ husky: "9.1.7",
1932
+ "is-ci": "3.0.1",
1933
+ tsup: "8.5.1",
1934
+ tsx: "4.22.3",
1935
+ typescript: "6.0.3",
1936
+ vite: "8.0.14",
1937
+ vitest: "4.1.7"
1938
+ }
1939
+ };
1940
+
1941
+ // src/version.ts
1942
+ var CLI_VERSION = package_default.version;
1943
+
1944
+ // src/commands/setup.ts
1857
1945
  var PINNED_CLI = "@tested/cli";
1858
1946
  function buildCiSnippet() {
1859
1947
  return [
@@ -1865,12 +1953,9 @@ function buildCiSnippet() {
1865
1953
  " runs-on: ubuntu-latest",
1866
1954
  " steps:",
1867
1955
  " - uses: actions/checkout@v4",
1868
- " with:",
1869
- " fetch-depth: 0",
1870
1956
  " - uses: tested-hq/cli/action@main",
1871
1957
  " with:",
1872
- " # pin ref for reproducible installs (do not use floating tags in prod)",
1873
- " cli-ref: main",
1958
+ ` version: ${CLI_VERSION}`,
1874
1959
  " push: true",
1875
1960
  " pr-number: ${{ github.event.pull_request.number }}",
1876
1961
  " token: ${{ secrets.TESTED_TOKEN }}"
@@ -1887,6 +1972,7 @@ function buildTokenInstructions() {
1887
1972
  function buildInstallInstructions() {
1888
1973
  return [
1889
1974
  "Install CLI:",
1975
+ " Node 24+",
1890
1976
  ` pnpm add -D ${PINNED_CLI}`,
1891
1977
  ` # or: npx ${PINNED_CLI}`,
1892
1978
  "",
@@ -1931,6 +2017,12 @@ function formatSetupHuman(opts) {
1931
2017
  ])
1932
2018
  );
1933
2019
  lines.push("");
2020
+ lines.push(
2021
+ dim(
2022
+ "Patch gate is skipped when the diff has no executable lines (tests-only, docs, comments)."
2023
+ )
2024
+ );
2025
+ lines.push("");
1934
2026
  lines.push(tip("re-check anytime: tested doctor"));
1935
2027
  lines.push("");
1936
2028
  return lines.join("\n");
@@ -2196,8 +2288,8 @@ function coloredPctCell(pct3, width = 6) {
2196
2288
  function formatMetricRow(label, pct3, covered, executable, emptyNote) {
2197
2289
  const labelPad = label.padEnd(8);
2198
2290
  if (executable === 0) {
2199
- const note = emptyNote ?? "no executable lines";
2200
- return ` ${labelPad} ${dim("-".padEnd(6))} ${dim(note)}`;
2291
+ const note = emptyNote ?? EMPTY_PATCH_REASON;
2292
+ return ` ${labelPad} ${dim("-".padEnd(6))} ${note}`;
2201
2293
  }
2202
2294
  const pctStr = coloredPctCell(pct3);
2203
2295
  const bar = metricBar(pct3);
@@ -2216,7 +2308,7 @@ function formatHuman(out, opts = {}) {
2216
2308
  out.patch.pct,
2217
2309
  out.patch.covered,
2218
2310
  out.patch.executable,
2219
- "no executable lines in patch"
2311
+ EMPTY_PATCH_REASON
2220
2312
  )
2221
2313
  );
2222
2314
  const projectRow = formatMetricRow(
@@ -2229,7 +2321,7 @@ function formatHuman(out, opts = {}) {
2229
2321
  if (opts.thresholds) {
2230
2322
  const patchPass = out.patch.pct >= opts.thresholds.patch;
2231
2323
  const projectPass = out.project.pct >= opts.thresholds.project;
2232
- const patchOk = out.patch.executable === 0 ? true : patchPass;
2324
+ const patchOk = isEmptyPatch(out.patch) ? true : patchPass;
2233
2325
  const overall = patchOk && projectPass;
2234
2326
  const details = [];
2235
2327
  if (!patchOk) {
@@ -2240,7 +2332,7 @@ function formatHuman(out, opts = {}) {
2240
2332
  `project ${out.project.pct.toFixed(1)}% < ${opts.thresholds.project}%`
2241
2333
  );
2242
2334
  }
2243
- const detail = details.length > 0 ? dim(` ${details.join("; ")}`) : "";
2335
+ const detail = details.length > 0 ? dim(` ${details.join("; ")}`) : isEmptyPatch(out.patch) ? dim(` ${EMPTY_PATCH_REASON}`) : "";
2244
2336
  lines.push(
2245
2337
  ` ${"Gate".padEnd(8)} ${overall ? badge("pass") : badge("fail")}${detail}`
2246
2338
  );
@@ -2253,12 +2345,15 @@ function formatHuman(out, opts = {}) {
2253
2345
  );
2254
2346
  }
2255
2347
  }
2256
- if (out.files.length > 0) {
2348
+ if (isEmptyPatch(out.patch)) {
2349
+ lines.push("");
2350
+ lines.push(dim("No executable lines in the patch \u2014 patch gate does not apply."));
2351
+ } else if (out.files.length > 0) {
2257
2352
  lines.push("");
2258
2353
  lines.push(heading("Files in diff:"));
2259
2354
  const anyPatch = out.files.some((f) => f.patchCoverage !== null);
2260
2355
  if (!anyPatch) {
2261
- lines.push(dim(" (project coverage \u2014 no executable lines in patch)"));
2356
+ lines.push(dim(` (project coverage \u2014 ${EMPTY_PATCH_REASON})`));
2262
2357
  }
2263
2358
  for (const f of out.files) {
2264
2359
  const hasPatch = f.patchCoverage !== null;
@@ -2344,7 +2439,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2344
2439
  const projectPct = diff.project.pct;
2345
2440
  const patchThreshold = config.thresholds.patch;
2346
2441
  const projectThreshold = config.thresholds.project;
2347
- const patchSkipped = diff.patch.executable === 0;
2442
+ const patchSkipped = isEmptyPatch(diff.patch);
2348
2443
  const patchPass = patchSkipped ? true : patchPct >= patchThreshold;
2349
2444
  const projectPass = projectPct >= projectThreshold;
2350
2445
  const overall = patchPass && projectPass ? "pass" : "fail";
@@ -2355,10 +2450,11 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2355
2450
  pct: patchPct,
2356
2451
  threshold: patchThreshold,
2357
2452
  pass: patchPass,
2358
- ...patchSkipped ? { skipped: true } : {}
2453
+ ...patchSkipped ? { skipped: true, reason: EMPTY_PATCH_REASON } : {}
2359
2454
  },
2360
2455
  project: { pct: projectPct, threshold: projectThreshold, pass: projectPass },
2361
- overall
2456
+ overall,
2457
+ ...patchSkipped ? { note: EMPTY_PATCH_REASON } : {}
2362
2458
  };
2363
2459
  return {
2364
2460
  skipped: false,
@@ -2377,7 +2473,7 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2377
2473
  lines.push("");
2378
2474
  if (patchSkipped) {
2379
2475
  lines.push(
2380
- ` ${"Patch".padEnd(8)} ${dim("-")} ${dim("(no executable lines \u2014 skipped)")} ${badge("info")}`
2476
+ ` ${"Patch".padEnd(8)} ${dim("-")} ${EMPTY_PATCH_REASON} ${badge("skip")}`
2381
2477
  );
2382
2478
  } else {
2383
2479
  lines.push(formatMetricLine("Patch", patchPct, patchThreshold, patchPass));
@@ -2385,10 +2481,17 @@ ${tip("add thresholds.patch / thresholds.project to enforce")}
2385
2481
  lines.push(formatMetricLine("Project", projectPct, projectThreshold, projectPass));
2386
2482
  if (overall === "fail") {
2387
2483
  lines.push("");
2484
+ if (patchSkipped) {
2485
+ lines.push(dim("No executable lines in the patch \u2014 patch gate skipped."));
2486
+ }
2388
2487
  lines.push(tip("add tests for uncovered ranges: tested diff"));
2389
2488
  } else {
2390
2489
  lines.push("");
2391
- lines.push(dim(patchSkipped ? "project thresholds met (patch skipped)" : "thresholds met"));
2490
+ lines.push(
2491
+ dim(
2492
+ patchSkipped ? "No executable lines in the patch \u2014 patch gate skipped. Project threshold met." : "thresholds met"
2493
+ )
2494
+ );
2392
2495
  }
2393
2496
  lines.push("");
2394
2497
  return {
@@ -2561,7 +2664,7 @@ function createProgram() {
2561
2664
  "Agent loop:",
2562
2665
  " tested setup \u2192 tested run \u2192 tested diff \u2192 tested check \u2192 tested push --pr <n>"
2563
2666
  ].join("\n")
2564
- ).version("0.1.0");
2667
+ ).version(CLI_VERSION);
2565
2668
  registerSetupCommand(program2);
2566
2669
  registerDoctorCommand(program2);
2567
2670
  registerInitCommand(program2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tested/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Coverage your agent can use. CLI for patch + project coverage with agent-readable JSON output.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tested.dev",
@@ -18,7 +18,7 @@
18
18
  "packageManager": "pnpm@11.3.0",
19
19
  "bin": {
20
20
  "tested": "./dist/tested.js",
21
- "td": "./dist/td.js"
21
+ "td": "./dist/tested.js"
22
22
  },
23
23
  "files": [
24
24
  "dist",
@@ -36,7 +36,7 @@
36
36
  "prepublishOnly": "pnpm run build"
37
37
  },
38
38
  "engines": {
39
- "node": ">=20.19.0"
39
+ "node": ">=24"
40
40
  },
41
41
  "dependencies": {
42
42
  "commander": "14.0.3",