@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 +74 -0
- package/dist/bin.mjs +1013 -37
- package/package.json +3 -3
- 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.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.
|
|
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.
|
|
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":
|
|
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: "
|
|
7141
|
-
description: "
|
|
8117
|
+
name: "coverage:status",
|
|
8118
|
+
description: "Show recent coverage history (read-only; fetches from the orphan branch)"
|
|
7142
8119
|
},
|
|
7143
8120
|
args: {
|
|
7144
|
-
|
|
8121
|
+
dir: {
|
|
7145
8122
|
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."
|
|
8123
|
+
description: "Project directory (default: current directory)",
|
|
8124
|
+
required: false
|
|
7155
8125
|
},
|
|
7156
|
-
|
|
8126
|
+
limit: {
|
|
7157
8127
|
type: "string",
|
|
7158
|
-
description: "
|
|
8128
|
+
description: "Number of trailing records to render (default: 10)"
|
|
7159
8129
|
},
|
|
7160
|
-
|
|
8130
|
+
branch: {
|
|
7161
8131
|
type: "string",
|
|
7162
|
-
description: "
|
|
8132
|
+
description: "Override the orphan branch name (default: from .tooling.json or 'bst/coverage-metrics')"
|
|
7163
8133
|
},
|
|
7164
|
-
|
|
8134
|
+
json: {
|
|
7165
8135
|
type: "boolean",
|
|
7166
|
-
description: "
|
|
8136
|
+
description: "Emit the parsed records as JSON instead of the table"
|
|
7167
8137
|
}
|
|
7168
8138
|
},
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
58
|
-
"@bensandee/
|
|
57
|
+
"@bensandee/config": "0.10.2",
|
|
58
|
+
"@bensandee/claude-hooks": "0.7.2"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsdown",
|
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
|