@bensandee/tooling 0.55.2 → 0.57.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
@@ -162,6 +162,80 @@ Convention and overrides coexist — a repo can use both.
162
162
 
163
163
  Works for both `changesets` and `simple` release strategies, on Forgejo (via API upload) and GitHub (via `gh release upload --clobber`). The asset step in the workflow is gated on the release itself having published/pushed, so failed releases never upload stale artifacts.
164
164
 
165
+ ### Coverage tracking
166
+
167
+ | Command | Description |
168
+ | -------------------------- | --------------------------------------------------------------------------------------------- |
169
+ | `tooling coverage:summary` | Print a labeled per-package summary of the latest run. `--json` for machine-readable form. |
170
+ | `tooling coverage:check` | Compare the latest run against `coverage-baseline.json`; exit non-zero on regression. |
171
+ | `tooling coverage:record` | Append the latest run to a `bst/coverage-metrics` orphan branch (CI: push to default branch). |
172
+ | `tooling coverage:status` | Show recent coverage trend. Read-only — fetches the orphan branch, no checkout. |
173
+
174
+ The feature has two halves: a **CI history** that captures one record per commit on the default branch (visible to anyone with the repo), and a **local ratchet** that the agent (or developer) consults before declaring a task complete.
175
+
176
+ #### How it works
177
+
178
+ `repo:sync` enables coverage tracking automatically when a `vitest.config.ts` is present. The generated `vitest.config.ts` includes the `json-summary` reporter so each `pnpm test:coverage` writes `coverage/coverage-summary.json` alongside the human-readable HTML report. A generated `coverage.yml` workflow runs on push to the default branch and pipes:
179
+
180
+ ```text
181
+ pnpm test:coverage → coverage:summary → coverage:record → coverage:status
182
+ ```
183
+
184
+ so every CI log ends with the trend table for the last 10 records.
185
+
186
+ The history file (`coverage-history.jsonl`) lives on a dedicated `bst/coverage-metrics` orphan branch — never on `main`. This sidesteps branch protection (no privileged token needed beyond `contents: write`) and keeps `git log main` clean. `coverage:record` uses pure git plumbing (`hash-object` → `mktree` → `commit-tree` → `push --force-with-lease`) so the working tree is never touched, and concurrent CI runs are handled safely via compare-and-swap with retry.
187
+
188
+ #### The local ratchet
189
+
190
+ A tracked `coverage-baseline.json` at the repo root captures the four overall totals (lines/statements/branches/functions) plus per-package numbers. `coverage:check` compares the latest run against it and exits non-zero if any total drops more than the configured `tolerancePp` (default 0.1). When all four are flat-or-up, `--update-baseline` rewrites the file so it can be staged with the change.
191
+
192
+ `repo:sync` also inserts a `<!-- @tooling:coverage-rule -->` managed block into `CLAUDE.md` describing the workflow:
193
+
194
+ > Before declaring a task complete that touched files under `packages/*/src/`, run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any total dropped, add tests until parity is restored — or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json`.
195
+
196
+ Skip the rule for docs-only, config-only, dependency-bump, or test-only changes.
197
+
198
+ #### Bootstrap
199
+
200
+ On a clean default branch, generate the initial baseline:
201
+
202
+ ```bash
203
+ pnpm test:coverage
204
+ pnpm exec bst coverage:check --update-baseline --no-fail-on-missing-baseline
205
+ git add coverage-baseline.json
206
+ ```
207
+
208
+ The first CI run on the default branch creates the orphan `bst/coverage-metrics` branch automatically. From any clone, inspect the trend with:
209
+
210
+ ```bash
211
+ pnpm exec bst coverage:status --limit 20
212
+ ```
213
+
214
+ #### Opt-out
215
+
216
+ Disable the feature entirely:
217
+
218
+ ```json
219
+ { "coverage": false }
220
+ ```
221
+
222
+ Or keep the local ratchet but skip the CI history append:
223
+
224
+ ```json
225
+ { "coverage": { "history": "none" } }
226
+ ```
227
+
228
+ | Override field | Default | Description |
229
+ | -------------------- | ------------------------ | ---------------------------------------------------------------------- |
230
+ | `history` | `"orphan-branch"` | `"none"` disables CI history; the local ratchet still works |
231
+ | `historyBranch` | `"bst/coverage-metrics"` | Override the orphan branch name |
232
+ | `tolerancePp` | `0.1` | Tolerance in percentage points before `coverage:check` flags a drop |
233
+ | `gateOnPullRequests` | `false` | Run coverage on PR builds too (deferred follow-up — currently a no-op) |
234
+
235
+ #### Per-package re-sum
236
+
237
+ The trimmed record's `packages` map is computed by aggregating files whose path starts with `packages/<name>/`, so monorepo packages get their own line/branch percentages without per-package vitest configs. Single-package repos collapse to one entry keyed by the directory basename. Per-package numbers are recorded but **not gated** — only the four overall totals control the ratchet.
238
+
165
239
  ### Docker
166
240
 
167
241
  | Command | Description |
package/dist/bin.mjs CHANGED
@@ -595,6 +595,12 @@ const DockerCheckConfigSchema = z.object({
595
595
  timeoutMs: z.number().int().positive().optional().meta({ description: "Overall timeout in milliseconds" }),
596
596
  pollIntervalMs: z.number().int().positive().optional().meta({ description: "Poll interval in milliseconds" })
597
597
  });
598
+ const CoverageConfigSchema = z.strictObject({
599
+ history: z.enum(["orphan-branch", "none"]).optional().meta({ description: "How coverage history is persisted. 'orphan-branch' (default) appends to a dedicated branch; 'none' disables CI history but keeps the local ratchet." }),
600
+ historyBranch: z.string().optional().meta({ description: "Orphan branch name for coverage history (default: 'bst/coverage-metrics')" }),
601
+ tolerancePp: z.number().nonnegative().optional().meta({ description: "Tolerance in percentage points for baseline comparison (default: 0.1)" }),
602
+ gateOnPullRequests: z.boolean().optional().meta({ description: "Run coverage on PR builds too (default: false; deferred follow-up)" })
603
+ });
598
604
  const ReleaseAssetSchema = z.strictObject({
599
605
  command: z.array(z.string()).nonempty().optional().meta({ description: "argv passed directly to the child process (no shell interpolation), e.g. [\"pnpm\",\"build\"]" }),
600
606
  file: z.string().meta({ description: "Path to the asset file to upload, relative to the project root" }),
@@ -637,7 +643,8 @@ const ToolingConfigSchema = z.strictObject({
637
643
  registryNamespace: z.string().optional().meta({ description: "Docker registry namespace (e.g. ghcr.io/myorg, code.example.com/myorg)" }),
638
644
  releaseAssets: z.array(ReleaseAssetSchema).optional().meta({ description: "Custom assets to build and upload to the release" }),
639
645
  postReleaseHook: z.string().optional().meta({ description: "Override the post-release hook command (default: POST to POST_RELEASE_HOOK_URL with HMAC-SHA256 auth)" }),
640
- postReleaseHookSecrets: z.array(z.string()).optional().meta({ description: "Additional secret names to inject as env vars in the post-release hook step (e.g. ['WEBHOOK_SECRET'])" })
646
+ postReleaseHookSecrets: z.array(z.string()).optional().meta({ description: "Additional secret names to inject as env vars in the post-release hook step (e.g. ['WEBHOOK_SECRET'])" }),
647
+ coverage: z.union([z.literal(false), CoverageConfigSchema]).optional().meta({ description: "Coverage tracking configuration. Set to false to disable; omit for default-on (when a vitest config is present)." })
641
648
  });
642
649
  /** Format a Zod issue as a human-readable bullet: location + plain-English explanation. */
643
650
  function formatIssue(issue) {
@@ -1522,6 +1529,7 @@ const STANDARD_SCRIPTS_SINGLE = {
1522
1529
  dev: "tsdown --watch",
1523
1530
  typecheck: "tsc --noEmit",
1524
1531
  test: "vitest run",
1532
+ "test:coverage": "vitest run --coverage",
1525
1533
  lint: "oxlint",
1526
1534
  knip: "knip",
1527
1535
  check: "bst checks:run",
@@ -1532,6 +1540,7 @@ const STANDARD_SCRIPTS_SINGLE = {
1532
1540
  const STANDARD_SCRIPTS_MONOREPO = {
1533
1541
  build: "pnpm -r build",
1534
1542
  test: "pnpm -r test",
1543
+ "test:coverage": "vitest run --coverage",
1535
1544
  typecheck: "pnpm -r --parallel run typecheck",
1536
1545
  lint: "oxlint",
1537
1546
  knip: "knip",
@@ -1791,7 +1800,7 @@ async function generatePackageJson(ctx) {
1791
1800
  const devDeps = { ...ROOT_DEV_DEPS };
1792
1801
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1793
1802
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1794
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.55.2";
1803
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.57.0";
1795
1804
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1796
1805
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1797
1806
  "@changesets/cli": "2.31.0",
@@ -2095,12 +2104,342 @@ function generateMonorepoPackageTsconfigs(ctx) {
2095
2104
  return results;
2096
2105
  }
2097
2106
  //#endregion
2107
+ //#region src/coverage-tracking/compare.ts
2108
+ const TOTAL_KEYS = [
2109
+ "lines",
2110
+ "statements",
2111
+ "branches",
2112
+ "functions"
2113
+ ];
2114
+ const DEFAULT_TOLERANCE_PP = .1;
2115
+ /** Round to 2 decimal places to keep diff output stable across runs. */
2116
+ function round2$1(n) {
2117
+ return Math.round(n * 100) / 100;
2118
+ }
2119
+ /**
2120
+ * Compare a current run against a baseline across the four overall totals.
2121
+ * A total passes when current >= baseline OR (baseline - current) <= tolerancePp.
2122
+ */
2123
+ function compareTotals(baseline, current, tolerancePp = DEFAULT_TOLERANCE_PP) {
2124
+ const totals = TOTAL_KEYS.map((key) => {
2125
+ const b = baseline[key];
2126
+ const c = current[key];
2127
+ return {
2128
+ key,
2129
+ baseline: b,
2130
+ current: c,
2131
+ delta: round2$1(c - b),
2132
+ pass: c >= b || b - c <= tolerancePp
2133
+ };
2134
+ });
2135
+ return {
2136
+ pass: totals.every((t) => t.pass),
2137
+ tolerancePp,
2138
+ totals
2139
+ };
2140
+ }
2141
+ /** True iff every total is flat-or-up (delta >= 0). Used to gate baseline updates. */
2142
+ function isFlatOrUp(baseline, current) {
2143
+ return TOTAL_KEYS.every((key) => current[key] >= baseline[key]);
2144
+ }
2145
+ //#endregion
2146
+ //#region src/coverage-tracking/parse.ts
2147
+ const TotalEntrySchema = z.object({
2148
+ total: z.number(),
2149
+ covered: z.number(),
2150
+ skipped: z.number().optional(),
2151
+ pct: z.number()
2152
+ });
2153
+ const FileEntrySchema = z.object({
2154
+ lines: TotalEntrySchema,
2155
+ statements: TotalEntrySchema,
2156
+ branches: TotalEntrySchema,
2157
+ functions: TotalEntrySchema
2158
+ });
2159
+ /**
2160
+ * Schema for v8 / istanbul `coverage/coverage-summary.json`. The "total" key
2161
+ * holds the grand totals; every other key is a per-file path → entry mapping.
2162
+ */
2163
+ const V8CoverageSummarySchema = z.object({ total: FileEntrySchema }).catchall(FileEntrySchema);
2164
+ const TotalsSchema = z.object({
2165
+ lines: z.number(),
2166
+ statements: z.number(),
2167
+ branches: z.number(),
2168
+ functions: z.number()
2169
+ });
2170
+ const PackageEntrySchema = z.object({
2171
+ lines: z.number(),
2172
+ branches: z.number()
2173
+ });
2174
+ /** Coverage record without identity fields — body shape for `coverage-baseline.json`. */
2175
+ const CoverageBaselineSchema = z.object({
2176
+ totals: TotalsSchema,
2177
+ packages: z.record(z.string(), PackageEntrySchema)
2178
+ });
2179
+ /** Full trimmed record — appended to `coverage-history.jsonl` and emitted by `coverage:summary`. */
2180
+ const CoverageRecordSchema = CoverageBaselineSchema.extend({
2181
+ sha: z.string(),
2182
+ timestamp: z.string()
2183
+ });
2184
+ /** Parse a v8 `coverage-summary.json` blob; throws FatalError on malformed input. */
2185
+ function parseV8Summary(raw) {
2186
+ let json;
2187
+ try {
2188
+ json = JSON.parse(raw);
2189
+ } catch (error) {
2190
+ throw new FatalError(`coverage-summary.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2191
+ }
2192
+ const result = V8CoverageSummarySchema.safeParse(json);
2193
+ if (!result.success) throw new FatalError(`coverage-summary.json shape doesn't match v8 / istanbul output: ${result.error.message}`);
2194
+ return result.data;
2195
+ }
2196
+ /** Parse a `coverage-baseline.json` blob; throws FatalError on malformed input. */
2197
+ function parseBaseline(raw) {
2198
+ let json;
2199
+ try {
2200
+ json = JSON.parse(raw);
2201
+ } catch (error) {
2202
+ throw new FatalError(`coverage-baseline.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2203
+ }
2204
+ const result = CoverageBaselineSchema.safeParse(json);
2205
+ if (!result.success) throw new FatalError(`coverage-baseline.json has unexpected shape: ${result.error.message}`);
2206
+ return result.data;
2207
+ }
2208
+ /** Parse a single line of `coverage-history.jsonl`; returns undefined for malformed lines. */
2209
+ function parseHistoryLine(line) {
2210
+ let json;
2211
+ try {
2212
+ json = JSON.parse(line);
2213
+ } catch (_error) {
2214
+ return;
2215
+ }
2216
+ const result = CoverageRecordSchema.safeParse(json);
2217
+ return result.success ? result.data : void 0;
2218
+ }
2219
+ //#endregion
2220
+ //#region src/coverage-tracking/history.ts
2221
+ const DEFAULT_HISTORY_BRANCH = "bst/coverage-metrics";
2222
+ const HISTORY_FILE = "coverage-history.jsonl";
2223
+ const MAX_PUSH_ATTEMPTS = 5;
2224
+ function remoteRef(config) {
2225
+ return `refs/remotes/${config.remote ?? "origin"}/${config.historyBranch}`;
2226
+ }
2227
+ function fetchBranch(executor, config) {
2228
+ const remote = config.remote ?? "origin";
2229
+ if (executor.exec("git", [
2230
+ "fetch",
2231
+ remote,
2232
+ `${config.historyBranch}:${remoteRef(config)}`
2233
+ ], { cwd: config.cwd }).exitCode !== 0) return { exists: false };
2234
+ const revParse = executor.exec("git", [
2235
+ "rev-parse",
2236
+ "--quiet",
2237
+ "--verify",
2238
+ remoteRef(config)
2239
+ ], { cwd: config.cwd });
2240
+ if (revParse.exitCode !== 0) return { exists: false };
2241
+ return {
2242
+ exists: true,
2243
+ parentSha: revParse.stdout.trim()
2244
+ };
2245
+ }
2246
+ function readRemoteHistory(executor, config) {
2247
+ const showResult = executor.exec("git", ["show", `${remoteRef(config)}:${HISTORY_FILE}`], { cwd: config.cwd });
2248
+ if (showResult.exitCode !== 0) return "";
2249
+ return showResult.stdout;
2250
+ }
2251
+ function makeBlob(executor, cwd, content) {
2252
+ const result = executor.exec("git", [
2253
+ "hash-object",
2254
+ "-w",
2255
+ "--stdin"
2256
+ ], {
2257
+ cwd,
2258
+ input: content
2259
+ });
2260
+ if (result.exitCode !== 0) throw new FatalError(`git hash-object failed: ${result.stderr.trim() || "unknown error"}`);
2261
+ return result.stdout.trim();
2262
+ }
2263
+ function makeTree(executor, cwd, blobSha) {
2264
+ const treeInput = `100644 blob ${blobSha}\t${HISTORY_FILE}\n`;
2265
+ const result = executor.exec("git", ["mktree"], {
2266
+ cwd,
2267
+ input: treeInput
2268
+ });
2269
+ if (result.exitCode !== 0) throw new FatalError(`git mktree failed: ${result.stderr.trim() || "unknown error"}`);
2270
+ return result.stdout.trim();
2271
+ }
2272
+ function makeCommit(executor, config, treeSha, parentSha, message) {
2273
+ const env = {};
2274
+ if (config.authorName) {
2275
+ env["GIT_AUTHOR_NAME"] = config.authorName;
2276
+ env["GIT_COMMITTER_NAME"] = config.authorName;
2277
+ }
2278
+ if (config.authorEmail) {
2279
+ env["GIT_AUTHOR_EMAIL"] = config.authorEmail;
2280
+ env["GIT_COMMITTER_EMAIL"] = config.authorEmail;
2281
+ }
2282
+ const args = parentSha ? [
2283
+ "commit-tree",
2284
+ "-p",
2285
+ parentSha,
2286
+ "-m",
2287
+ message,
2288
+ treeSha
2289
+ ] : [
2290
+ "commit-tree",
2291
+ "-m",
2292
+ message,
2293
+ treeSha
2294
+ ];
2295
+ const result = executor.exec("git", args, {
2296
+ cwd: config.cwd,
2297
+ env
2298
+ });
2299
+ if (result.exitCode !== 0) throw new FatalError(`git commit-tree failed: ${result.stderr.trim() || "unknown error"}`);
2300
+ return result.stdout.trim();
2301
+ }
2302
+ function pushCommit(executor, config, commitSha, parentSha) {
2303
+ const remote = config.remote ?? "origin";
2304
+ const refSpec = `${commitSha}:refs/heads/${config.historyBranch}`;
2305
+ if (parentSha === void 0) return executor.exec("git", [
2306
+ "push",
2307
+ remote,
2308
+ refSpec
2309
+ ], { cwd: config.cwd });
2310
+ const lease = `--force-with-lease=refs/heads/${config.historyBranch}:${parentSha}`;
2311
+ return executor.exec("git", [
2312
+ "push",
2313
+ lease,
2314
+ remote,
2315
+ refSpec
2316
+ ], { cwd: config.cwd });
2317
+ }
2318
+ function shortSha(sha) {
2319
+ return sha.slice(0, 7);
2320
+ }
2321
+ /**
2322
+ * Append a coverage record to the orphan branch. Bootstraps the branch on first run.
2323
+ * Uses pure git plumbing (no worktree / no checkout) so the working tree stays untouched.
2324
+ * On push conflict (concurrent record), refetches and rebuilds the commit (up to MAX_PUSH_ATTEMPTS).
2325
+ */
2326
+ function appendRecord(executor, config, record) {
2327
+ const line = JSON.stringify(record) + "\n";
2328
+ let lastError = "";
2329
+ for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
2330
+ const fetched = fetchBranch(executor, config);
2331
+ const newContent = (fetched.exists ? readRemoteHistory(executor, config) : "") + line;
2332
+ const blobSha = makeBlob(executor, config.cwd, newContent);
2333
+ const treeSha = makeTree(executor, config.cwd, blobSha);
2334
+ const message = `chore(coverage): record ${shortSha(record.sha)} [skip ci]`;
2335
+ const commitSha = makeCommit(executor, config, treeSha, fetched.parentSha, message);
2336
+ const pushResult = pushCommit(executor, config, commitSha, fetched.parentSha);
2337
+ if (pushResult.exitCode === 0) return {
2338
+ commitSha,
2339
+ bootstrapped: !fetched.exists,
2340
+ attempts: attempt
2341
+ };
2342
+ lastError = pushResult.stderr.trim() || pushResult.stdout.trim() || "unknown push error";
2343
+ }
2344
+ throw new FatalError(`Failed to push to ${config.historyBranch} after ${MAX_PUSH_ATTEMPTS} attempts: ${lastError}`);
2345
+ }
2346
+ /**
2347
+ * Read all records from the orphan branch (read-only). Performs a shallow fetch and
2348
+ * reads via `git show` — no working-tree disruption, no local branch creation.
2349
+ * Returns an empty array if the branch doesn't exist yet.
2350
+ */
2351
+ function readRecords(executor, config) {
2352
+ if (!fetchBranch(executor, config).exists) return [];
2353
+ const raw = readRemoteHistory(executor, config);
2354
+ const records = [];
2355
+ for (const line of raw.split("\n")) {
2356
+ if (line.trim() === "") continue;
2357
+ const parsed = parseHistoryLine(line);
2358
+ if (parsed) records.push(parsed);
2359
+ }
2360
+ return records;
2361
+ }
2362
+ //#endregion
2363
+ //#region src/coverage-tracking/config.ts
2364
+ /** Resolve the effective coverage configuration from explicit inputs (testable). */
2365
+ function resolveFromInputs(inputs) {
2366
+ if (inputs.coverage === false) return {
2367
+ enabled: false,
2368
+ historyEnabled: false,
2369
+ historyBranch: DEFAULT_HISTORY_BRANCH,
2370
+ tolerancePp: DEFAULT_TOLERANCE_PP,
2371
+ gateOnPullRequests: false
2372
+ };
2373
+ const enabled = inputs.hasVitestConfig || inputs.setupVitest;
2374
+ const overrides = inputs.coverage ?? {};
2375
+ return {
2376
+ enabled,
2377
+ historyEnabled: enabled && overrides.history !== "none",
2378
+ historyBranch: overrides.historyBranch ?? "bst/coverage-metrics",
2379
+ tolerancePp: overrides.tolerancePp ?? .1,
2380
+ gateOnPullRequests: overrides.gateOnPullRequests ?? false
2381
+ };
2382
+ }
2383
+ /**
2384
+ * Resolve from the real filesystem (used by CLI commands).
2385
+ * Default-on when a vitest config is detected and `.tooling.json#coverage` is not `false`.
2386
+ */
2387
+ function resolveCoverageConfig(targetDir) {
2388
+ const tooling = safeLoadToolingConfig(targetDir);
2389
+ return resolveFromInputs({
2390
+ hasVitestConfig: existsSync(path.join(targetDir, "vitest.config.ts")) || existsSync(path.join(targetDir, "vitest.config.mts")),
2391
+ setupVitest: tooling?.setupVitest === true,
2392
+ coverage: tooling?.coverage
2393
+ });
2394
+ }
2395
+ const CoverageOverridesShape = z.object({
2396
+ history: z.enum(["orphan-branch", "none"]).optional(),
2397
+ historyBranch: z.string().optional(),
2398
+ tolerancePp: z.number().nonnegative().optional(),
2399
+ gateOnPullRequests: z.boolean().optional()
2400
+ });
2401
+ const CoverageFieldSchema = z.union([z.literal(false), CoverageOverridesShape]);
2402
+ const ToolingShallowSchema = z.object({
2403
+ setupVitest: z.boolean().optional(),
2404
+ coverage: CoverageFieldSchema.optional()
2405
+ });
2406
+ /** Resolve from a generator context (used by generators for mock-friendly testing). */
2407
+ function resolveCoverageConfigFromContext(reader, fallbackSetupVitest) {
2408
+ const toolingRaw = reader.read(".tooling.json");
2409
+ let setupVitest = fallbackSetupVitest;
2410
+ let coverage;
2411
+ if (toolingRaw) try {
2412
+ const result = ToolingShallowSchema.safeParse(JSON.parse(toolingRaw));
2413
+ if (result.success) {
2414
+ if (typeof result.data.setupVitest === "boolean") setupVitest = result.data.setupVitest;
2415
+ coverage = result.data.coverage;
2416
+ }
2417
+ } catch (_error) {}
2418
+ return resolveFromInputs({
2419
+ hasVitestConfig: reader.exists("vitest.config.ts") || reader.exists("vitest.config.mts"),
2420
+ setupVitest,
2421
+ coverage
2422
+ });
2423
+ }
2424
+ /** Like loadToolingConfig but returns undefined on any error so commands can degrade gracefully. */
2425
+ function safeLoadToolingConfig(targetDir) {
2426
+ try {
2427
+ return loadToolingConfig(targetDir);
2428
+ } catch (_error) {
2429
+ return;
2430
+ }
2431
+ }
2432
+ //#endregion
2098
2433
  //#region src/generators/vitest.ts
2099
2434
  const VITEST_CONFIG = `import { defineConfig } from "vitest/config";
2100
2435
 
2101
2436
  export default defineConfig({
2102
2437
  test: {
2103
2438
  include: ["test/**/*.test.ts"],
2439
+ coverage: {
2440
+ provider: "v8",
2441
+ reporter: ["text", "html", "lcov", "json-summary"],
2442
+ },
2104
2443
  },
2105
2444
  });
2106
2445
  `;
@@ -2112,18 +2451,50 @@ describe("example", () => {
2112
2451
  });
2113
2452
  });
2114
2453
  `;
2454
+ const ROOT_CONFIG_PATHS = ["vitest.config.ts", "vitest.config.mts"];
2455
+ function findJsonSummaryIssue(content) {
2456
+ if (!/\bcoverage\s*:\s*\{/.test(content)) return "missing-coverage";
2457
+ const reporterMatch = /\breporter\s*:\s*\[([\s\S]*?)\]/.exec(content);
2458
+ if (!reporterMatch) return "missing-reporter";
2459
+ if (!reporterMatch[1]?.includes("json-summary")) return "missing-json-summary";
2460
+ }
2461
+ function describeIssue(issue) {
2462
+ switch (issue) {
2463
+ case "missing-coverage": return "no `coverage` block found";
2464
+ case "missing-reporter": return "no `reporter` array inside `coverage` block";
2465
+ case "missing-json-summary": return "`coverage.reporter` does not include \"json-summary\"";
2466
+ }
2467
+ }
2468
+ function warnIfMissingJsonSummary(ctx) {
2469
+ if (!resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest).enabled) return;
2470
+ for (const rel of ROOT_CONFIG_PATHS) {
2471
+ if (!ctx.exists(rel)) continue;
2472
+ const content = ctx.read(rel);
2473
+ if (content === void 0) continue;
2474
+ const issue = findJsonSummaryIssue(content);
2475
+ if (issue === void 0) return;
2476
+ log.warn(`${rel}: ${describeIssue(issue)} — coverage tracking (bst coverage:summary / coverage:check) requires the "json-summary" reporter to read coverage/coverage-summary.json.`);
2477
+ return;
2478
+ }
2479
+ }
2115
2480
  async function generateVitest(ctx) {
2116
2481
  const results = [];
2117
- if (!ctx.config.setupVitest) return [{
2118
- filePath: "vitest.config.ts",
2119
- action: "skipped",
2120
- description: "Vitest setup not requested"
2121
- }];
2122
- if (ctx.config.structure === "monorepo") return [{
2123
- filePath: "vitest.config.ts",
2124
- action: "skipped",
2125
- description: "Monorepo: vitest config belongs in individual packages"
2126
- }];
2482
+ if (!ctx.config.setupVitest) {
2483
+ warnIfMissingJsonSummary(ctx);
2484
+ return [{
2485
+ filePath: "vitest.config.ts",
2486
+ action: "skipped",
2487
+ description: "Vitest setup not requested"
2488
+ }];
2489
+ }
2490
+ if (ctx.config.structure === "monorepo") {
2491
+ warnIfMissingJsonSummary(ctx);
2492
+ return [{
2493
+ filePath: "vitest.config.ts",
2494
+ action: "skipped",
2495
+ description: "Monorepo: vitest config belongs in individual packages"
2496
+ }];
2497
+ }
2127
2498
  const configPath = "vitest.config.ts";
2128
2499
  if (ctx.exists(configPath)) if (ctx.read(configPath) === VITEST_CONFIG) results.push({
2129
2500
  filePath: configPath,
@@ -2160,6 +2531,7 @@ async function generateVitest(ctx) {
2160
2531
  description: "Generated starter test file"
2161
2532
  });
2162
2533
  }
2534
+ warnIfMissingJsonSummary(ctx);
2163
2535
  return results;
2164
2536
  }
2165
2537
  //#endregion
@@ -2902,7 +3274,8 @@ const REQUIRED_ENTRIES = [
2902
3274
  const OPTIONAL_ENTRIES = [
2903
3275
  ".tooling-migrate.md",
2904
3276
  ".tooling-archived/",
2905
- ".claude/hooks/bst-hook.audit.local.jsonl*"
3277
+ ".claude/hooks/bst-hook.audit.local.jsonl*",
3278
+ "coverage/"
2906
3279
  ];
2907
3280
  /**
2908
3281
  * Optional entries added only when a feature is actually configured.
@@ -4039,6 +4412,201 @@ async function generateVscodeSettings(ctx) {
4039
4412
  return results;
4040
4413
  }
4041
4414
  //#endregion
4415
+ //#region src/generators/coverage-ci.ts
4416
+ function coverageSteps(nodeVersionYaml) {
4417
+ return [
4418
+ { step: {
4419
+ uses: "actions/checkout@v6",
4420
+ with: { "fetch-depth": 0 }
4421
+ } },
4422
+ { step: { uses: "pnpm/action-setup@v5" } },
4423
+ { step: {
4424
+ uses: "actions/setup-node@v6",
4425
+ with: {
4426
+ ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
4427
+ cache: "pnpm"
4428
+ }
4429
+ } },
4430
+ { step: { run: "pnpm install --frozen-lockfile" } },
4431
+ { step: {
4432
+ name: "Build",
4433
+ run: "pnpm build"
4434
+ } },
4435
+ { step: {
4436
+ name: "Run tests with coverage",
4437
+ run: "pnpm test:coverage"
4438
+ } },
4439
+ { step: {
4440
+ name: "Print coverage summary",
4441
+ run: "pnpm exec bst coverage:summary"
4442
+ } },
4443
+ { step: {
4444
+ name: "Append record to bst/coverage-metrics branch",
4445
+ run: "pnpm exec bst coverage:record"
4446
+ } },
4447
+ { step: {
4448
+ name: "Show recent coverage trend",
4449
+ run: "pnpm exec bst coverage:status --limit 10"
4450
+ } }
4451
+ ];
4452
+ }
4453
+ async function generateCoverageCi(ctx) {
4454
+ const filePath = "coverage-ci";
4455
+ if (ctx.config.ci === "none") return {
4456
+ filePath,
4457
+ action: "skipped",
4458
+ description: "CI workflow not requested"
4459
+ };
4460
+ const config = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4461
+ if (!config.enabled || !config.historyEnabled) return {
4462
+ filePath,
4463
+ action: "skipped",
4464
+ description: "Coverage tracking disabled"
4465
+ };
4466
+ const isGitHub = ctx.config.ci === "github";
4467
+ const isForgejo = !isGitHub;
4468
+ const workflowPath = isGitHub ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
4469
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
4470
+ const content = buildWorkflowYaml({
4471
+ ci: ctx.config.ci,
4472
+ name: "Coverage",
4473
+ on: { push: {
4474
+ branches: ["main"],
4475
+ "tags-ignore": ["**"]
4476
+ } },
4477
+ permissions: { contents: "write" },
4478
+ enableEmailNotifications: isForgejo,
4479
+ jobName: "coverage",
4480
+ steps: coverageSteps(nodeVersionYaml)
4481
+ });
4482
+ const alreadyExists = ctx.exists(workflowPath);
4483
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
4484
+ if (existing) {
4485
+ if (isToolingIgnored(existing)) return {
4486
+ filePath: workflowPath,
4487
+ action: "skipped",
4488
+ description: "Coverage workflow has ignore comment"
4489
+ };
4490
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
4491
+ filePath: workflowPath,
4492
+ action: "skipped",
4493
+ description: "Coverage workflow already up to date"
4494
+ };
4495
+ }
4496
+ const withPinnedActions = preservePinnedActions(preserveDevBinaryPath(content, existing), existing);
4497
+ const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
4498
+ ctx.write(workflowPath, final);
4499
+ return {
4500
+ filePath: workflowPath,
4501
+ action: alreadyExists ? "updated" : "created",
4502
+ description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions coverage workflow`
4503
+ };
4504
+ }
4505
+ //#endregion
4506
+ //#region src/generators/claude-md.ts
4507
+ const FILE = "CLAUDE.md";
4508
+ const START_MARKER = "<!-- @tooling:coverage-rule -->";
4509
+ const END_MARKER = "<!-- @tooling:endcoverage-rule -->";
4510
+ /**
4511
+ * The coverage-baseline rule that the agent must consult before declaring a
4512
+ * task complete. Kept terse — the linked plan and `--help` carry the long form.
4513
+ */
4514
+ const RULE_BODY = [
4515
+ "### Coverage baseline",
4516
+ "",
4517
+ "Before declaring a task complete that touched files under `packages/*/src/` (or `src/` for single-package repos), run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any of the four overall totals (lines/statements/branches/functions) dropped by more than the configured tolerance, add tests until parity is restored — or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json` with the change. Skip this gate for docs-only, config-only, dependency-bump, or test-only changes."
4518
+ ].join("\n");
4519
+ /** Build the full managed block including markers. */
4520
+ function buildBlock() {
4521
+ return `${START_MARKER}\n\n${RULE_BODY}\n\n${END_MARKER}\n`;
4522
+ }
4523
+ function findBlock(existing) {
4524
+ const lines = existing.split("\n");
4525
+ let startLine = -1;
4526
+ for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() === START_MARKER) {
4527
+ startLine = i;
4528
+ break;
4529
+ }
4530
+ if (startLine === -1) return void 0;
4531
+ for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() === END_MARKER) return {
4532
+ startLine,
4533
+ endLine: i
4534
+ };
4535
+ }
4536
+ /**
4537
+ * Replace any existing `<!-- @tooling:coverage-rule -->...<!-- @tooling:endcoverage-rule -->`
4538
+ * block in `existing` with `block`, or append it at the end if no block is found.
4539
+ */
4540
+ function upsertBlock(existing, block) {
4541
+ const found = findBlock(existing);
4542
+ if (!found) return `${existing.replace(/\n+$/, "")}\n\n${block}`;
4543
+ const lines = existing.split("\n");
4544
+ const beforeLines = lines.slice(0, found.startLine);
4545
+ const afterLines = lines.slice(found.endLine + 1);
4546
+ const before = beforeLines.join("\n").replace(/\n+$/, "");
4547
+ const after = afterLines.join("\n").replace(/^\n+/, "");
4548
+ const tail = after.length > 0 ? `\n\n${after}` : "\n";
4549
+ return `${before}\n\n${block.replace(/\n+$/, "")}${tail}`;
4550
+ }
4551
+ /** Strip a coverage-rule managed block (markers + body) from CLAUDE.md content. */
4552
+ function removeBlock(existing) {
4553
+ const found = findBlock(existing);
4554
+ if (!found) return existing;
4555
+ const lines = existing.split("\n");
4556
+ const beforeLines = lines.slice(0, found.startLine);
4557
+ const afterLines = lines.slice(found.endLine + 1);
4558
+ const before = beforeLines.join("\n").replace(/\n+$/, "");
4559
+ const after = afterLines.join("\n").replace(/^\n+/, "");
4560
+ if (after.length === 0) return `${before}\n`;
4561
+ return `${before}\n\n${after}`;
4562
+ }
4563
+ const STARTER_CLAUDE_MD = "# CLAUDE.md\n\nProject guidance for Claude Code.\n";
4564
+ async function generateClaudeMd(ctx) {
4565
+ const config = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4566
+ const existing = ctx.read(FILE);
4567
+ if (!config.enabled) {
4568
+ if (!existing) return {
4569
+ filePath: FILE,
4570
+ action: "skipped",
4571
+ description: "Coverage disabled; no CLAUDE.md to manage"
4572
+ };
4573
+ if (!existing.includes(START_MARKER)) return {
4574
+ filePath: FILE,
4575
+ action: "skipped",
4576
+ description: "Coverage disabled; no managed block to remove"
4577
+ };
4578
+ const updated = removeBlock(existing);
4579
+ ctx.write(FILE, updated);
4580
+ return {
4581
+ filePath: FILE,
4582
+ action: "updated",
4583
+ description: "Removed coverage-rule managed block"
4584
+ };
4585
+ }
4586
+ const block = buildBlock();
4587
+ if (!existing) {
4588
+ const content = upsertBlock(STARTER_CLAUDE_MD, block);
4589
+ ctx.write(FILE, content);
4590
+ return {
4591
+ filePath: FILE,
4592
+ action: "created",
4593
+ description: "Generated CLAUDE.md with coverage-rule block"
4594
+ };
4595
+ }
4596
+ const updated = upsertBlock(existing, block);
4597
+ if (updated === existing) return {
4598
+ filePath: FILE,
4599
+ action: "skipped",
4600
+ description: "Coverage-rule block already up to date"
4601
+ };
4602
+ ctx.write(FILE, updated);
4603
+ return {
4604
+ filePath: FILE,
4605
+ action: "updated",
4606
+ description: "Updated coverage-rule managed block"
4607
+ };
4608
+ }
4609
+ //#endregion
4042
4610
  //#region src/generators/versionrc.ts
