@bensandee/tooling 0.87.2 → 0.88.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.mjs +16 -160
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -332,7 +332,7 @@ The feature has two halves: a **CI history** that captures one record per commit
332
332
 
333
333
  #### How it works
334
334
 
335
- `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:
335
+ `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. The generated CI workflow (`ci.yml`, or `check.yml` for a non-changesets strategy) runs the coverage steps on push to the default branch and pipes:
336
336
 
337
337
  ```text
338
338
  pnpm test:coverage -> coverage:summary -> coverage:record -> coverage:status
package/dist/bin.mjs CHANGED
@@ -269,7 +269,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
269
269
  */
270
270
  const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
271
271
  name: "bst",
272
- version: "0.87.2"
272
+ version: "0.88.0"
273
273
  });
274
274
  //#endregion
275
275
  //#region ../cli-kit/src/logger.ts
@@ -7029,8 +7029,6 @@ const MANAGED_SCRIPTS = {
7029
7029
  function matchesManagedScript(scriptValue, expectedFragment) {
7030
7030
  return scriptValue.includes(expectedFragment);
7031
7031
  }
7032
- /** Deprecated scripts to remove during migration. */
7033
- const DEPRECATED_SCRIPTS = ["tooling:init", "tooling:update"];
7034
7032
  /**
7035
7033
  * DevDeps for each project/package (single repo) or per-package (monorepo),
7036
7034
  * filtered by what the stack actually uses. `typescript` is dropped (not just
@@ -7360,10 +7358,6 @@ function mergeManagedScripts(pkg, allScripts, changes) {
7360
7358
  changes.push(`updated script: ${key}`);
7361
7359
  }
7362
7360
  }
7363
- for (const key of DEPRECATED_SCRIPTS) if (key in existingScripts) {
7364
- delete existingScripts[key];
7365
- changes.push(`removed deprecated script: ${key}`);
7366
- }
7367
7361
  pkg.scripts = existingScripts;
7368
7362
  }
7369
7363
  /**
@@ -7593,9 +7587,9 @@ function buildManagedDevDeps(ctx) {
7593
7587
  const isMonorepo = ctx.config.structure === "monorepo";
7594
7588
  const devDeps = { ...rootDevDeps(ctx.config) };
7595
7589
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
7596
- if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.14.5";
7597
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.87.2";
7598
- if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.16.0";
7590
+ if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.15.0";
7591
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.88.0";
7592
+ if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.17.0";
7599
7593
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
7600
7594
  "@changesets/cli": "3.0.3",
7601
7595
  "@release-it/bumper": "8.0.1",
@@ -10579,41 +10573,6 @@ export default {${ignoreBinariesField(ignoreBinaries)}
10579
10573
  } satisfies KnipConfig;
10580
10574
  `;
10581
10575
  }
10582
- /**
10583
- * What earlier versions of this generator wrote. A repo that still carries one verbatim
10584
- * is carrying our redundant ignores, and its `knip` step still reports the two config
10585
- * hints -- so we rewrite it to the current template. Only a byte-identical match (modulo
10586
- * the repo's formatter) qualifies: a config the user has edited is theirs, hints or not.
10587
- */
10588
- function buildLegacyKnipConfigSingle(ignoreBinaries) {
10589
- return `import type { KnipConfig } from "knip";
10590
-
10591
- export default {
10592
- entry: ["src/index.ts"],
10593
- project: ["src/**/*.ts"],
10594
- ignore: ["dist/**", ".claude/hooks/**"],${ignoreBinariesField(ignoreBinaries)}
10595
- } satisfies KnipConfig;
10596
- `;
10597
- }
10598
- function buildLegacyKnipConfigMonorepo(ignoreBinaries) {
10599
- return `import type { KnipConfig } from "knip";
10600
-
10601
- export default {
10602
- ignore: [".claude/hooks/**"],${ignoreBinariesField(ignoreBinaries)}
10603
- workspaces: {
10604
- ".": {
10605
- entry: [],
10606
- project: [],
10607
- },
10608
- "packages/*": {
10609
- entry: ["src/index.ts", "src/bin.ts"],
10610
- project: ["src/**/*.ts"],
10611
- ignore: ["dist/**"],
10612
- },
10613
- },
10614
- } satisfies KnipConfig;
10615
- `;
10616
- }
10617
10576
  const REQUIRED_IGNORE = ".claude/hooks/**";
