@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 +74 -0
- package/dist/bin.mjs +1060 -47
- package/package.json +1 -1
- package/tooling.schema.json +33 -0
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.
|
|
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)
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
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.
|
|
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.
|
|
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":
|
|
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: "
|
|
7141
|
-
description: "
|
|
8154
|
+
name: "coverage:status",
|
|
8155
|
+
description: "Show recent coverage history (read-only; fetches from the orphan branch)"
|
|
7142
8156
|
},
|
|
7143
8157
|
args: {
|
|
7144
|
-
|
|
8158
|
+
dir: {
|
|
7145
8159
|
type: "positional",
|
|
7146
|
-
description: "
|
|
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
|
-
|
|
8163
|
+
limit: {
|
|
7157
8164
|
type: "string",
|
|
7158
|
-
description: "
|
|
8165
|
+
description: "Number of trailing records to render (default: 10)"
|
|
7159
8166
|
},
|
|
7160
|
-
|
|
8167
|
+
branch: {
|
|
7161
8168
|
type: "string",
|
|
7162
|
-
description: "
|
|
8169
|
+
description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
|
|
7163
8170
|
},
|
|
7164
|
-
|
|
8171
|
+
json: {
|
|
7165
8172
|
type: "boolean",
|
|
7166
|
-
description: "
|
|
8173
|
+
description: "Emit the parsed records as JSON instead of the table"
|
|
7167
8174
|
}
|
|
7168
8175
|
},
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
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
|
-
|
|
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.
|
|
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
package/tooling.schema.json
CHANGED
|
@@ -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
|