4043
4611
  function buildConfig() {
4044
4612
  return {
@@ -4127,6 +4695,8 @@ async function runGenerators(ctx) {
4127
4695
  results.push(await generateChangesets(ctx));
4128
4696
  results.push(await generateReleaseCi(ctx));
4129
4697
  results.push(await generateDeployCi(ctx));
4698
+ results.push(await generateCoverageCi(ctx));
4699
+ results.push(await generateClaudeMd(ctx));
4130
4700
  results.push(...await generateVitest(ctx));
4131
4701
  results.push(...await generateVscodeSettings(ctx));
4132
4702
  results.push(saveToolingConfig(ctx, ctx.config));
@@ -4141,7 +4711,7 @@ async function runGenerators(ctx) {
4141
4711
  function generateMigratePrompt(results, config, detected) {
4142
4712
  const sections = [];
4143
4713
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
4144
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.55.2 repo:sync\` on ${timestamp}_\n`);
4714
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.57.0 repo:sync\` on ${timestamp}_\n`);
4145
4715
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
4146
4716
  if (changed.length > 0) {
4147
4717
  const items = changed.map((r) => {
@@ -7111,11 +7681,451 @@ async function sendWebhookWithLogging(url, secret, payload) {
7111
7681
  else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
7112
7682
  }
7113
7683
  //#endregion
7684
+ //#region src/commands/webhook-send.ts
7685
+ const webhookSendCommand = defineCommand({
7686
+ meta: {
7687
+ name: "webhook:send",
7688
+ description: "Send a POST request to a webhook URL with GitHub-style HMAC-SHA256 authentication"
7689
+ },
7690
+ args: {
7691
+ url: {
7692
+ type: "positional",
7693
+ description: "Webhook URL to POST to. If empty or missing, the command exits successfully (no-op)."
7694
+ },
7695
+ tag: {
7696
+ type: "string",
7697
+ description: "Release tag name (e.g. v1.2.3). Falls back to GITHUB_REF env var."
7698
+ },
7699
+ repository: {
7700
+ type: "string",
7701
+ description: "Repository in owner/repo format. Falls back to GITHUB_REPOSITORY env var."
7702
+ },
7703
+ "server-url": {
7704
+ type: "string",
7705
+ description: "Server URL (e.g. https://github.com). Falls back to GITHUB_SERVER_URL env var."
7706
+ },
7707
+ actor: {
7708
+ type: "string",
7709
+ description: "User or bot that triggered the release. Falls back to GITHUB_ACTOR env var."
7710
+ },
7711
+ prerelease: {
7712
+ type: "boolean",
7713
+ description: "Mark the release as a prerelease"
7714
+ }
7715
+ },
7716
+ async run({ args }) {
7717
+ if (!args.url) {
7718
+ log.info("No webhook URL configured — skipping post-release hook");
7719
+ return;
7720
+ }
7721
+ const secret = process.env["WEBHOOK_SECRET"];
7722
+ const payload = args.tag || args.repository ? buildSimplePayload({
7723
+ tag: args.tag ?? "",
7724
+ repository: args.repository ?? process.env["GITHUB_REPOSITORY"] ?? "",
7725
+ serverUrl: args["server-url"] ?? process.env["GITHUB_SERVER_URL"] ?? "https://github.com",
7726
+ actor: args.actor ?? process.env["GITHUB_ACTOR"] ?? "github-actions[bot]",
7727
+ prerelease: args.prerelease ?? false
7728
+ }) : buildPayloadFromEnv();
7729
+ await sendWebhookWithLogging(args.url, secret || void 0, payload);
7730
+ }
7731
+ });
7732
+ //#endregion
7733
+ //#region src/coverage-tracking/derive.ts
7734
+ function emptySums() {
7735
+ return {
7736
+ linesTotal: 0,
7737
+ linesCovered: 0,
7738
+ branchesTotal: 0,
7739
+ branchesCovered: 0
7740
+ };
7741
+ }
7742
+ function addEntry(sums, entry) {
7743
+ sums.linesTotal += entry.lines.total;
7744
+ sums.linesCovered += entry.lines.covered;
7745
+ sums.branchesTotal += entry.branches.total;
7746
+ sums.branchesCovered += entry.branches.covered;
7747
+ }
7748
+ function pct(covered, total) {
7749
+ if (total === 0) return 0;
7750
+ return Math.round(covered / total * 1e3) / 10;
7751
+ }
7752
+ function toPackageEntry(sums) {
7753
+ return {
7754
+ lines: pct(sums.linesCovered, sums.linesTotal),
7755
+ branches: pct(sums.branchesCovered, sums.branchesTotal)
7756
+ };
7757
+ }
7758
+ function totalsFrom(entry) {
7759
+ return {
7760
+ lines: entry.lines.pct,
7761
+ statements: entry.statements.pct,
7762
+ branches: entry.branches.pct,
7763
+ functions: entry.functions.pct
7764
+ };
7765
+ }
7766
+ /** Match `<rootDir>/packages/<name>/...` and capture `<name>`. Posix-style; tolerates `\` on win32. */
7767
+ function packageNameFromPath(filePath, rootDir) {
7768
+ const normalized = filePath.split(path.sep).join("/");
7769
+ const rootNormalized = rootDir.split(path.sep).join("/").replace(/\/+$/, "");
7770
+ const rel = normalized.startsWith(`${rootNormalized}/`) ? normalized.slice(rootNormalized.length + 1) : normalized;
7771
+ return /^packages\/([^/]+)\//.exec(rel)?.[1];
7772
+ }
7773
+ /** Build a trimmed coverage record (or baseline body) from a parsed v8 summary. */
7774
+ function deriveBaseline(summary, options) {
7775
+ const totals = totalsFrom(summary.total);
7776
+ const perPackage = /* @__PURE__ */ new Map();
7777
+ const fallbackName = options.singlePackageName ?? path.basename(options.rootDir);
7778
+ const fallbackSums = emptySums();
7779
+ let sawAnyMonorepoPath = false;
7780
+ for (const [filePath, entry] of Object.entries(summary)) {
7781
+ if (filePath === "total") continue;
7782
+ const pkgName = packageNameFromPath(filePath, options.rootDir);
7783
+ if (pkgName) {
7784
+ sawAnyMonorepoPath = true;
7785
+ const sums = perPackage.get(pkgName) ?? emptySums();
7786
+ addEntry(sums, entry);
7787
+ perPackage.set(pkgName, sums);
7788
+ } else addEntry(fallbackSums, entry);
7789
+ }
7790
+ const packages = {};
7791
+ if (sawAnyMonorepoPath) for (const [name, sums] of perPackage) packages[name] = toPackageEntry(sums);
7792
+ else if (fallbackSums.linesTotal > 0 || fallbackSums.branchesTotal > 0) packages[fallbackName] = toPackageEntry(fallbackSums);
7793
+ return {
7794
+ totals,
7795
+ packages
7796
+ };
7797
+ }
7798
+ /** Build a full record (with identity) from a parsed v8 summary. */
7799
+ function deriveRecord(summary, options) {
7800
+ const baseline = deriveBaseline(summary, options);
7801
+ return {
7802
+ sha: options.identity.sha,
7803
+ timestamp: options.identity.timestamp,
7804
+ ...baseline
7805
+ };
7806
+ }
7807
+ //#endregion
7808
+ //#region src/coverage-tracking/executor.ts
7809
+ /** Real implementation of CoverageExecutor that shells out to git. */
7810
+ function createRealCoverageExecutor() {
7811
+ return { exec(file, args, options) {
7812
+ try {
7813
+ return {
7814
+ stdout: execFileSync(file, args, {
7815
+ cwd: options?.cwd,
7816
+ env: options?.env ? {
7817
+ ...process.env,
7818
+ ...options.env
7819
+ } : void 0,
7820
+ input: options?.input,
7821
+ encoding: "utf-8",
7822
+ stdio: [
7823
+ "pipe",
7824
+ "pipe",
7825
+ "pipe"
7826
+ ]
7827
+ }),
7828
+ stderr: "",
7829
+ exitCode: 0
7830
+ };
7831
+ } catch (err) {
7832
+ if (isExecSyncError(err)) return {
7833
+ stdout: err.stdout,
7834
+ stderr: err.stderr,
7835
+ exitCode: err.status
7836
+ };
7837
+ return {
7838
+ stdout: "",
7839
+ stderr: "",
7840
+ exitCode: 1
7841
+ };
7842
+ }
7843
+ } };
7844
+ }
7845
+ //#endregion
7846
+ //#region src/coverage-tracking/render.ts
7847
+ const PCT_WIDTH = 7;
7848
+ function pad(value, width) {
7849
+ if (value.length >= width) return value;
7850
+ return value + " ".repeat(width - value.length);
7851
+ }
7852
+ function padLeft(value, width) {
7853
+ if (value.length >= width) return value;
7854
+ return " ".repeat(width - value.length) + value;
7855
+ }
7856
+ function formatPct(n) {
7857
+ return `${n.toFixed(1)}%`;
7858
+ }
7859
+ function formatDelta(n) {
7860
+ if (n === 0) return " 0.00";
7861
+ return `${n > 0 ? "+" : ""}${n.toFixed(2)}`;
7862
+ }
7863
+ /** Format a single record (or baseline) as a labeled, aligned block for `coverage:summary`. */
7864
+ function formatRecordSummary(body) {
7865
+ const t = body.totals;
7866
+ const totalsLine = `lines ${formatPct(t.lines)} | statements ${formatPct(t.statements)} | branches ${formatPct(t.branches)} | functions ${formatPct(t.functions)}`;
7867
+ const lines = [body.sha ? `coverage @ ${body.sha.slice(0, 7)}` : "coverage", totalsLine];
7868
+ const pkgs = Object.entries(body.packages);
7869
+ if (pkgs.length > 0) {
7870
+ const longest = Math.max(...pkgs.map(([name]) => name.length));
7871
+ lines.push("by package:");
7872
+ for (const [name, entry] of pkgs.toSorted(([a], [b]) => a.localeCompare(b))) lines.push(` ${pad(name, longest)} lines ${padLeft(formatPct(entry.lines), PCT_WIDTH)} branches ${padLeft(formatPct(entry.branches), PCT_WIDTH)}`);
7873
+ }
7874
+ return lines.join("\n");
7875
+ }
7876
+ /** Format a CompareResult diff table for `coverage:check` failure output. */
7877
+ function formatCompareDiff(result) {
7878
+ const lines = [];
7879
+ lines.push(`coverage check ${result.pass ? "PASS" : "FAIL"} (tolerance ${result.tolerancePp.toFixed(2)}pp)`);
7880
+ const longestKey = Math.max(...TOTAL_KEYS.map((k) => k.length));
7881
+ for (const total of result.totals) {
7882
+ const status = total.pass ? "✓" : "✗";
7883
+ lines.push(` ${status} ${pad(total.key, longestKey)} baseline ${padLeft(formatPct(total.baseline), PCT_WIDTH)} current ${padLeft(formatPct(total.current), PCT_WIDTH)} Δ ${padLeft(formatDelta(total.delta), 7)}`);
7884
+ }
7885
+ return lines.join("\n");
7886
+ }
7887
+ /** Format a trend table for `coverage:status`. */
7888
+ function formatTrendTable(records) {
7889
+ if (records.length === 0) return "no coverage history recorded yet";
7890
+ const rows = records.map((r) => ({
7891
+ sha: r.sha.slice(0, 7),
7892
+ when: formatTimestamp(r.timestamp),
7893
+ totals: r.totals
7894
+ }));
7895
+ const header = [
7896
+ "sha",
7897
+ "when",
7898
+ "lines",
7899
+ "statements",
7900
+ "branches",
7901
+ "functions",
7902
+ "Δlines"
7903
+ ];
7904
+ const widths = [
7905
+ Math.max(header[0].length, ...rows.map((r) => r.sha.length)),
7906
+ Math.max(header[1].length, ...rows.map((r) => r.when.length)),
7907
+ PCT_WIDTH,
7908
+ PCT_WIDTH,
7909
+ PCT_WIDTH,
7910
+ PCT_WIDTH,
7911
+ 7
7912
+ ];
7913
+ const lines = [];
7914
+ lines.push(joinRow(header, widths));
7915
+ lines.push(joinRow(widths.map((w) => "-".repeat(w)), widths));
7916
+ let prevLines;
7917
+ for (const row of rows) {
7918
+ const deltaLines = prevLines === void 0 ? "" : formatDelta(round2(row.totals.lines - prevLines));
7919
+ lines.push(joinRow([
7920
+ row.sha,
7921
+ row.when,
7922
+ formatPct(row.totals.lines),
7923
+ formatPct(row.totals.statements),
7924
+ formatPct(row.totals.branches),
7925
+ formatPct(row.totals.functions),
7926
+ deltaLines
7927
+ ], widths));
7928
+ prevLines = row.totals.lines;
7929
+ }
7930
+ if (records.length >= 2) {
7931
+ const first = records[0];
7932
+ const last = records[records.length - 1];
7933
+ if (first && last) {
7934
+ const overall = round2(last.totals.lines - first.totals.lines);
7935
+ lines.push("");
7936
+ lines.push(`lines ${formatPct(last.totals.lines)} (${formatDelta(overall)} over last ${String(records.length)} records)`);
7937
+ }
7938
+ }
7939
+ return lines.join("\n");
7940
+ }
7941
+ function round2(n) {
7942
+ return Math.round(n * 100) / 100;
7943
+ }
7944
+ function joinRow(cells, widths) {
7945
+ return cells.map((cell, i) => padLeft(cell, widths[i] ?? cell.length)).join(" ");
7946
+ }
7947
+ function formatTimestamp(iso) {
7948
+ const date = new Date(iso);
7949
+ if (Number.isNaN(date.getTime())) return iso;
7950
+ return `${String(date.getUTCFullYear())}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
7951
+ }
7952
+ //#endregion
7953
+ //#region src/commands/coverage-summary.ts
7954
+ const COVERAGE_SUMMARY_FILE$2 = "coverage/coverage-summary.json";
7955
+ const out$3 = (msg) => console.log(msg);
7956
+ function readSummary(targetDir) {
7957
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$2);
7958
+ if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE$2} not found. Run \`pnpm test:coverage\` first, or add "json-summary" to your vitest reporters.`);
7959
+ return readFileSync(summaryPath, "utf-8");
7960
+ }
7961
+ function getHeadSha(targetDir) {
7962
+ const result = createRealCoverageExecutor().exec("git", ["rev-parse", "HEAD"], { cwd: targetDir });
7963
+ if (result.exitCode !== 0) return "unknown";
7964
+ return result.stdout.trim();
7965
+ }
7966
+ const coverageSummaryCommand = defineCommand({
7967
+ meta: {
7968
+ name: "coverage:summary",
7969
+ description: "Print a human-readable summary of the latest coverage run"
7970
+ },
7971
+ args: {
7972
+ dir: {
7973
+ type: "positional",
7974
+ description: "Project directory (default: current directory)",
7975
+ required: false
7976
+ },
7977
+ json: {
7978
+ type: "boolean",
7979
+ description: "Emit the trimmed record as JSON on stdout (for downstream tooling)"
7980
+ }
7981
+ },
7982
+ run({ args }) {
7983
+ const targetDir = path.resolve(args.dir ?? ".");
7984
+ const summary = parseV8Summary(readSummary(targetDir));
7985
+ if (args.json === true) {
7986
+ const record = deriveRecord(summary, {
7987
+ rootDir: targetDir,
7988
+ identity: {
7989
+ sha: getHeadSha(targetDir),
7990
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
7991
+ }
7992
+ });
7993
+ out$3(JSON.stringify(record));
7994
+ return;
7995
+ }
7996
+ out$3(formatRecordSummary({
7997
+ ...deriveBaseline(summary, { rootDir: targetDir }),
7998
+ sha: getHeadSha(targetDir)
7999
+ }));
8000
+ }
8001
+ });
8002
+ //#endregion
8003
+ //#region src/coverage-tracking/baseline.ts
8004
+ /** Serialize a baseline to its JSON form (with trailing newline). Single source of truth for layout. */
8005
+ function serializeBaseline(baseline) {
8006
+ return JSON.stringify(baseline, null, 2) + "\n";
8007
+ }
8008
+ //#endregion
8009
+ //#region src/commands/coverage-check.ts
8010
+ const COVERAGE_SUMMARY_FILE$1 = "coverage/coverage-summary.json";
8011
+ const BASELINE_FILE = "coverage-baseline.json";
8012
+ const out$2 = (msg) => console.log(msg);
8013
+ const coverageCheckCommand = defineCommand({
8014
+ meta: {
8015
+ name: "coverage:check",
8016
+ description: "Compare the latest coverage run against coverage-baseline.json"
8017
+ },
8018
+ args: {
8019
+ dir: {
8020
+ type: "positional",
8021
+ description: "Project directory (default: current directory)",
8022
+ required: false
8023
+ },
8024
+ "update-baseline": {
8025
+ type: "boolean",
8026
+ description: "If all four totals are flat-or-up, rewrite coverage-baseline.json"
8027
+ },
8028
+ "no-fail-on-missing-baseline": {
8029
+ type: "boolean",
8030
+ description: "Treat absent baseline as a soft notice (used during bootstrap)"
8031
+ },
8032
+ json: {
8033
+ type: "boolean",
8034
+ description: "Emit machine-readable diff on stdout instead of the table"
8035
+ }
8036
+ },
8037
+ run({ args }) {
8038
+ const targetDir = path.resolve(args.dir ?? ".");
8039
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$1);
8040
+ const baselinePath = path.join(targetDir, BASELINE_FILE);
8041
+ if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE$1} not found. Run \`pnpm test:coverage\` first, or add "json-summary" to your vitest reporters.`);
8042
+ const current = deriveBaseline(parseV8Summary(readFileSync(summaryPath, "utf-8")), { rootDir: targetDir });
8043
+ if (!existsSync(baselinePath)) {
8044
+ if (args["update-baseline"] === true) {
8045
+ writeFileSync(baselinePath, serializeBaseline(current));
8046
+ out$2(`Created ${BASELINE_FILE} (no prior baseline).`);
8047
+ return;
8048
+ }
8049
+ const message = `${BASELINE_FILE} not found. Run with --update-baseline to create one from the current run.`;
8050
+ if (args["no-fail-on-missing-baseline"] === true) {
8051
+ out$2(message);
8052
+ return;
8053
+ }
8054
+ throw new FatalError(message);
8055
+ }
8056
+ const baseline = parseBaseline(readFileSync(baselinePath, "utf-8"));
8057
+ const config = resolveCoverageConfig(targetDir);
8058
+ const result = compareTotals(baseline.totals, current.totals, config.tolerancePp);
8059
+ if (args.json === true) out$2(JSON.stringify(result));
8060
+ else out$2(formatCompareDiff(result));
8061
+ if (args["update-baseline"] === true) if (isFlatOrUp(baseline.totals, current.totals)) {
8062
+ writeFileSync(baselinePath, serializeBaseline(current));
8063
+ out$2(`Updated ${BASELINE_FILE} (all four totals flat-or-up).`);
8064
+ } else out$2(`Skipped baseline update: at least one total dropped below baseline. Add tests or adjust coverage.exclude before updating.`);
8065
+ if (!result.pass) process.exitCode = 1;
8066
+ }
8067
+ });
8068
+ //#endregion
8069
+ //#region src/commands/coverage-record.ts
8070
+ const COVERAGE_SUMMARY_FILE = "coverage/coverage-summary.json";
8071
+ const out$1 = (msg) => console.log(msg);
8072
+ const coverageRecordCommand = defineCommand({
8073
+ meta: {
8074
+ name: "coverage:record",
8075
+ description: "Append the current coverage run to the orphan-branch history"
8076
+ },
8077
+ args: {
8078
+ dir: {
8079
+ type: "positional",
8080
+ description: "Project directory (default: current directory)",
8081
+ required: false
8082
+ },
8083
+ branch: {
8084
+ type: "string",
8085
+ description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
8086
+ }
8087
+ },
8088
+ run({ args }) {
8089
+ const targetDir = path.resolve(args.dir ?? ".");
8090
+ const config = resolveCoverageConfig(targetDir);
8091
+ if (!config.historyEnabled) {
8092
+ out$1("coverage history disabled (.tooling.json: coverage.history === 'none' or coverage === false). Skipping record.");
8093
+ return;
8094
+ }
8095
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE);
8096
+ if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE} not found. Run \`pnpm test:coverage\` before \`coverage:record\`.`);
8097
+ const summary = parseV8Summary(readFileSync(summaryPath, "utf-8"));
8098
+ const executor = createRealCoverageExecutor();
8099
+ const headResult = executor.exec("git", ["rev-parse", "HEAD"], { cwd: targetDir });
8100
+ if (headResult.exitCode !== 0) throw new FatalError(`Failed to resolve HEAD sha: ${headResult.stderr.trim()}`);
8101
+ const sha = headResult.stdout.trim();
8102
+ const record = deriveRecord(summary, {
8103
+ rootDir: targetDir,
8104
+ identity: {
8105
+ sha,
8106
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
8107
+ }
8108
+ });
8109
+ const branch = args.branch ?? config.historyBranch;
8110
+ out$1(`Appending coverage record for ${sha.slice(0, 7)} to ${branch}...`);
8111
+ const result = appendRecord(executor, {
8112
+ cwd: targetDir,
8113
+ historyBranch: branch,
8114
+ authorName: "coverage-bot",
8115
+ authorEmail: "coverage-bot@noreply.localhost"
8116
+ }, record);
8117
+ out$1(`${result.bootstrapped ? "Bootstrapped" : "Appended to"} ${branch}: commit ${result.commitSha.slice(0, 7)}${result.attempts > 1 ? ` (after ${String(result.attempts)} attempts)` : ""}.`);
8118
+ }
8119
+ });
8120
+ //#endregion
8121
+ //#region src/commands/coverage-status.ts
8122
+ const out = (msg) => console.log(msg);
8123
+ //#endregion
7114
8124
  //#region src/bin.ts