10618
10577
  /** The file bst deploys into the consumer's repo, and the only thing REQUIRED_IGNORE defends. */
10619
10578
  const HOOK_FILE = ".claude/hooks/bst-hook.mjs";
@@ -10774,14 +10733,6 @@ async function generateKnip(ctx) {
10774
10733
  const buildConfig = isMonorepo ? buildKnipConfigMonorepo : buildKnipConfigSingle;
10775
10734
  const existingPath = KNIP_CONFIG_PATHS.find((p) => ctx.exists(p));
10776
10735
  if (existingPath) {
10777
- if (existingPath === filePath && isLegacyTemplate(ctx, filePath, isMonorepo, ignoreBinaries)) {
10778
- ctx.write(filePath, formattedTemplate(ctx, filePath, buildConfig(ignoreBinaries)));
10779
- return {
10780
- filePath,
10781
- action: "updated",
10782
- description: "Dropped ignore patterns that are already outside knip's project scope"
10783
- };
10784
- }
10785
10736
  const content = ctx.read(existingPath);
10786
10737
  if (content === void 0) return {
10787
10738
  filePath: existingPath,
@@ -10819,21 +10770,9 @@ async function generateKnip(ctx) {
10819
10770
  description: createdDescription(filePath)
10820
10771
  };
10821
10772
  }
10822
- /**
10823
- * Whether the config on disk is one this generator wrote before the redundant ignores were
10824
- * dropped. Both the currently-detected `ignoreBinaries` and the empty form are candidates:
10825
- * a repo that gained a `composer.json` after its last sync carries the older, binary-less
10826
- * variant, and it deserves the same cleanup.
10827
- */
10828
- function isLegacyTemplate(ctx, filePath, isMonorepo, ignoreBinaries) {
10829
- const build = isMonorepo ? buildLegacyKnipConfigMonorepo : buildLegacyKnipConfigSingle;
10830
- return (ignoreBinaries.length > 0 ? [build(ignoreBinaries), build([])] : [build([])]).some((legacy) => isGeneratedTemplate(ctx, filePath, legacy));
10831
- }
10832
10773
  //#endregion
10833
10774
  //#region src/generators/renovate.ts
10834
10775
  const SHARED_PRESET = "local>bensandee/tooling";
10835
- /** Deprecated npm-based preset to migrate away from. */
10836
- const LEGACY_PRESET = "@bensandee/config";
10837
10776
  /** All known renovate config file locations, in priority order. */
10838
10777
  const RENOVATE_CONFIG_PATHS = [
10839
10778
  "renovate.json",
@@ -10856,17 +10795,6 @@ async function generateRenovate(ctx) {
10856
10795
  if (existing) {
10857
10796
  const parsed = parseRenovateJson(existing);
10858
10797
  const existingExtends = parsed.extends ?? [];
10859
- const legacyIndex = existingExtends.indexOf(LEGACY_PRESET);
10860
- if (legacyIndex !== -1) {
10861
- existingExtends[legacyIndex] = SHARED_PRESET;
10862
- parsed.extends = existingExtends;
10863
- ctx.write(existingPath, JSON.stringify(parsed, null, 2) + "\n");
10864
- return {
10865
- filePath: existingPath,
10866
- action: "updated",
10867
- description: `Migrated extends: ${LEGACY_PRESET} -> ${SHARED_PRESET}`
10868
- };
10869
- }
10870
10798
  if (!existingExtends.includes(SHARED_PRESET)) {
10871
10799
  existingExtends.unshift(SHARED_PRESET);
10872
10800
  parsed.extends = existingExtends;
@@ -11088,22 +11016,6 @@ function parseClaudeSettings(raw) {
11088
11016
  return;
11089
11017
  }
11090
11018
  }
11091
- /**
11092
- * Instructions previously emitted to `.claude/settings.json#instructions`.
11093
- * Migrated to a managed block in CLAUDE.md (see `claude-md.ts`); on sync we
11094
- * strip any existing entry that matches (by either-direction prefix, mirroring
11095
- * the upgrade-in-place semantics the merge logic used to apply on add).
11096
- */
11097
- const MIGRATED_INSTRUCTIONS = [
11098
- "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
11099
- "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
11100
- "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option. For no-empty-function: add a `{ /* no-op */ }` comment body instead of a disable comment.",
11101
- "Prefer extensionless imports; if an extension is required, use .ts over .js.",
11102
- "The bst-hook manages Bash/file permissions. See .claude/hooks/bst-hook-guide.md for auto-approved, denied, and passthrough commands."
11103
- ];
11104
- function isMigratedInstruction(s) {
11105
- return MIGRATED_INSTRUCTIONS.some((c) => s.startsWith(c) || c.startsWith(s));
11106
- }
11107
11019
  function buildSettings(ctx) {
11108
11020
  const allow = [];
11109
11021
  const enabledPlugins = { "code-simplifier@claude-plugins-official": true };
@@ -11127,15 +11039,6 @@ function buildSettings(ctx) {
11127
11039
  };
11128
11040
  }
11129
11041
  const BST_HOOK_MARKER = "bst-hook.mjs";
11130
- /** Remove old per-tool bst-hook entries that are now covered by a consolidated matcher. */
11131
- function removeObsoleteBstHookEntries(matchers, consolidatedMatcher) {
11132
- const consolidatedTools = new Set(consolidatedMatcher.split("|"));
11133
- return matchers.filter((m) => {
11134
- if (m.matcher === void 0 || !consolidatedTools.has(m.matcher)) return true;
11135
- if (!m.hooks.some((h) => h.command?.includes(BST_HOOK_MARKER) === true)) return true;
11136
- return false;
11137
- });
11138
- }
11139
11042
  /**
11140
11043
  * Merge a single generated matcher into `mergedHooks` for the given event,
11141
11044
  * returning the number of changes made (for change tracking).
@@ -11156,15 +11059,8 @@ function mergeGeneratedMatcher(mergedHooks, existingMatchers, event, gm) {
11156
11059
  }
11157
11060
  return 0;
11158
11061
  }
11159
- let changes = 0;
11160
- if (gm.matcher !== void 0 && gm.matcher.includes("|")) {
11161
- const before = (mergedHooks[event] ?? []).length;
11162
- mergedHooks[event] = removeObsoleteBstHookEntries(mergedHooks[event] ?? [], gm.matcher);
11163
- changes += before - (mergedHooks[event] ?? []).length;
11164
- }
11165
11062
  mergedHooks[event] = [...mergedHooks[event] ?? [], gm];
11166
- changes++;
11167
- return changes;
11063
+ return 1;
11168
11064
  }
11169
11065
  /** Remove empty optional fields to keep the file clean. */
11170
11066
  function serializeSettings$1(settings) {
@@ -11188,9 +11084,6 @@ function writeOrMergeSettings(ctx, filePath, generated) {
11188
11084
  const { settings, rawJson } = parsed;
11189
11085
  const missingAllow = generated.permissions.allow.filter((rule) => !settings.permissions.allow.includes(rule));
11190
11086
  const missingDeny = generated.permissions.deny.filter((rule) => !settings.permissions.deny.includes(rule));
11191
- const filteredInstructions = settings.instructions.filter((s) => !isMigratedInstruction(s));
11192
- const instructionChanges = settings.instructions.length - filteredInstructions.length;
11193
- const mergedInstructions = filteredInstructions;
11194
11087
  let hookChanges = 0;
11195
11088
  const mergedHooks = { ...settings.hooks };
11196
11089
  for (const [event, generatedMatchers] of Object.entries(generated.hooks)) {
@@ -11199,11 +11092,11 @@ function writeOrMergeSettings(ctx, filePath, generated) {
11199
11092
  }
11200
11093
  const missingPlugins = Object.entries(generated.enabledPlugins).filter(([key]) => !(key in settings.enabledPlugins));
11201
11094
  const missingMarketplaces = Object.entries(generated.extraKnownMarketplaces).filter(([key]) => !(key in settings.extraKnownMarketplaces));
11202
- const changed = missingAllow.length + missingDeny.length + hookChanges + instructionChanges + missingPlugins.length + missingMarketplaces.length;
11095
+ const changed = missingAllow.length + missingDeny.length + hookChanges + missingPlugins.length + missingMarketplaces.length;
11203
11096
  if (changed === 0) return {
11204
11097
  filePath,
11205
11098
  action: "skipped",
11206
- description: "Already has all rules and instructions"
11099
+ description: "Already has all rules and hooks"
11207
11100
  };
11208
11101
  rawJson["permissions"] = {
11209
11102
  ...settings.permissions,
@@ -11211,7 +11104,7 @@ function writeOrMergeSettings(ctx, filePath, generated) {
11211
11104
  deny: [...settings.permissions.deny, ...missingDeny]
11212
11105
  };
11213
11106
  if (Object.keys(mergedHooks).length > 0) rawJson["hooks"] = mergedHooks;
11214
- if (mergedInstructions.length > 0) rawJson["instructions"] = mergedInstructions;
11107
+ if (settings.instructions.length > 0) rawJson["instructions"] = settings.instructions;
11215
11108
  else delete rawJson["instructions"];
11216
11109
  const updatedPlugins = { ...settings.enabledPlugins };
11217
11110
  for (const [key, value] of missingPlugins) updatedPlugins[key] = value;
@@ -11225,7 +11118,7 @@ function writeOrMergeSettings(ctx, filePath, generated) {
11225
11118
  return {
11226
11119
  filePath,
11227
11120
  action: "updated",
11228
- description: `Updated ${String(changed)} rules/instructions`
11121
+ description: `Updated ${String(changed)} rules/hooks`
11229
11122
  };
11230
11123
  }
11231
11124
  ctx.write(filePath, serializeSettings$1(generated));
@@ -11628,46 +11521,6 @@ async function generateVscodeSettings(ctx) {
11628
11521
  return results;
11629
11522
  }
11630
11523
  //#endregion
11631
- //#region src/generators/coverage-ci.ts
11632
- /**
11633
- * Cleanup-only generator: coverage steps now run inline in the CI workflow (see generateCi), so
11634
- * any legacy `coverage.yml` left over from an older sync is removed.
11635
- *
11636
- * The result is `removed`, not `archived`: `repo:sync --check` counts only the former as drift,
11637
- * and the agent's workflow sync sweep acts only on drift the check reports. An `archived` result
11638
- * let a plain `repo:sync` delete a file while `--check` called the repo up to date.
11639
- *
11640
- * The ignore comment is the only opt-out here, and there is deliberately no managed-header guard:
11641
- * this file predates the header, so no legacy `coverage.yml` in a synced repo carries one. Requiring
11642
- * it would leave every file this generator exists to remove exactly where it is.
11643
- */
11644
- async function generateCoverageCi(ctx) {
11645
- const filePath = "coverage-ci";
11646
- if (ctx.config.ci === "none") return {
11647
- filePath,
11648
- action: "skipped",
11649
- description: "CI workflow not requested"
11650
- };
11651
- const workflowPath = ctx.config.ci === "github" ? ".github/workflows/coverage.yml" : ".forgejo/workflows/coverage.yml";
11652
- const existing = ctx.read(workflowPath);
11653
- if (existing === void 0) return {
11654
- filePath,
11655
- action: "skipped",
11656
- description: "Coverage runs inline in ci.yml"
11657
- };
11658
- if (isToolingIgnored(existing)) return {
11659
- filePath: workflowPath,
11660
- action: "skipped",
11661
- description: "coverage.yml has ignore comment"
11662
- };
11663
- ctx.remove(workflowPath);
11664
- return {
11665
- filePath: workflowPath,
11666
- action: "removed",
11667
- description: `Moved to ${ARCHIVE_DIR$1}/${workflowPath} (coverage now runs inline in ci.yml)`
11668
- };
11669
- }
11670
- //#endregion
11671
11524
  //#region src/docs-check/blocks.ts
11672
11525
  /**
11673
11526
  * A line that opens something other than a labelled-line continuation: a `Key:`-style
@@ -12396,6 +12249,7 @@ const COMMENTS_GUIDE_BODY = [
12396
12249
  "- Default to none. A comment earns its place by carrying what the code cannot -- a rejected alternative, the bug that forced this shape, an invariant enforced elsewhere -- and earns it one decision at a time, in a sentence or two each: what was rejected, and what it cost. Five decisions is five sentences, not five paragraphs. Recoverable from the code? Leave it unwritten.",
12397
12250
  "- Present tense, about what is. No `now`, `previously`, `changed to`: diff narration is stale on the next edit, and the commit message carries it. No running ledgers or changelogs: when adjusting a threshold, constant, or baseline, state the active invariant and prune past deltas.",
12398
12251
  "- Never cite a lifecycle-managed doc, PR, or issue from code: no PR or issue numbers (`PR #123`, `closes #456`), no path under `docs/superpowers/` or `docs/design/`, none carrying a `retired/` or `completed/` segment, no `docs/plan-*.md`, and no bare decision or task ID (`D3`, `T2`). They move, retire, and renumber, nothing checks the reference, and the reader has no way to tell a dead one from a live one. State the constraint itself, or cite a maintained reference doc instead. A `docs/` path that is a glob or an elided example rather than a reference (`docs/*.md`) is vocabulary, not a citation, and is fine.",
12252
+ "- Code that exists only to accept an older shape -- a file, field, flag, environment variable, label or comment format -- carries `// @compat <id> until <expiry>: <reason>` on the line above it. The expiry is a date (`YYYY-MM-DD`) when the old shape lives with consumers who upgrade on their own schedule; a version of the code's own package (`v1.4.0`) when its consumer upgrades with each release; or `forge-clean` for old data on the forge, which has neither. Remove the code and every test that carries the same id together.",
12399
12253
  "- **The code is the fact.** When a comment disagrees with it, fix it in one line or delete it -- never grow one to argue the code is right."
12400
12254
  ].join("\n");
12401
12255
  /**
@@ -12424,7 +12278,10 @@ const TESTING_GUIDE_BODY = [
12424
12278
  "- Never assert that a constant equals its own literal (a default, threshold, model name, label). Test the logic that reads it.",
12425
12279
  "- Never pin prose with `toContain`/`toBe`. Snapshot it, or assert a property: ordering, absence, output that changes with an input, a character set or length.",
12426
12280
  "- One case per branch. A new literal down a path already tested, or the same call through a one-line wrapper, is a duplicate.",
12427
- "- Never assert what the test set up: a mock's stub, a type the compiler guarantees, a call count no caller relies on."
12281
+ "- Never assert what the test set up: a mock's stub, a type the compiler guarantees, a call count no caller relies on.",
12282
+ "- A test that exists only for a compatibility path carries the same `@compat` marker as the code.",
12283
+ "- Test a branch in the module that owns it. A wrapper gets one test that it is wired to that module, not a re-run of the module's own cases.",
12284
+ "- A fixture used in three or more cases goes into a builder, in the test file or in a shared test helper, not written out again in each case."
12428
12285
  ].join("\n");
12429
12286
  /**
12430
12287
  * Worktrees, issue linking, commit subjects, and merging the default branch in. The issue-linking
@@ -13260,7 +13117,6 @@ async function runGenerators(ctx) {
13260
13117
  results.push(await generateReleaseCi(ctx));
13261
13118
  results.push(...await generateStaleCiWorkflows(ctx));
13262
13119
  results.push(await generateDeployCi(ctx));
13263
- results.push(await generateCoverageCi(ctx));
13264
13120
  results.push(...await generateClaudeMd(ctx));
13265
13121
  results.push(...await generateVitest(ctx));
13266
13122
  results.push(...await generateVscodeSettings(ctx));
@@ -13291,7 +13147,7 @@ function reportFailures(failed, command) {
13291
13147
  function generateMigratePrompt(results, config, detected) {
13292
13148
  const sections = [];
13293
13149
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
13294
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.87.2 repo:sync\` on ${timestamp}_\n`);
13150
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.88.0 repo:sync\` on ${timestamp}_\n`);
13295
13151
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
13296
13152
  if (changed.length > 0) {
13297
13153
  const items = changed.map((r) => {
@@ -33418,7 +33274,7 @@ setForgejoLogger(log$1);
33418
33274
  * `local <sha>[-dirty]` otherwise -- so an artifact built from the working tree
33419
33275
  * can't pass itself off as the published release whose version it carries.
33420
33276
  */
33421
- const displayVersion = "0.87.2";
33277
+ const displayVersion = "0.88.0";
33422
33278
  /**
33423
33279
  * The registered `bst` subcommand map, exported so both the JSON entry path below (resolving a
33424
33280
  * command name before citty parses anything) and the coverage test (enumerating every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.87.2",
3
+ "version": "0.88.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {