@bensandee/tooling 0.55.2 → 0.56.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.56.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",
@@ -2101,6 +2110,10 @@ const VITEST_CONFIG = `import { defineConfig } from "vitest/config";
2101
2110
  export default defineConfig({
2102
2111
  test: {
2103
2112
  include: ["test/**/*.test.ts"],
2113
+ coverage: {
2114
+ provider: "v8",
2115
+ reporter: ["text", "html", "lcov", "json-summary"],
2116
+ },
2104
2117
  },
2105
2118
  });
2106
2119
  `;
@@ -2902,7 +2915,8 @@ const REQUIRED_ENTRIES = [
2902
2915
  const OPTIONAL_ENTRIES = [
2903
2916
  ".tooling-migrate.md",
2904
2917
  ".tooling-archived/",
2905
- ".claude/hooks/bst-hook.audit.local.jsonl*"
2918
+ ".claude/hooks/bst-hook.audit.local.jsonl*",
2919
+ "coverage/"
2906
2920
  ];
2907
2921
  /**
2908
2922
  * Optional entries added only when a feature is actually configured.
@@ -4039,6 +4053,523 @@ async function generateVscodeSettings(ctx) {
4039
4053
  return results;
4040
4054
  }
4041
4055
  //#endregion
4056
+ //#region src/coverage-tracking/compare.ts
4057
+ const TOTAL_KEYS = [
4058
+ "lines",
4059
+ "statements",
4060
+ "branches",
4061
+ "functions"
4062
+ ];
4063
+ const DEFAULT_TOLERANCE_PP = .1;
4064
+ /** Round to 2 decimal places to keep diff output stable across runs. */
4065
+ function round2$1(n) {
4066
+ return Math.round(n * 100) / 100;
4067
+ }
4068
+ /**
4069
+ * Compare a current run against a baseline across the four overall totals.
4070
+ * A total passes when current >= baseline OR (baseline - current) <= tolerancePp.
4071
+ */
4072
+ function compareTotals(baseline, current, tolerancePp = DEFAULT_TOLERANCE_PP) {
4073
+ const totals = TOTAL_KEYS.map((key) => {
4074
+ const b = baseline[key];
4075
+ const c = current[key];
4076
+ return {
4077
+ key,
4078
+ baseline: b,
4079
+ current: c,
4080
+ delta: round2$1(c - b),
4081
+ pass: c >= b || b - c <= tolerancePp
4082
+ };
4083
+ });
4084
+ return {
4085
+ pass: totals.every((t) => t.pass),
4086
+ tolerancePp,
4087
+ totals
4088
+ };
4089
+ }
4090
+ /** True iff every total is flat-or-up (delta >= 0). Used to gate baseline updates. */
4091
+ function isFlatOrUp(baseline, current) {
4092
+ return TOTAL_KEYS.every((key) => current[key] >= baseline[key]);
4093
+ }
4094
+ //#endregion
4095
+ //#region src/coverage-tracking/parse.ts
4096
+ const TotalEntrySchema = z.object({
4097
+ total: z.number(),
4098
+ covered: z.number(),
4099
+ skipped: z.number().optional(),
4100
+ pct: z.number()
4101
+ });
4102
+ const FileEntrySchema = z.object({
4103
+ lines: TotalEntrySchema,
4104
+ statements: TotalEntrySchema,
4105
+ branches: TotalEntrySchema,
4106
+ functions: TotalEntrySchema
4107
+ });
4108
+ /**
4109
+ * Schema for v8 / istanbul `coverage/coverage-summary.json`. The "total" key
4110
+ * holds the grand totals; every other key is a per-file path → entry mapping.
4111
+ */
4112
+ const V8CoverageSummarySchema = z.object({ total: FileEntrySchema }).catchall(FileEntrySchema);
4113
+ const TotalsSchema = z.object({
4114
+ lines: z.number(),
4115
+ statements: z.number(),
4116
+ branches: z.number(),
4117
+ functions: z.number()
4118
+ });
4119
+ const PackageEntrySchema = z.object({
4120
+ lines: z.number(),
4121
+ branches: z.number()
4122
+ });
4123
+ /** Coverage record without identity fields — body shape for `coverage-baseline.json`. */
4124
+ const CoverageBaselineSchema = z.object({
4125
+ totals: TotalsSchema,
4126
+ packages: z.record(z.string(), PackageEntrySchema)
4127
+ });
4128
+ /** Full trimmed record — appended to `coverage-history.jsonl` and emitted by `coverage:summary`. */
4129
+ const CoverageRecordSchema = CoverageBaselineSchema.extend({
4130
+ sha: z.string(),
4131
+ timestamp: z.string()
4132
+ });
4133
+ /** Parse a v8 `coverage-summary.json` blob; throws FatalError on malformed input. */
4134
+ function parseV8Summary(raw) {
4135
+ let json;
4136
+ try {
4137
+ json = JSON.parse(raw);
4138
+ } catch (error) {
4139
+ throw new FatalError(`coverage-summary.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
4140
+ }
4141
+ const result = V8CoverageSummarySchema.safeParse(json);
4142
+ if (!result.success) throw new FatalError(`coverage-summary.json shape doesn't match v8 / istanbul output: ${result.error.message}`);
4143
+ return result.data;
4144
+ }
4145
+ /** Parse a `coverage-baseline.json` blob; throws FatalError on malformed input. */
4146
+ function parseBaseline(raw) {
4147
+ let json;
4148
+ try {
4149
+ json = JSON.parse(raw);
4150
+ } catch (error) {
4151
+ throw new FatalError(`coverage-baseline.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
4152
+ }
4153
+ const result = CoverageBaselineSchema.safeParse(json);
4154
+ if (!result.success) throw new FatalError(`coverage-baseline.json has unexpected shape: ${result.error.message}`);
4155
+ return result.data;
4156
+ }
4157
+ /** Parse a single line of `coverage-history.jsonl`; returns undefined for malformed lines. */
4158
+ function parseHistoryLine(line) {
4159
+ let json;
4160
+ try {
4161
+ json = JSON.parse(line);
4162
+ } catch (_error) {
4163
+ return;
4164
+ }
4165
+ const result = CoverageRecordSchema.safeParse(json);
4166
+ return result.success ? result.data : void 0;
4167
+ }
4168
+ //#endregion
4169
+ //#region src/coverage-tracking/history.ts
4170
+ const DEFAULT_HISTORY_BRANCH = "bst/coverage-metrics";
4171
+ const HISTORY_FILE = "coverage-history.jsonl";
4172
+ const MAX_PUSH_ATTEMPTS = 5;
4173
+ function remoteRef(config) {
4174
+ return `refs/remotes/${config.remote ?? "origin"}/${config.historyBranch}`;
4175
+ }
4176
+ function fetchBranch(executor, config) {
4177
+ const remote = config.remote ?? "origin";
4178
+ if (executor.exec("git", [
4179
+ "fetch",
4180
+ remote,
4181
+ `${config.historyBranch}:${remoteRef(config)}`
4182
+ ], { cwd: config.cwd }).exitCode !== 0) return { exists: false };
4183
+ const revParse = executor.exec("git", [
4184
+ "rev-parse",
4185
+ "--quiet",
4186
+ "--verify",
4187
+ remoteRef(config)
4188
+ ], { cwd: config.cwd });
4189
+ if (revParse.exitCode !== 0) return { exists: false };
4190
+ return {
4191
+ exists: true,
4192
+ parentSha: revParse.stdout.trim()
4193
+ };
4194
+ }
4195
+ function readRemoteHistory(executor, config) {
4196
+ const showResult = executor.exec("git", ["show", `${remoteRef(config)}:${HISTORY_FILE}`], { cwd: config.cwd });
4197
+ if (showResult.exitCode !== 0) return "";
4198
+ return showResult.stdout;
4199
+ }
4200
+ function makeBlob(executor, cwd, content) {
4201
+ const result = executor.exec("git", [
4202
+ "hash-object",
4203
+ "-w",
4204
+ "--stdin"
4205
+ ], {
4206
+ cwd,
4207
+ input: content
4208
+ });
4209
+ if (result.exitCode !== 0) throw new FatalError(`git hash-object failed: ${result.stderr.trim() || "unknown error"}`);
4210
+ return result.stdout.trim();
4211
+ }
4212
+ function makeTree(executor, cwd, blobSha) {
4213
+ const treeInput = `100644 blob ${blobSha}\t${HISTORY_FILE}\n`;
4214
+ const result = executor.exec("git", ["mktree"], {
4215
+ cwd,
4216
+ input: treeInput
4217
+ });
4218
+ if (result.exitCode !== 0) throw new FatalError(`git mktree failed: ${result.stderr.trim() || "unknown error"}`);
4219
+ return result.stdout.trim();
4220
+ }
4221
+ function makeCommit(executor, config, treeSha, parentSha, message) {
4222
+ const env = {};
4223
+ if (config.authorName) {
4224
+ env["GIT_AUTHOR_NAME"] = config.authorName;
4225
+ env["GIT_COMMITTER_NAME"] = config.authorName;
4226
+ }
4227
+ if (config.authorEmail) {
4228
+ env["GIT_AUTHOR_EMAIL"] = config.authorEmail;
4229
+ env["GIT_COMMITTER_EMAIL"] = config.authorEmail;
4230
+ }
4231
+ const args = parentSha ? [
4232
+ "commit-tree",
4233
+ "-p",
4234
+ parentSha,
4235
+ "-m",
4236
+ message,
4237
+ treeSha
4238
+ ] : [
4239
+ "commit-tree",
4240
+ "-m",
4241
+ message,
4242
+ treeSha
4243
+ ];
4244
+ const result = executor.exec("git", args, {
4245
+ cwd: config.cwd,
4246
+ env
4247
+ });
4248
+ if (result.exitCode !== 0) throw new FatalError(`git commit-tree failed: ${result.stderr.trim() || "unknown error"}`);
4249
+ return result.stdout.trim();
4250
+ }
4251
+ function pushCommit(executor, config, commitSha, parentSha) {
4252
+ const remote = config.remote ?? "origin";
4253
+ const refSpec = `${commitSha}:refs/heads/${config.historyBranch}`;
4254
+ if (parentSha === void 0) return executor.exec("git", [
4255
+ "push",
4256
+ remote,
4257
+ refSpec
4258
+ ], { cwd: config.cwd });
4259
+ const lease = `--force-with-lease=refs/heads/${config.historyBranch}:${parentSha}`;
4260
+ return executor.exec("git", [
4261
+ "push",
4262
+ lease,
4263
+ remote,
4264
+ refSpec
4265
+ ], { cwd: config.cwd });
4266
+ }
4267
+ function shortSha(sha) {
4268
+ return sha.slice(0, 7);
4269
+ }
4270
+ /**
4271
+ * Append a coverage record to the orphan branch. Bootstraps the branch on first run.
4272
+ * Uses pure git plumbing (no worktree / no checkout) so the working tree stays untouched.
4273
+ * On push conflict (concurrent record), refetches and rebuilds the commit (up to MAX_PUSH_ATTEMPTS).
4274
+ */
4275
+ function appendRecord(executor, config, record) {
4276
+ const line = JSON.stringify(record) + "\n";
4277
+ let lastError = "";
4278
+ for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
4279
+ const fetched = fetchBranch(executor, config);
4280
+ const newContent = (fetched.exists ? readRemoteHistory(executor, config) : "") + line;
4281
+ const blobSha = makeBlob(executor, config.cwd, newContent);
4282
+ const treeSha = makeTree(executor, config.cwd, blobSha);
4283
+ const message = `chore(coverage): record ${shortSha(record.sha)} [skip ci]`;
4284
+ const commitSha = makeCommit(executor, config, treeSha, fetched.parentSha, message);
4285
+ const pushResult = pushCommit(executor, config, commitSha, fetched.parentSha);
4286
+ if (pushResult.exitCode === 0) return {
4287
+ commitSha,
4288
+ bootstrapped: !fetched.exists,
4289
+ attempts: attempt
4290
+ };
4291
+ lastError = pushResult.stderr.trim() || pushResult.stdout.trim() || "unknown push error";
4292
+ }
4293
+ throw new FatalError(`Failed to push to ${config.historyBranch} after ${MAX_PUSH_ATTEMPTS} attempts: ${lastError}`);
4294
+ }
4295
+ /**
4296
+ * Read all records from the orphan branch (read-only). Performs a shallow fetch and
4297
+ * reads via `git show` — no working-tree disruption, no local branch creation.
4298
+ * Returns an empty array if the branch doesn't exist yet.
4299
+ */
4300
+ function readRecords(executor, config) {
4301
+ if (!fetchBranch(executor, config).exists) return [];
4302
+ const raw = readRemoteHistory(executor, config);
4303
+ const records = [];
4304
+ for (const line of raw.split("\n")) {
4305
+ if (line.trim() === "") continue;
4306
+ const parsed = parseHistoryLine(line);
4307
+ if (parsed) records.push(parsed);
4308
+ }
4309
+ return records;
4310
+ }
4311
+ //#endregion
4312
+ //#region src/coverage-tracking/config.ts
4313
+ /** Resolve the effective coverage configuration from explicit inputs (testable). */
4314
+ function resolveFromInputs(inputs) {
4315
+ if (inputs.coverage === false) return {
4316
+ enabled: false,
4317
+ historyEnabled: false,
4318
+ historyBranch: DEFAULT_HISTORY_BRANCH,
4319
+ tolerancePp: DEFAULT_TOLERANCE_PP,
4320
+ gateOnPullRequests: false
4321
+ };
4322
+ const enabled = inputs.hasVitestConfig || inputs.setupVitest;
4323
+ const overrides = inputs.coverage ?? {};
4324
+ return {
4325
+ enabled,
4326
+ historyEnabled: enabled && overrides.history !== "none",
4327
+ historyBranch: overrides.historyBranch ?? "bst/coverage-metrics",
4328
+ tolerancePp: overrides.tolerancePp ?? .1,
4329
+ gateOnPullRequests: overrides.gateOnPullRequests ?? false
4330
+ };
4331
+ }
4332
+ /**
4333
+ * Resolve from the real filesystem (used by CLI commands).
4334
+ * Default-on when a vitest config is detected and `.tooling.json#coverage` is not `false`.
4335
+ */
4336
+ function resolveCoverageConfig(targetDir) {
4337
+ const tooling = safeLoadToolingConfig(targetDir);
4338
+ return resolveFromInputs({
4339
+ hasVitestConfig: existsSync(path.join(targetDir, "vitest.config.ts")) || existsSync(path.join(targetDir, "vitest.config.mts")),
4340
+ setupVitest: tooling?.setupVitest === true,
4341
+ coverage: tooling?.coverage
4342
+ });
4343
+ }
4344
+ const CoverageOverridesShape = z.object({
4345
+ history: z.enum(["orphan-branch", "none"]).optional(),
4346
+ historyBranch: z.string().optional(),
4347
+ tolerancePp: z.number().nonnegative().optional(),
4348
+ gateOnPullRequests: z.boolean().optional()
4349
+ });
4350
+ const CoverageFieldSchema = z.union([z.literal(false), CoverageOverridesShape]);
4351
+ const ToolingShallowSchema = z.object({
4352
+ setupVitest: z.boolean().optional(),
4353
+ coverage: CoverageFieldSchema.optional()
4354
+ });
4355
+ /** Resolve from a generator context (used by generators for mock-friendly testing). */
4356
+ function resolveCoverageConfigFromContext(reader, fallbackSetupVitest) {
4357
+ const toolingRaw = reader.read(".tooling.json");
4358
+ let setupVitest = fallbackSetupVitest;
4359
+ let coverage;
4360
+ if (toolingRaw) try {
4361
+ const result = ToolingShallowSchema.safeParse(JSON.parse(toolingRaw));
4362
+ if (result.success) {
4363
+ if (typeof result.data.setupVitest === "boolean") setupVitest = result.data.setupVitest;
4364
+ coverage = result.data.coverage;
4365
+ }
4366
+ } catch (_error) {}
4367
+ return resolveFromInputs({
4368
+ hasVitestConfig: reader.exists("vitest.config.ts") || reader.exists("vitest.config.mts"),
4369
+ setupVitest,
4370
+ coverage
4371
+ });
4372
+ }
4373
+ /** Like loadToolingConfig but returns undefined on any error so commands can degrade gracefully. */
4374
+ function safeLoadToolingConfig(targetDir) {
4375
+ try {
4376
+ return loadToolingConfig(targetDir);
4377
+ } catch (_error) {
4378
+ return;
4379
+ }
4380
+ }
4381
+ //#endregion
4382
+ //#region src/generators/coverage-ci.ts
4383
+ function coverageSteps(nodeVersionYaml) {
4384
+ return [
4385
+ { step: {
4386
+ uses: "actions/checkout@v6",
4387
+ with: { "fetch-depth": 0 }
4388
+ } },
4389
+ { step: { uses: "pnpm/action-setup@v5" } },
4390
+ { step: {
4391
+ uses: "actions/setup-node@v6",
4392
+ with: {
4393
+ ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
4394
+ cache: "pnpm"
4395
+ }
4396
+ } },
4397
+ { step: { run: "pnpm install --frozen-lockfile" } },
4398
+ { step: {
4399
+ name: "Run tests with coverage",
4400
+ run: "pnpm test:coverage"
4401
+ } },
4402
+ { step: {
4403
+ name: "Print coverage summary",
4404
+ run: "pnpm exec bst coverage:summary"
4405
+ } },
4406
+ { step: {
4407
+ name: "Append record to bst/coverage-metrics branch",
4408
+ run: "pnpm exec bst coverage:record"
4409
+ } },
4410
+ { step: {
4411
+ name: "Show recent coverage trend",
4412
+ run: "pnpm exec bst coverage:status --limit 10"
4413
+ } }
4414
+ ];
4415
+ }
4416
+ async function generateCoverageCi(ctx) {
4417
+ const filePath = "coverage-ci";
4418
+ if (ctx.config.ci === "none") return {
4419
+ filePath,
4420
+ action: "skipped",
4421
+ description: "CI workflow not requested"
4422
+ };
4423
+ const config = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4424
+ if (!config.enabled || !config.historyEnabled) return {
4425
+ filePath,
4426
+ action: "skipped",
4427
+ description: "Coverage tracking disabled"
4428
+ };
4429
+ const isGitHub = ctx.config.ci === "github";
4430
+ const isForgejo = !isGitHub;
4431
+ const workflowPath = isGitHub ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
4432
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
4433
+ const content = buildWorkflowYaml({
4434
+ ci: ctx.config.ci,
4435
+ name: "Coverage",
4436
+ on: { push: {
4437
+ branches: ["main"],
4438
+ "tags-ignore": ["**"]
4439
+ } },
4440
+ permissions: { contents: "write" },
4441
+ enableEmailNotifications: isForgejo,
4442
+ jobName: "coverage",
4443
+ steps: coverageSteps(nodeVersionYaml)
4444
+ });
4445
+ const alreadyExists = ctx.exists(workflowPath);
4446
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
4447
+ if (existing) {
4448
+ if (isToolingIgnored(existing)) return {
4449
+ filePath: workflowPath,
4450
+ action: "skipped",
4451
+ description: "Coverage workflow has ignore comment"
4452
+ };
4453
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
4454
+ filePath: workflowPath,
4455
+ action: "skipped",
4456
+ description: "Coverage workflow already up to date"
4457
+ };
4458
+ }
4459
+ const withPinnedActions = preservePinnedActions(preserveDevBinaryPath(content, existing), existing);
4460
+ const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
4461
+ ctx.write(workflowPath, final);
4462
+ return {
4463
+ filePath: workflowPath,
4464
+ action: alreadyExists ? "updated" : "created",
4465
+ description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions coverage workflow`
4466
+ };
4467
+ }
4468
+ //#endregion
4469
+ //#region src/generators/claude-md.ts
4470
+ const FILE = "CLAUDE.md";
4471
+ const START_MARKER = "<!-- @tooling:coverage-rule -->";
4472
+ const END_MARKER = "<!-- @tooling:endcoverage-rule -->";
4473
+ /**
4474
+ * The coverage-baseline rule that the agent must consult before declaring a
4475
+ * task complete. Kept terse — the linked plan and `--help` carry the long form.
4476
+ */
4477
+ const RULE_BODY = [
4478
+ "### Coverage baseline",
4479
+ "",
4480
+ "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."
4481
+ ].join("\n");
4482
+ /** Build the full managed block including markers. */
4483
+ function buildBlock() {
4484
+ return `${START_MARKER}\n\n${RULE_BODY}\n\n${END_MARKER}\n`;
4485
+ }
4486
+ function findBlock(existing) {
4487
+ const lines = existing.split("\n");
4488
+ let startLine = -1;
4489
+ for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() === START_MARKER) {
4490
+ startLine = i;
4491
+ break;
4492
+ }
4493
+ if (startLine === -1) return void 0;
4494
+ for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() === END_MARKER) return {
4495
+ startLine,
4496
+ endLine: i
4497
+ };
4498
+ }
4499
+ /**
4500
+ * Replace any existing `<!-- @tooling:coverage-rule -->...<!-- @tooling:endcoverage-rule -->`
4501
+ * block in `existing` with `block`, or append it at the end if no block is found.
4502
+ */
4503
+ function upsertBlock(existing, block) {
4504
+ const found = findBlock(existing);
4505
+ if (!found) return `${existing.replace(/\n+$/, "")}\n\n${block}`;
4506
+ const lines = existing.split("\n");
4507
+ const beforeLines = lines.slice(0, found.startLine);
4508
+ const afterLines = lines.slice(found.endLine + 1);
4509
+ const before = beforeLines.join("\n").replace(/\n+$/, "");
4510
+ const after = afterLines.join("\n").replace(/^\n+/, "");
4511
+ const tail = after.length > 0 ? `\n\n${after}` : "\n";
4512
+ return `${before}\n\n${block.replace(/\n+$/, "")}${tail}`;
4513
+ }
4514
+ /** Strip a coverage-rule managed block (markers + body) from CLAUDE.md content. */
4515
+ function removeBlock(existing) {
4516
+ const found = findBlock(existing);
4517
+ if (!found) return existing;
4518
+ const lines = existing.split("\n");
4519
+ const beforeLines = lines.slice(0, found.startLine);
4520
+ const afterLines = lines.slice(found.endLine + 1);
4521
+ const before = beforeLines.join("\n").replace(/\n+$/, "");
4522
+ const after = afterLines.join("\n").replace(/^\n+/, "");
4523
+ if (after.length === 0) return `${before}\n`;
4524
+ return `${before}\n\n${after}`;
4525
+ }
4526
+ const STARTER_CLAUDE_MD = "# CLAUDE.md\n\nProject guidance for Claude Code.\n";
4527
+ async function generateClaudeMd(ctx) {
4528
+ const config = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4529
+ const existing = ctx.read(FILE);
4530
+ if (!config.enabled) {
4531
+ if (!existing) return {
4532
+ filePath: FILE,
4533
+ action: "skipped",
4534
+ description: "Coverage disabled; no CLAUDE.md to manage"
4535
+ };
4536
+ if (!existing.includes(START_MARKER)) return {
4537
+ filePath: FILE,
4538
+ action: "skipped",
4539
+ description: "Coverage disabled; no managed block to remove"
4540
+ };
4541
+ const updated = removeBlock(existing);
4542
+ ctx.write(FILE, updated);
4543
+ return {
4544
+ filePath: FILE,
4545
+ action: "updated",
4546
+ description: "Removed coverage-rule managed block"
4547
+ };
4548
+ }
4549
+ const block = buildBlock();
4550
+ if (!existing) {
4551
+ const content = upsertBlock(STARTER_CLAUDE_MD, block);
4552
+ ctx.write(FILE, content);
4553
+ return {
4554
+ filePath: FILE,
4555
+ action: "created",
4556
+ description: "Generated CLAUDE.md with coverage-rule block"
4557
+ };
4558
+ }
4559
+ const updated = upsertBlock(existing, block);
4560
+ if (updated === existing) return {
4561
+ filePath: FILE,
4562
+ action: "skipped",
4563
+ description: "Coverage-rule block already up to date"
4564
+ };
4565
+ ctx.write(FILE, updated);
4566
+ return {
4567
+ filePath: FILE,
4568
+ action: "updated",
4569
+ description: "Updated coverage-rule managed block"
4570
+ };
4571
+ }
4572
+ //#endregion
4042
4573
  //#region src/generators/versionrc.ts
4043
4574
  function buildConfig() {
4044
4575
  return {
@@ -4127,6 +4658,8 @@ async function runGenerators(ctx) {
4127
4658
  results.push(await generateChangesets(ctx));
4128
4659
  results.push(await generateReleaseCi(ctx));
4129
4660
  results.push(await generateDeployCi(ctx));
4661
+ results.push(await generateCoverageCi(ctx));
4662
+ results.push(await generateClaudeMd(ctx));
4130
4663
  results.push(...await generateVitest(ctx));
4131
4664
  results.push(...await generateVscodeSettings(ctx));
4132
4665
  results.push(saveToolingConfig(ctx, ctx.config));
@@ -4141,7 +4674,7 @@ async function runGenerators(ctx) {
4141
4674
  function generateMigratePrompt(results, config, detected) {
4142
4675
  const sections = [];
4143
4676
  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`);
4677
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.56.0 repo:sync\` on ${timestamp}_\n`);
4145
4678
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
4146
4679
  if (changed.length > 0) {
4147
4680
  const items = changed.map((r) => {
@@ -7111,11 +7644,451 @@ async function sendWebhookWithLogging(url, secret, payload) {
7111
7644
  else log.warn(`Post-release hook failed: ${result.error ?? "unknown error"} (${context})`);
7112
7645
  }
7113
7646
  //#endregion
7647
+ //#region src/commands/webhook-send.ts
7648
+ const webhookSendCommand = defineCommand({
7649
+ meta: {
7650
+ name: "webhook:send",
7651
+ description: "Send a POST request to a webhook URL with GitHub-style HMAC-SHA256 authentication"
7652
+ },
7653
+ args: {
7654
+ url: {
7655
+ type: "positional",
7656
+ description: "Webhook URL to POST to. If empty or missing, the command exits successfully (no-op)."
7657
+ },
7658
+ tag: {
7659
+ type: "string",
7660
+ description: "Release tag name (e.g. v1.2.3). Falls back to GITHUB_REF env var."
7661
+ },
7662
+ repository: {
7663
+ type: "string",
7664
+ description: "Repository in owner/repo format. Falls back to GITHUB_REPOSITORY env var."
7665
+ },
7666
+ "server-url": {
7667
+ type: "string",
7668
+ description: "Server URL (e.g. https://github.com). Falls back to GITHUB_SERVER_URL env var."
7669
+ },
7670
+ actor: {
7671
+ type: "string",
7672
+ description: "User or bot that triggered the release. Falls back to GITHUB_ACTOR env var."
7673
+ },
7674
+ prerelease: {
7675
+ type: "boolean",
7676
+ description: "Mark the release as a prerelease"
7677
+ }
7678
+ },
7679
+ async run({ args }) {
7680
+ if (!args.url) {
7681
+ log.info("No webhook URL configured — skipping post-release hook");
7682
+ return;
7683
+ }
7684
+ const secret = process.env["WEBHOOK_SECRET"];
7685
+ const payload = args.tag || args.repository ? buildSimplePayload({
7686
+ tag: args.tag ?? "",
7687
+ repository: args.repository ?? process.env["GITHUB_REPOSITORY"] ?? "",
7688
+ serverUrl: args["server-url"] ?? process.env["GITHUB_SERVER_URL"] ?? "https://github.com",
7689
+ actor: args.actor ?? process.env["GITHUB_ACTOR"] ?? "github-actions[bot]",
7690
+ prerelease: args.prerelease ?? false
7691
+ }) : buildPayloadFromEnv();
7692
+ await sendWebhookWithLogging(args.url, secret || void 0, payload);
7693
+ }
7694
+ });
7695
+ //#endregion
7696
+ //#region src/coverage-tracking/derive.ts
7697
+ function emptySums() {
7698
+ return {
7699
+ linesTotal: 0,
7700
+ linesCovered: 0,
7701
+ branchesTotal: 0,
7702
+ branchesCovered: 0
7703
+ };
7704
+ }
7705
+ function addEntry(sums, entry) {
7706
+ sums.linesTotal += entry.lines.total;
7707
+ sums.linesCovered += entry.lines.covered;
7708
+ sums.branchesTotal += entry.branches.total;
7709
+ sums.branchesCovered += entry.branches.covered;
7710
+ }
7711
+ function pct(covered, total) {
7712
+ if (total === 0) return 0;
7713
+ return Math.round(covered / total * 1e3) / 10;
7714
+ }
7715
+ function toPackageEntry(sums) {
7716
+ return {
7717
+ lines: pct(sums.linesCovered, sums.linesTotal),
7718
+ branches: pct(sums.branchesCovered, sums.branchesTotal)
7719
+ };
7720
+ }
7721
+ function totalsFrom(entry) {
7722
+ return {
7723
+ lines: entry.lines.pct,
7724
+ statements: entry.statements.pct,
7725
+ branches: entry.branches.pct,
7726
+ functions: entry.functions.pct
7727
+ };
7728
+ }
7729
+ /** Match `<rootDir>/packages/<name>/...` and capture `<name>`. Posix-style; tolerates `\` on win32. */
7730
+ function packageNameFromPath(filePath, rootDir) {
7731
+ const normalized = filePath.split(path.sep).join("/");
7732
+ const rootNormalized = rootDir.split(path.sep).join("/").replace(/\/+$/, "");
7733
+ const rel = normalized.startsWith(`${rootNormalized}/`) ? normalized.slice(rootNormalized.length + 1) : normalized;
7734
+ return /^packages\/([^/]+)\//.exec(rel)?.[1];
7735
+ }
7736
+ /** Build a trimmed coverage record (or baseline body) from a parsed v8 summary. */
7737
+ function deriveBaseline(summary, options) {
7738
+ const totals = totalsFrom(summary.total);
7739
+ const perPackage = /* @__PURE__ */ new Map();
7740
+ const fallbackName = options.singlePackageName ?? path.basename(options.rootDir);
7741
+ const fallbackSums = emptySums();
7742
+ let sawAnyMonorepoPath = false;
7743
+ for (const [filePath, entry] of Object.entries(summary)) {
7744
+ if (filePath === "total") continue;
7745
+ const pkgName = packageNameFromPath(filePath, options.rootDir);
7746
+ if (pkgName) {
7747
+ sawAnyMonorepoPath = true;
7748
+ const sums = perPackage.get(pkgName) ?? emptySums();
7749
+ addEntry(sums, entry);
7750
+ perPackage.set(pkgName, sums);
7751
+ } else addEntry(fallbackSums, entry);
7752
+ }
7753
+ const packages = {};
7754
+ if (sawAnyMonorepoPath) for (const [name, sums] of perPackage) packages[name] = toPackageEntry(sums);
7755
+ else if (fallbackSums.linesTotal > 0 || fallbackSums.branchesTotal > 0) packages[fallbackName] = toPackageEntry(fallbackSums);
7756
+ return {
7757
+ totals,
7758
+ packages
7759
+ };
7760
+ }
7761
+ /** Build a full record (with identity) from a parsed v8 summary. */
7762
+ function deriveRecord(summary, options) {
7763
+ const baseline = deriveBaseline(summary, options);
7764
+ return {
7765
+ sha: options.identity.sha,
7766
+ timestamp: options.identity.timestamp,
7767
+ ...baseline
7768
+ };
7769
+ }
7770
+ //#endregion
7771
+ //#region src/coverage-tracking/executor.ts
7772
+ /** Real implementation of CoverageExecutor that shells out to git. */
7773
+ function createRealCoverageExecutor() {
7774
+ return { exec(file, args, options) {
7775
+ try {
7776
+ return {
7777
+ stdout: execFileSync(file, args, {
7778
+ cwd: options?.cwd,
7779
+ env: options?.env ? {
7780
+ ...process.env,
7781
+ ...options.env
7782
+ } : void 0,
7783
+ input: options?.input,
7784
+ encoding: "utf-8",
7785
+ stdio: [
7786
+ "pipe",
7787
+ "pipe",
7788
+ "pipe"
7789
+ ]
7790
+ }),
7791
+ stderr: "",
7792
+ exitCode: 0
7793
+ };
7794
+ } catch (err) {
7795
+ if (isExecSyncError(err)) return {
7796
+ stdout: err.stdout,
7797
+ stderr: err.stderr,
7798
+ exitCode: err.status
7799
+ };
7800
+ return {
7801
+ stdout: "",
7802
+ stderr: "",
7803
+ exitCode: 1
7804
+ };
7805
+ }
7806
+ } };
7807
+ }
7808
+ //#endregion
7809
+ //#region src/coverage-tracking/render.ts
7810
+ const PCT_WIDTH = 7;
7811
+ function pad(value, width) {
7812
+ if (value.length >= width) return value;
7813
+ return value + " ".repeat(width - value.length);
7814
+ }
7815
+ function padLeft(value, width) {
7816
+ if (value.length >= width) return value;
7817
+ return " ".repeat(width - value.length) + value;
7818
+ }
7819
+ function formatPct(n) {
7820
+ return `${n.toFixed(1)}%`;
7821
+ }
7822
+ function formatDelta(n) {
7823
+ if (n === 0) return " 0.00";
7824
+ return `${n > 0 ? "+" : ""}${n.toFixed(2)}`;
7825
+ }
7826
+ /** Format a single record (or baseline) as a labeled, aligned block for `coverage:summary`. */
7827
+ function formatRecordSummary(body) {
7828
+ const t = body.totals;
7829
+ const totalsLine = `lines ${formatPct(t.lines)} | statements ${formatPct(t.statements)} | branches ${formatPct(t.branches)} | functions ${formatPct(t.functions)}`;
7830
+ const lines = [body.sha ? `coverage @ ${body.sha.slice(0, 7)}` : "coverage", totalsLine];
7831
+ const pkgs = Object.entries(body.packages);
7832
+ if (pkgs.length > 0) {
7833
+ const longest = Math.max(...pkgs.map(([name]) => name.length));
7834
+ lines.push("by package:");
7835
+ 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)}`);
7836
+ }
7837
+ return lines.join("\n");
7838
+ }
7839
+ /** Format a CompareResult diff table for `coverage:check` failure output. */
7840
+ function formatCompareDiff(result) {
7841
+ const lines = [];
7842
+ lines.push(`coverage check ${result.pass ? "PASS" : "FAIL"} (tolerance ${result.tolerancePp.toFixed(2)}pp)`);
7843
+ const longestKey = Math.max(...TOTAL_KEYS.map((k) => k.length));
7844
+ for (const total of result.totals) {
7845
+ const status = total.pass ? "✓" : "✗";
7846
+ 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)}`);
7847
+ }
7848
+ return lines.join("\n");
7849
+ }
7850
+ /** Format a trend table for `coverage:status`. */
7851
+ function formatTrendTable(records) {
7852
+ if (records.length === 0) return "no coverage history recorded yet";
7853
+ const rows = records.map((r) => ({
7854
+ sha: r.sha.slice(0, 7),
7855
+ when: formatTimestamp(r.timestamp),
7856
+ totals: r.totals
7857
+ }));
7858
+ const header = [
7859
+ "sha",
7860
+ "when",
7861
+ "lines",
7862
+ "statements",
7863
+ "branches",
7864
+ "functions",
7865
+ "Δlines"
7866
+ ];
7867
+ const widths = [
7868
+ Math.max(header[0].length, ...rows.map((r) => r.sha.length)),
7869
+ Math.max(header[1].length, ...rows.map((r) => r.when.length)),
7870
+ PCT_WIDTH,
7871
+ PCT_WIDTH,
7872
+ PCT_WIDTH,
7873
+ PCT_WIDTH,
7874
+ 7
7875
+ ];
7876
+ const lines = [];
7877
+ lines.push(joinRow(header, widths));
7878
+ lines.push(joinRow(widths.map((w) => "-".repeat(w)), widths));
7879
+ let prevLines;
7880
+ for (const row of rows) {
7881
+ const deltaLines = prevLines === void 0 ? "" : formatDelta(round2(row.totals.lines - prevLines));
7882
+ lines.push(joinRow([
7883
+ row.sha,
7884
+ row.when,
7885
+ formatPct(row.totals.lines),
7886
+ formatPct(row.totals.statements),
7887
+ formatPct(row.totals.branches),
7888
+ formatPct(row.totals.functions),
7889
+ deltaLines
7890
+ ], widths));
7891
+ prevLines = row.totals.lines;
7892
+ }
7893
+ if (records.length >= 2) {
7894
+ const first = records[0];
7895
+ const last = records[records.length - 1];
7896
+ if (first && last) {
7897
+ const overall = round2(last.totals.lines - first.totals.lines);
7898
+ lines.push("");
7899
+ lines.push(`lines ${formatPct(last.totals.lines)} (${formatDelta(overall)} over last ${String(records.length)} records)`);
7900
+ }
7901
+ }
7902
+ return lines.join("\n");
7903
+ }
7904
+ function round2(n) {
7905
+ return Math.round(n * 100) / 100;
7906
+ }
7907
+ function joinRow(cells, widths) {
7908
+ return cells.map((cell, i) => padLeft(cell, widths[i] ?? cell.length)).join(" ");
7909
+ }
7910
+ function formatTimestamp(iso) {
7911
+ const date = new Date(iso);
7912
+ if (Number.isNaN(date.getTime())) return iso;
7913
+ return `${String(date.getUTCFullYear())}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
7914
+ }
7915
+ //#endregion
7916
+ //#region src/commands/coverage-summary.ts
7917
+ const COVERAGE_SUMMARY_FILE$2 = "coverage/coverage-summary.json";
7918
+ const out$3 = (msg) => console.log(msg);
7919
+ function readSummary(targetDir) {
7920
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$2);
7921
+ 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.`);
7922
+ return readFileSync(summaryPath, "utf-8");
7923
+ }
7924
+ function getHeadSha(targetDir) {
7925
+ const result = createRealCoverageExecutor().exec("git", ["rev-parse", "HEAD"], { cwd: targetDir });
7926
+ if (result.exitCode !== 0) return "unknown";
7927
+ return result.stdout.trim();
7928
+ }
7929
+ const coverageSummaryCommand = defineCommand({
7930
+ meta: {
7931
+ name: "coverage:summary",
7932
+ description: "Print a human-readable summary of the latest coverage run"
7933
+ },
7934
+ args: {
7935
+ dir: {
7936
+ type: "positional",
7937
+ description: "Project directory (default: current directory)",
7938
+ required: false
7939
+ },
7940
+ json: {
7941
+ type: "boolean",
7942
+ description: "Emit the trimmed record as JSON on stdout (for downstream tooling)"
7943
+ }
7944
+ },
7945
+ run({ args }) {
7946
+ const targetDir = path.resolve(args.dir ?? ".");
7947
+ const summary = parseV8Summary(readSummary(targetDir));
7948
+ if (args.json === true) {
7949
+ const record = deriveRecord(summary, {
7950
+ rootDir: targetDir,
7951
+ identity: {
7952
+ sha: getHeadSha(targetDir),
7953
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
7954
+ }
7955
+ });
7956
+ out$3(JSON.stringify(record));
7957
+ return;
7958
+ }
7959
+ out$3(formatRecordSummary({
7960
+ ...deriveBaseline(summary, { rootDir: targetDir }),
7961
+ sha: getHeadSha(targetDir)
7962
+ }));
7963
+ }
7964
+ });
7965
+ //#endregion
7966
+ //#region src/coverage-tracking/baseline.ts
7967
+ /** Serialize a baseline to its JSON form (with trailing newline). Single source of truth for layout. */
7968
+ function serializeBaseline(baseline) {
7969
+ return JSON.stringify(baseline, null, 2) + "\n";
7970
+ }
7971
+ //#endregion
7972
+ //#region src/commands/coverage-check.ts
7973
+ const COVERAGE_SUMMARY_FILE$1 = "coverage/coverage-summary.json";
7974
+ const BASELINE_FILE = "coverage-baseline.json";
7975
+ const out$2 = (msg) => console.log(msg);
7976
+ const coverageCheckCommand = defineCommand({
7977
+ meta: {
7978
+ name: "coverage:check",
7979
+ description: "Compare the latest coverage run against coverage-baseline.json"
7980
+ },
7981
+ args: {
7982
+ dir: {
7983
+ type: "positional",
7984
+ description: "Project directory (default: current directory)",
7985
+ required: false
7986
+ },
7987
+ "update-baseline": {
7988
+ type: "boolean",
7989
+ description: "If all four totals are flat-or-up, rewrite coverage-baseline.json"
7990
+ },
7991
+ "no-fail-on-missing-baseline": {
7992
+ type: "boolean",
7993
+ description: "Treat absent baseline as a soft notice (used during bootstrap)"
7994
+ },
7995
+ json: {
7996
+ type: "boolean",
7997
+ description: "Emit machine-readable diff on stdout instead of the table"
7998
+ }
7999
+ },
8000
+ run({ args }) {
8001
+ const targetDir = path.resolve(args.dir ?? ".");
8002
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE$1);
8003
+ const baselinePath = path.join(targetDir, BASELINE_FILE);
8004
+ 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.`);
8005
+ const current = deriveBaseline(parseV8Summary(readFileSync(summaryPath, "utf-8")), { rootDir: targetDir });
8006
+ if (!existsSync(baselinePath)) {
8007
+ if (args["update-baseline"] === true) {
8008
+ writeFileSync(baselinePath, serializeBaseline(current));
8009
+ out$2(`Created ${BASELINE_FILE} (no prior baseline).`);
8010
+ return;
8011
+ }
8012
+ const message = `${BASELINE_FILE} not found. Run with --update-baseline to create one from the current run.`;
8013
+ if (args["no-fail-on-missing-baseline"] === true) {
8014
+ out$2(message);
8015
+ return;
8016
+ }
8017
+ throw new FatalError(message);
8018
+ }
8019
+ const baseline = parseBaseline(readFileSync(baselinePath, "utf-8"));
8020
+ const config = resolveCoverageConfig(targetDir);
8021
+ const result = compareTotals(baseline.totals, current.totals, config.tolerancePp);
8022
+ if (args.json === true) out$2(JSON.stringify(result));
8023
+ else out$2(formatCompareDiff(result));
8024
+ if (args["update-baseline"] === true) if (isFlatOrUp(baseline.totals, current.totals)) {
8025
+ writeFileSync(baselinePath, serializeBaseline(current));
8026
+ out$2(`Updated ${BASELINE_FILE} (all four totals flat-or-up).`);
8027
+ } else out$2(`Skipped baseline update: at least one total dropped below baseline. Add tests or adjust coverage.exclude before updating.`);
8028
+ if (!result.pass) process.exitCode = 1;
8029
+ }
8030
+ });
8031
+ //#endregion
8032
+ //#region src/commands/coverage-record.ts
8033
+ const COVERAGE_SUMMARY_FILE = "coverage/coverage-summary.json";
8034
+ const out$1 = (msg) => console.log(msg);
8035
+ const coverageRecordCommand = defineCommand({
8036
+ meta: {
8037
+ name: "coverage:record",
8038
+ description: "Append the current coverage run to the orphan-branch history"
8039
+ },
8040
+ args: {
8041
+ dir: {
8042
+ type: "positional",
8043
+ description: "Project directory (default: current directory)",
8044
+ required: false
8045
+ },
8046
+ branch: {
8047
+ type: "string",
8048
+ description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
8049
+ }
8050
+ },
8051
+ run({ args }) {
8052
+ const targetDir = path.resolve(args.dir ?? ".");
8053
+ const config = resolveCoverageConfig(targetDir);
8054
+ if (!config.historyEnabled) {
8055
+ out$1("coverage history disabled (.tooling.json: coverage.history === 'none' or coverage === false). Skipping record.");
8056
+ return;
8057
+ }
8058
+ const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE);
8059
+ if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE} not found. Run \`pnpm test:coverage\` before \`coverage:record\`.`);
8060
+ const summary = parseV8Summary(readFileSync(summaryPath, "utf-8"));
8061
+ const executor = createRealCoverageExecutor();
8062
+ const headResult = executor.exec("git", ["rev-parse", "HEAD"], { cwd: targetDir });
8063
+ if (headResult.exitCode !== 0) throw new FatalError(`Failed to resolve HEAD sha: ${headResult.stderr.trim()}`);
8064
+ const sha = headResult.stdout.trim();
8065
+ const record = deriveRecord(summary, {
8066
+ rootDir: targetDir,
8067
+ identity: {
8068
+ sha,
8069
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
8070
+ }
8071
+ });
8072
+ const branch = args.branch ?? config.historyBranch;
8073
+ out$1(`Appending coverage record for ${sha.slice(0, 7)} to ${branch}...`);
8074
+ const result = appendRecord(executor, {
8075
+ cwd: targetDir,
8076
+ historyBranch: branch,
8077
+ authorName: "coverage-bot",
8078
+ authorEmail: "coverage-bot@noreply.localhost"
8079
+ }, record);
8080
+ out$1(`${result.bootstrapped ? "Bootstrapped" : "Appended to"} ${branch}: commit ${result.commitSha.slice(0, 7)}${result.attempts > 1 ? ` (after ${String(result.attempts)} attempts)` : ""}.`);
8081
+ }
8082
+ });
8083
+ //#endregion
8084
+ //#region src/commands/coverage-status.ts
8085
+ const out = (msg) => console.log(msg);
8086
+ //#endregion
7114
8087
  //#region src/bin.ts
7115
8088
  const main = defineCommand({
7116
8089
  meta: {
7117
8090
  name: "bst",
7118
- version: "0.55.2",
8091
+ version: "0.56.0",
7119
8092
  description: "Bootstrap and maintain standardized TypeScript project tooling"
7120
8093
  },
7121
8094
  subCommands: {
@@ -7135,56 +8108,59 @@ const main = defineCommand({
7135
8108
  "setup:secrets": setupSecretsCommand,
7136
8109
  "forgejo:secrets": forgejoSecretsCommand,
7137
8110
  "forgejo:webhooks": forgejoWebhooksCommand,
7138
- "webhook:send": defineCommand({
8111
+ "webhook:send": webhookSendCommand,
8112
+ "coverage:summary": coverageSummaryCommand,
8113
+ "coverage:check": coverageCheckCommand,
8114
+ "coverage:record": coverageRecordCommand,
8115
+ "coverage:status": defineCommand({
7139
8116
  meta: {
7140
- name: "webhook:send",
7141
- description: "Send a POST request to a webhook URL with GitHub-style HMAC-SHA256 authentication"
8117
+ name: "coverage:status",
8118
+ description: "Show recent coverage history (read-only; fetches from the orphan branch)"
7142
8119
  },
7143
8120
  args: {
7144
- url: {
8121
+ dir: {
7145
8122
  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."
8123
+ description: "Project directory (default: current directory)",
8124
+ required: false
7155
8125
  },
7156
- "server-url": {
8126
+ limit: {
7157
8127
  type: "string",
7158
- description: "Server URL (e.g. https://github.com). Falls back to GITHUB_SERVER_URL env var."
8128
+ description: "Number of trailing records to render (default: 10)"
7159
8129
  },
7160
- actor: {
8130
+ branch: {
7161
8131
  type: "string",
7162
- description: "User or bot that triggered the release. Falls back to GITHUB_ACTOR env var."
8132
+ description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
7163
8133
  },
7164
- prerelease: {
8134
+ json: {
7165
8135
  type: "boolean",
7166
- description: "Mark the release as a prerelease"
8136
+ description: "Emit the parsed records as JSON instead of the table"
7167
8137
  }
7168
8138
  },
7169
- async run({ args }) {
7170
- if (!args.url) {
7171
- log.info("No webhook URL configured — skipping post-release hook");
8139
+ run({ args }) {
8140
+ const targetDir = path.resolve(args.dir ?? ".");
8141
+ const config = resolveCoverageConfig(targetDir);
8142
+ const branch = args.branch ?? config.historyBranch;
8143
+ const limit = args.limit ? Math.max(1, Number.parseInt(args.limit, 10) || 10) : 10;
8144
+ const records = readRecords(createRealCoverageExecutor(), {
8145
+ cwd: targetDir,
8146
+ historyBranch: branch
8147
+ });
8148
+ if (records.length === 0) {
8149
+ if (args.json === true) out("[]");
8150
+ else out(`no coverage history recorded yet (branch: ${branch})`);
8151
+ return;
8152
+ }
8153
+ const trailing = records.slice(-limit);
8154
+ if (args.json === true) {
8155
+ out(JSON.stringify(trailing));
7172
8156
  return;
7173
8157
  }
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);
8158
+ out(formatTrendTable(trailing));
7183
8159
  }
7184
8160
  })
7185
8161
  }
7186
8162
  });
7187
- console.log(`@bensandee/tooling v0.55.2`);
8163
+ console.log(`@bensandee/tooling v0.56.0`);
7188
8164
  async function run() {
7189
8165
  await runMain(main);
7190
8166
  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.56.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "prettier": "3.8.3",
55
55
  "release-it": "20.0.0",
56
56
  "tsdown": "0.21.10",
57
- "@bensandee/claude-hooks": "0.7.2",
58
- "@bensandee/config": "0.10.2"
57
+ "@bensandee/config": "0.10.2",
58
+ "@bensandee/claude-hooks": "0.7.2"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",
@@ -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