7115
8125
  const main = defineCommand({
7116
8126
  meta: {
7117
8127
  name: "bst",
7118
- version: "0.55.2",
8128
+ version: "0.57.0",
7119
8129
  description: "Bootstrap and maintain standardized TypeScript project tooling"
7120
8130
  },
7121
8131
  subCommands: {
@@ -7135,56 +8145,59 @@ const main = defineCommand({
7135
8145
  "setup:secrets": setupSecretsCommand,
7136
8146
  "forgejo:secrets": forgejoSecretsCommand,
7137
8147
  "forgejo:webhooks": forgejoWebhooksCommand,
7138
- "webhook:send": defineCommand({
8148
+ "webhook:send": webhookSendCommand,
8149
+ "coverage:summary": coverageSummaryCommand,
8150
+ "coverage:check": coverageCheckCommand,
8151
+ "coverage:record": coverageRecordCommand,
8152
+ "coverage:status": defineCommand({
7139
8153
  meta: {
7140
- name: "webhook:send",
7141
- description: "Send a POST request to a webhook URL with GitHub-style HMAC-SHA256 authentication"
8154
+ name: "coverage:status",
8155
+ description: "Show recent coverage history (read-only; fetches from the orphan branch)"
7142
8156
  },
7143
8157
  args: {
7144
- url: {
8158
+ dir: {
7145
8159
  type: "positional",
7146
- description: "Webhook URL to POST to. If empty or missing, the command exits successfully (no-op)."
7147
- },
7148
- tag: {
7149
- type: "string",
7150
- description: "Release tag name (e.g. v1.2.3). Falls back to GITHUB_REF env var."
7151
- },
7152
- repository: {
7153
- type: "string",
7154
- description: "Repository in owner/repo format. Falls back to GITHUB_REPOSITORY env var."
8160
+ description: "Project directory (default: current directory)",
8161
+ required: false
7155
8162
  },
7156
- "server-url": {
8163
+ limit: {
7157
8164
  type: "string",
7158
- description: "Server URL (e.g. https://github.com). Falls back to GITHUB_SERVER_URL env var."
8165
+ description: "Number of trailing records to render (default: 10)"
7159
8166
  },
7160
- actor: {
8167
+ branch: {
7161
8168
  type: "string",
7162
- description: "User or bot that triggered the release. Falls back to GITHUB_ACTOR env var."
8169
+ description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
7163
8170
  },
7164
- prerelease: {
8171
+ json: {
7165
8172
  type: "boolean",
7166
- description: "Mark the release as a prerelease"
8173
+ description: "Emit the parsed records as JSON instead of the table"
7167
8174
  }
7168
8175
  },
7169
- async run({ args }) {
7170
- if (!args.url) {
7171
- log.info("No webhook URL configured — skipping post-release hook");
8176
+ run({ args }) {
8177
+ const targetDir = path.resolve(args.dir ?? ".");
8178
+ const config = resolveCoverageConfig(targetDir);
8179
+ const branch = args.branch ?? config.historyBranch;
8180
+ const limit = args.limit ? Math.max(1, Number.parseInt(args.limit, 10) || 10) : 10;
8181
+ const records = readRecords(createRealCoverageExecutor(), {
8182
+ cwd: targetDir,
8183
+ historyBranch: branch
8184
+ });
8185
+ if (records.length === 0) {
8186
+ if (args.json === true) out("[]");
8187
+ else out(`no coverage history recorded yet (branch: ${branch})`);
8188
+ return;
8189
+ }
8190
+ const trailing = records.slice(-limit);
8191
+ if (args.json === true) {
8192
+ out(JSON.stringify(trailing));
7172
8193
  return;
7173
8194
  }
7174
- const secret = process.env["WEBHOOK_SECRET"];
7175
- const payload = args.tag || args.repository ? buildSimplePayload({
7176
- tag: args.tag ?? "",
7177
- repository: args.repository ?? process.env["GITHUB_REPOSITORY"] ?? "",
7178
- serverUrl: args["server-url"] ?? process.env["GITHUB_SERVER_URL"] ?? "https://github.com",
7179
- actor: args.actor ?? process.env["GITHUB_ACTOR"] ?? "github-actions[bot]",
7180
- prerelease: args.prerelease ?? false
7181
- }) : buildPayloadFromEnv();
7182
- await sendWebhookWithLogging(args.url, secret || void 0, payload);
8195
+ out(formatTrendTable(trailing));
7183
8196
  }
7184
8197
  })
7185
8198
  }
7186
8199
  });
7187
- console.log(`@bensandee/tooling v0.55.2`);
8200
+ console.log(`@bensandee/tooling v0.57.0`);
7188
8201
  async function run() {
7189
8202
  await runMain(main);
7190
8203
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.55.2",
3
+ "version": "0.57.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -225,6 +225,39 @@
225
225
  "items": {
226
226
  "type": "string"
227
227
  }
228
+ },
229
+ "coverage": {
230
+ "description": "Coverage tracking configuration. Set to false to disable; omit for default-on (when a vitest config is present).",
231
+ "anyOf": [
232
+ {
233
+ "type": "boolean",
234
+ "const": false
235
+ },
236
+ {
237
+ "type": "object",
238
+ "properties": {
239
+ "history": {
240
+ "description": "How coverage history is persisted. 'orphan-branch' (default) appends to a dedicated branch; 'none' disables CI history but keeps the local ratchet.",
241
+ "type": "string",
242
+ "enum": ["orphan-branch", "none"]
243
+ },
244
+ "historyBranch": {
245
+ "description": "Orphan branch name for coverage history (default: 'bst/coverage-metrics')",
246
+ "type": "string"
247
+ },
248
+ "tolerancePp": {
249
+ "description": "Tolerance in percentage points for baseline comparison (default: 0.1)",
250
+ "type": "number",
251
+ "minimum": 0
252
+ },
253
+ "gateOnPullRequests": {
254
+ "description": "Run coverage on PR builds too (default: false; deferred follow-up)",
255
+ "type": "boolean"
256
+ }
257
+ },
258
+ "additionalProperties": false
259
+ }
260
+ ]
228
261
  }
229
262
  },
230
263
  "additionalProperties": false