@rtorcato/repo-tooling 3.35.0 → 3.37.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/AGENTS.md CHANGED
@@ -26,7 +26,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
26
26
  | `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
27
27
  | `list --json` | ✅ | ✅ | Enumerate the library's surface area. Each entry has `{ name, description, exports, fixTarget }`. |
28
28
  | `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
29
- | `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`). Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout — both non-zero halt the tick. |
29
+ | `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`), and assert `gh` authenticates as the declared `rules.aiLoop.agentUser`. Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout or the agent identity is wrong — both non-zero halt the tick. |
30
30
 
31
31
  ## Recommended workflows
32
32
 
@@ -94,11 +94,11 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
94
94
 
95
95
  ## Drift policy (important)
96
96
 
97
- `fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
97
+ `fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
98
98
 
99
99
  Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
100
100
 
101
- A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
101
+ A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422), and `biome` against a `biome.json` that does not parse, whose settings cannot be merged (#587). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
102
102
 
103
103
  ## Source-of-truth files in the repo
104
104
 
package/README.md CHANGED
@@ -67,9 +67,10 @@ Tailwind v4, exclude your stylesheet in `biome.json`:
67
67
  ```jsonc
68
68
  {
69
69
  "extends": ["@rtorcato/repo-tooling/biome"],
70
- // `includes` replaces the preset's list rather than extending it — restate
71
- // the exclusions you still want alongside the CSS one.
72
- "files": { "includes": ["**", "!**/node_modules", "!**/dist", "!**/*.css"] }
70
+ // List only the extra negations — Biome merges them into the preset's
71
+ // `includes`. Do NOT repeat the leading `"**"`: that is a lint error,
72
+ // `lint/suspicious/noBiomeFirstException`.
73
+ "files": { "includes": ["!**/*.css"] }
73
74
  }
74
75
  ```
75
76
 
@@ -91,7 +92,7 @@ See the [Getting Started guide](https://rtorcato.github.io/repo-tooling/guides/g
91
92
  | `copy <config>` | Copy a single config file into the current project. | `npx @rtorcato/repo-tooling copy biome` |
92
93
  | `doctor` | Diagnose an existing project for missing or drifted tooling. | `npx @rtorcato/repo-tooling doctor` |
93
94
  | `fix [target]` | Apply scaffolders for what `doctor` flagged (`--yes`, `--dry-run`, `--diff`). | `npx @rtorcato/repo-tooling fix` |
94
- | `loop guard` | Repair a main checkout that has gone `core.bare = true`, and gate the `node_modules` rebuild after a worktree removal. Exits `1` if the repair failed and `2` if the root is not a repairable checkout — see `--help`. | `npx @rtorcato/repo-tooling loop guard --root .` |
95
+ | `loop guard` | Repair a main checkout that has gone `core.bare = true`, gate the `node_modules` rebuild after a worktree removal, and halt when `gh` is not authenticated as the `rules.aiLoop.agentUser` the repo declares. Exits `1` if the repair failed and `2` if the root is not a repairable checkout or the identity is wrong — see `--help`. | `npx @rtorcato/repo-tooling loop guard --root .` |
95
96
 
96
97
  Prefer to run the audit in CI? `doctor` also ships as a GitHub Action:
97
98
 
@@ -22,7 +22,7 @@ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
22
22
  import { compareRulesWithReference } from '../utils/reference-rules.js';
23
23
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
24
24
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
25
- import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
25
+ import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPeerVersions, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
26
26
  export { evaluateNodeVersion };
27
27
  const PACKAGE = '@rtorcato/repo-tooling';
28
28
  // Detects the broken-release-on-protected-main footgun: a workflow that runs
@@ -366,6 +366,7 @@ export async function runDoctor(dir, skillsDir) {
366
366
  results.push(await checkPackageManager(targetDir, pkg));
367
367
  results.push(await checkConfigSchemaVersions(targetDir, pkg));
368
368
  results.push(checkGitDependencies(pkg));
369
+ results.push(await checkPeerVersions(targetDir, pkg));
369
370
  results.push(await checkVscodeExtensions(targetDir));
370
371
  results.push(await checkNodeVersionPin(targetDir));
371
372
  results.push(await checkNodeVersionConsistency(targetDir, pkg));
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import fs from 'fs-extra';
5
5
  import { realGitExec } from '../../base/git-identity.js';
6
+ import { realGhExec } from '../../base/github-settings.js';
6
7
  /**
7
8
  * The invariant table from the skill, verified on git 2.55.0:
8
9
  *
@@ -34,6 +35,45 @@ export function classifyRoot(insideWorkTree, gitEntry) {
34
35
  return 'linked-worktree';
35
36
  return 'genuinely-bare';
36
37
  }
38
+ const LOCKFILE = '.repo-tooling.json';
39
+ /**
40
+ * `rules.aiLoop.agentUser`, with the flat pre-v4 fallback — the same pair the
41
+ * skill's `jq` reads. Read raw rather than through `readLockfile`, whose parser
42
+ * requires a `record.config`: a hand-written rules-only lockfile is exactly the
43
+ * file this has to see.
44
+ */
45
+ export async function configuredAgentUser(root) {
46
+ const raw = await fs.readJson(path.join(root, LOCKFILE)).catch(() => null);
47
+ const user = raw?.rules?.aiLoop?.agentUser ?? raw?.aiLoop?.agentUser;
48
+ return typeof user === 'string' && user.trim() !== '' ? user.trim() : undefined;
49
+ }
50
+ /**
51
+ * Declared intent that is not met is a misconfiguration, not a degraded mode —
52
+ * so this halts, unlike the assignability check in `base/agent-user.ts`, which
53
+ * warns and carries on. That check can never catch this: `agentUser` is
54
+ * assignable regardless of who is calling.
55
+ */
56
+ export async function checkAgentIdentity(configured, gh) {
57
+ if (!configured) {
58
+ return {
59
+ verdict: 'not-configured',
60
+ message: `no aiLoop.agentUser in ${LOCKFILE} — identity check skipped`,
61
+ };
62
+ }
63
+ const r = await gh(['api', 'user', '--jq', '.login']);
64
+ const effective = r.ok ? r.stdout.trim() : '';
65
+ // GitHub logins are case-insensitive, so a case difference is one account.
66
+ if (effective !== '' && effective.toLowerCase() === configured.toLowerCase()) {
67
+ return {
68
+ verdict: 'match',
69
+ message: `gh is authenticated as ${effective} — the configured agent account`,
70
+ };
71
+ }
72
+ return {
73
+ verdict: 'mismatch',
74
+ message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account`,
75
+ };
76
+ }
37
77
  /**
38
78
  * `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
39
79
  * `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
@@ -99,6 +139,7 @@ export async function runLoopGuard(options = {}) {
99
139
  ? path.resolve(options.worktreeRoot)
100
140
  : defaultWorktreeRoot(root);
101
141
  const git = options.git ?? ((args) => realGitExec(args, root));
142
+ const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
102
143
  const install = options.install ?? realInstall;
103
144
  const messages = [];
104
145
  const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
@@ -127,6 +168,11 @@ export async function runLoopGuard(options = {}) {
127
168
  else {
128
169
  messages.push('main checkout is a work tree');
129
170
  }
171
+ const { verdict: identity, message: identityMessage } = await checkAgentIdentity(await configuredAgentUser(root), gh);
172
+ messages.push(identityMessage);
173
+ // A failed repair (1) is the more specific verdict, so it keeps the code.
174
+ if (identity === 'mismatch' && exitCode === 0)
175
+ exitCode = 2;
130
176
  const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
131
177
  const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
132
178
  let outcome = rebuild;
@@ -146,7 +192,7 @@ export async function runLoopGuard(options = {}) {
146
192
  messages.push('node_modules rebuilt');
147
193
  }
148
194
  }
149
- return { root, worktreeRoot, state, bare, rebuild: outcome, live, exitCode, messages };
195
+ return { root, worktreeRoot, state, bare, identity, rebuild: outcome, live, exitCode, messages };
150
196
  }
151
197
  /**
152
198
  * The three load-bearing conditions, kept separate from the run so a test can
@@ -92,7 +92,10 @@ export function buildPresetConfig(name, projectName) {
92
92
  ...BASE,
93
93
  projectName,
94
94
  projectType: 'react-app',
95
- typescript: { enabled: true, config: 'react' },
95
+ // vite-app, not react: this preset ships a Vite bundler, so
96
+ // `import.meta.env` has to typecheck, and typecheck is the app's CI
97
+ // gate rather than a build, so its tests are checked too (#592).
98
+ typescript: { enabled: true, config: 'vite-app' },
96
99
  testing: { framework: 'vitest', environment: 'browser' },
97
100
  bundler: 'vite',
98
101
  };
@@ -164,7 +167,7 @@ export const CONFIG_SCHEMA = {
164
167
  required: ['enabled', 'config'],
165
168
  properties: {
166
169
  enabled: { type: 'boolean' },
167
- config: { type: 'string', enum: ['base', 'react', 'next', 'node', 'express'] },
170
+ config: { type: 'string', enum: ['base', 'react', 'vite-app', 'next', 'node', 'express'] },
168
171
  },
169
172
  },
170
173
  linting: {
@@ -322,7 +322,13 @@ async function promptForConfig(targetDir, seed) {
322
322
  return [{ name: '⚡ Next.js', value: 'next' }, ...baseChoices];
323
323
  }
324
324
  if (answers.projectType === 'react-app' || answers.projectType === 'web-app') {
325
- return [{ name: '⚛️ React', value: 'react' }, ...baseChoices];
325
+ return [
326
+ // First, so it's the default: most React projects are apps, and the
327
+ // react preset is library-shaped — no vite/client, tests excluded.
328
+ { name: '⚛️ React + Vite app', value: 'vite-app' },
329
+ { name: '📦 React library', value: 'react' },
330
+ ...baseChoices,
331
+ ];
326
332
  }
327
333
  if (answers.projectType === 'node-api') {
328
334
  return [
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
+ import { parseJsonc } from '../utils/jsonc.js';
3
4
  export async function generateLintingConfigs(config, targetDir) {
4
5
  // Generate Biome config
5
6
  if (config.linting.tool === 'biome' || config.linting.tool === 'both') {
@@ -46,22 +47,58 @@ export const BIOME_LEGACY_CONFIG = 'biome.jsonc';
46
47
  * for autocomplete and validation, which is the only reason the key is there.
47
48
  */
48
49
  const BIOME_SCHEMA_URL = 'https://biomejs.dev/schemas/latest/schema.json';
50
+ /** The preset pointer this generator guarantees, and what already counts as it. */
51
+ const BIOME_PRESET = '@rtorcato/repo-tooling/biome';
52
+ /** Kept in step with doctor's matcher — the old js-tooling path still counts. */
53
+ const BIOME_PRESET_REF = /@rtorcato\/(?:js|repo)-tooling\/biome/;
49
54
  /**
50
55
  * The thin pointer config — the same shape this repo dogfoods. `fix biome` used
51
56
  * to copy the whole preset inline instead, which meant the scaffolded file
52
57
  * carried no `@rtorcato/repo-tooling/biome` reference for doctor's Biome check
53
58
  * to match, and preset improvements never reached consumers.
59
+ *
60
+ * A merge, not a rewrite (#587): only `$schema`, `extends` and a
61
+ * `linter.enabled: false` kill switch are this generator's to own, so every
62
+ * other key an existing biome.json carries — `files.includes`, a css parser
63
+ * flag, rule overrides — survives. Returns null and writes nothing when the
64
+ * existing file doesn't parse, since there is no way to merge into it and
65
+ * overwriting would destroy settings we cannot even read.
54
66
  */
55
67
  export async function generateBiomeConfig(targetDir) {
68
+ const file = path.join(targetDir, BIOME_CONFIG);
69
+ let existing = {};
70
+ if (await fs.pathExists(file)) {
71
+ const parsed = parseJsonc(await fs.readFile(file, 'utf-8'));
72
+ if (!parsed)
73
+ return null;
74
+ existing = parsed;
75
+ }
56
76
  // Biome 2.x schema + shape. The base preset (extends) already defines the
57
77
  // file globs via `files.includes`; emitting the old 1.x `include`/`ignore`
58
78
  // keys here forced consumers to run `biome migrate` before `biome check`
59
79
  // would run at all.
80
+ const { $schema: _schema, extends: prevExtends, linter, ...rest } = existing;
81
+ const prev = Array.isArray(prevExtends)
82
+ ? prevExtends
83
+ : typeof prevExtends === 'string'
84
+ ? [prevExtends]
85
+ : [];
86
+ const extendsList = prev.some((e) => typeof e === 'string' && BIOME_PRESET_REF.test(e))
87
+ ? prev
88
+ : [BIOME_PRESET, ...prev];
89
+ // The one consumer key that isn't kept: `linter.enabled: false` is precisely
90
+ // the drift doctor flags, so preserving it would leave the repo red after a
91
+ // run that reported success. Rule overrides under `linter.rules` stay.
92
+ const nextLinter = linter && typeof linter === 'object' ? { ...linter } : undefined;
93
+ if (nextLinter?.enabled === false)
94
+ delete nextLinter.enabled;
60
95
  const biomeConfig = {
61
96
  $schema: BIOME_SCHEMA_URL,
62
- extends: ['@rtorcato/repo-tooling/biome'],
97
+ extends: extendsList,
98
+ ...(nextLinter && Object.keys(nextLinter).length > 0 ? { linter: nextLinter } : {}),
99
+ ...rest,
63
100
  };
64
- await fs.writeJson(path.join(targetDir, BIOME_CONFIG), biomeConfig, { spaces: 2 });
101
+ await fs.writeJson(file, biomeConfig, { spaces: 2 });
65
102
  await fs.remove(path.join(targetDir, BIOME_LEGACY_CONFIG));
66
103
  return BIOME_CONFIG;
67
104
  }
@@ -66,13 +66,13 @@ function section(yaml, key) {
66
66
  * the release-age exemption is scope-derived, and a repo with no scope to
67
67
  * derive doesn't get that setting at all.
68
68
  */
69
- function settingsFor(glob) {
70
- return glob ? [...BASE_SETTINGS, releaseAgeSetting(glob)] : BASE_SETTINGS;
69
+ function settingsFor(glob, yaml, needsEsbuild) {
70
+ const builds = allowBuildsSetting(yaml, needsEsbuild);
71
+ return [...BASE_SETTINGS, ...(builds ? [builds] : []), ...(glob ? [releaseAgeSetting(glob)] : [])];
71
72
  }
72
73
  function releaseAgeSetting(glob) {
73
74
  return {
74
75
  label: `minimumReleaseAgeExclude: ${glob}`,
75
- applies: () => true,
76
76
  satisfied: (yaml) => (section(yaml, 'minimumReleaseAgeExclude') ?? []).some((l) => l.includes(glob)),
77
77
  key: 'minimumReleaseAgeExclude',
78
78
  block: `# Exempt this package's own scope from pnpm's minimumReleaseAge cutoff, so a
@@ -86,7 +86,6 @@ minimumReleaseAgeExclude:
86
86
  const BASE_SETTINGS = [
87
87
  {
88
88
  label: 'verifyDepsBeforeRun: false',
89
- applies: () => true,
90
89
  // Any explicit value counts — a repo that deliberately opted into
91
90
  // verification shouldn't be nagged back to the family default.
92
91
  satisfied: (yaml) => /^verifyDepsBeforeRun:/m.test(yaml),
@@ -97,23 +96,57 @@ verifyDepsBeforeRun: false
97
96
  `,
98
97
  item: '',
99
98
  },
100
- {
101
- label: 'allowBuilds: esbuild',
102
- applies: (needsEsbuild) => needsEsbuild,
103
- satisfied: (yaml) => (section(yaml, 'allowBuilds') ?? []).some((l) => /^\s*esbuild:/.test(l)),
99
+ ];
100
+ /** A YAML scalar used as a map key: quoted unless it is plainly safe bare. */
101
+ function asKey(name) {
102
+ return /^[a-z0-9][a-z0-9._-]*$/i.test(name) ? name : `'${name}'`;
103
+ }
104
+ /** Package names listed under `onlyBuiltDependencies:`, quotes stripped. */
105
+ function onlyBuiltDependencies(yaml) {
106
+ return (section(yaml, 'onlyBuiltDependencies') ?? [])
107
+ .map((line) => /^\s+-\s*['"]?([^'"\s#]+)/.exec(line)?.[1])
108
+ .filter((name) => Boolean(name));
109
+ }
110
+ /** True when `name` already carries a decision under `allowBuilds:`. */
111
+ function approved(yaml, name) {
112
+ const key = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ return (section(yaml, 'allowBuilds') ?? []).some((l) => new RegExp(`^\\s*['"]?${key}['"]?\\s*:`).test(l));
114
+ }
115
+ /**
116
+ * pnpm 11 reads build approvals from `allowBuilds` and ignores the older
117
+ * `onlyBuiltDependencies` list, so that list is *mirrored* into the map rather
118
+ * than replaced by a fixed baseline: writing only `esbuild: true` over a file
119
+ * that already approved more packages would narrow the allowlist and break the
120
+ * install on the very pnpm version the map exists for (#588). Null when there
121
+ * is nothing to approve.
122
+ */
123
+ function allowBuildsSetting(yaml, needsEsbuild) {
124
+ const packages = [
125
+ ...new Set([...(needsEsbuild ? ['esbuild'] : []), ...onlyBuiltDependencies(yaml)]),
126
+ ];
127
+ if (packages.length === 0)
128
+ return null;
129
+ const entry = (name) => ` ${asKey(name)}: true`;
130
+ return {
131
+ label: `allowBuilds: ${packages.join(', ')}`,
132
+ satisfied: (y) => packages.every((name) => approved(y, name)),
104
133
  key: 'allowBuilds',
105
134
  block: `# pnpm 11 reads build-script approvals from this map, not the older
106
135
  # onlyBuiltDependencies list, and fails the install outright without them.
107
136
  allowBuilds:
108
- esbuild: true
137
+ ${packages.map(entry).join('\n')}
109
138
  `,
110
- item: ' esbuild: true',
111
- },
112
- ];
139
+ // Only the undecided ones — a hand-vetted `false` is a decision and stays.
140
+ item: packages
141
+ .filter((name) => !approved(yaml, name))
142
+ .map(entry)
143
+ .join('\n'),
144
+ };
145
+ }
113
146
  /** Managed settings absent from `yaml`, named as doctor reports them. */
114
147
  export function missingPnpmSettings(yaml, needsEsbuild, glob) {
115
- return settingsFor(glob)
116
- .filter((s) => s.applies(needsEsbuild) && !s.satisfied(yaml))
148
+ return settingsFor(glob, yaml, needsEsbuild)
149
+ .filter((s) => !s.satisfied(yaml))
117
150
  .map((s) => s.label);
118
151
  }
119
152
  /** Insert `item` directly under an existing `key:` line, keeping the rest untouched. */
@@ -126,8 +159,8 @@ function insertUnder(yaml, key, item) {
126
159
  /** Merge every missing managed setting into `yaml` and return the new contents. */
127
160
  export function upsertPnpmSettings(yaml, needsEsbuild, glob) {
128
161
  let next = yaml;
129
- for (const setting of settingsFor(glob)) {
130
- if (!setting.applies(needsEsbuild) || setting.satisfied(next))
162
+ for (const setting of settingsFor(glob, yaml, needsEsbuild)) {
163
+ if (setting.satisfied(next))
131
164
  continue;
132
165
  if (setting.item && section(next, setting.key)) {
133
166
  next = insertUnder(next, setting.key, setting.item);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * JSONC → object, or null when it genuinely won't parse. Biome and friends
3
+ * accept comments and trailing commas, so a bare `JSON.parse` would reject
4
+ * configs the tools themselves read fine.
5
+ *
6
+ * The first alternative consumes whole string literals, so a `//` or a comment
7
+ * opener inside one (a `$schema` URL, most obviously) is never mistaken for a
8
+ * comment.
9
+ */
10
+ export function parseJsonc(text) {
11
+ const withoutComments = text.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? '');
12
+ try {
13
+ return JSON.parse(withoutComments.replace(/,(\s*[}\]])/g, '$1'));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
@@ -4,6 +4,7 @@ import { checkFile, hookHasUncommented } from '../../base/checks.js';
4
4
  import { realGitExec } from '../../base/git-identity.js';
5
5
  import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
6
6
  import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
7
+ import { parseJsonc } from '../../cli/utils/jsonc.js';
7
8
  const PACKAGE = '@rtorcato/repo-tooling';
8
9
  const NODE_MIN_MAJOR = 22;
9
10
  const NODE_LTS_REQUIREMENTS = {
@@ -44,23 +45,6 @@ export function evaluateNodeVersion(version) {
44
45
  detail: display,
45
46
  };
46
47
  }
47
- /**
48
- * JSONC → object, or null when it genuinely won't parse. `biome.jsonc` is a
49
- * declared candidate and the format allows comments and trailing commas, so
50
- * bare `JSON.parse` would reject configs Biome itself accepts.
51
- *
52
- * The first alternative consumes whole string literals, so a `//` or `/*`
53
- * inside one (a `$schema` URL, most obviously) is never mistaken for a comment.
54
- */
55
- function parseJsonc(text) {
56
- const withoutComments = text.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? '');
57
- try {
58
- return JSON.parse(withoutComments.replace(/,(\s*[}\]])/g, '$1'));
59
- }
60
- catch {
61
- return null;
62
- }
63
- }
64
48
  const BIOME_PRESET_REF = /@rtorcato\/(?:js|repo)-tooling\/biome/;
65
49
  /**
66
50
  * Both shapes of a `biome.json` this package produces (#378): the thin
@@ -1549,6 +1533,10 @@ export function rangeFloor(range) {
1549
1533
  function compareFloor(a, b) {
1550
1534
  return a[0] - b[0] || a[1] - b[1];
1551
1535
  }
1536
+ /** Full major.minor.patch ordering, for comparing an installed version. */
1537
+ function compareVersion(a, b) {
1538
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
1539
+ }
1552
1540
  /**
1553
1541
  * Config files whose `$schema` URL carries the tool version the config is
1554
1542
  * written for, and the package that reads them. One entry per tool; adding
@@ -1639,6 +1627,61 @@ export async function checkConfigSchemaVersions(dir, pkg) {
1639
1627
  hint: 'Raise the dependency floor to the version the config targets, or rewrite the config for the oldest version supported. Anyone resolving below the schema version gets a config-parse error that never mentions the version range.',
1640
1628
  };
1641
1629
  }
1630
+ /**
1631
+ * Installed peer versions against the ranges this package declares (#591).
1632
+ *
1633
+ * `checkConfigSchemaVersions` compares *declared* ranges; this compares what is
1634
+ * actually on disk. A consumer scaffolded by an older generator can sit on
1635
+ * `@biomejs/biome` 2.4.5 while the shipped preset uses `linter.rules.preset`,
1636
+ * a 2.5 key — and the only symptom is a parse error pointing inside a file the
1637
+ * consumer never wrote. The peer warning that would have said so scrolled past
1638
+ * at install time.
1639
+ *
1640
+ * ponytail: only the floor is compared. Ranges like `^20.0.0 || ^21.0.0` or
1641
+ * `>=5.0.0` have no single ceiling worth enforcing, and flagging a newer major
1642
+ * would fire on every repo that upgrades ahead of the range. Add a real semver
1643
+ * satisfies() if a peer ever ships a breaking major we must keep out.
1644
+ */
1645
+ export async function checkPeerVersions(dir, pkg) {
1646
+ const check = 'Peer versions';
1647
+ const modules = path.join(dir, 'node_modules');
1648
+ // Running against a consumer, the contract lives in the installed copy;
1649
+ // running against this repo itself, it is the repo's own package.json.
1650
+ const self = pkg?.name === PACKAGE ? pkg : await readPackageJson(path.join(modules, PACKAGE));
1651
+ const peers = self?.peerDependencies ?? {};
1652
+ const stale = [];
1653
+ let checked = 0;
1654
+ for (const [name, range] of Object.entries(peers)) {
1655
+ const floor = rangeFloor(range);
1656
+ if (!floor)
1657
+ continue;
1658
+ // Absent means the peer is simply unused — every one of ours is optional.
1659
+ const installed = (await readPackageJson(path.join(modules, name)))?.version;
1660
+ const version = typeof installed === 'string' ? rangeFloor(installed) : null;
1661
+ if (!version)
1662
+ continue;
1663
+ checked++;
1664
+ if (compareVersion(version, floor) < 0) {
1665
+ stale.push(`${name} ${installed} installed, ${PACKAGE} requires ${range}`);
1666
+ }
1667
+ }
1668
+ if (checked === 0) {
1669
+ return { check, status: 'ok', detail: 'no installed peers to compare' };
1670
+ }
1671
+ if (stale.length === 0) {
1672
+ return {
1673
+ check,
1674
+ status: 'ok',
1675
+ detail: `${checked} installed peer${checked === 1 ? '' : 's'} satisfy the declared ranges`,
1676
+ };
1677
+ }
1678
+ return {
1679
+ check,
1680
+ status: 'drift',
1681
+ detail: `${stale.length} peer${stale.length === 1 ? '' : 's'} installed below the declared range: ${stale.join('; ')}`,
1682
+ hint: 'Upgrade the named packages — a preset written for a newer version fails inside a file you cannot edit, with an error that never mentions the version.',
1683
+ };
1684
+ }
1642
1685
  /** npm git specifiers, including the bare `owner/repo` GitHub shorthand. */
1643
1686
  const GIT_PROTOCOL = /^(?:github|gitlab|bitbucket|gist):|^git\+|^git:\/\//;
1644
1687
  const GITHUB_SHORTHAND = /^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*(?:#.*)?$/;
@@ -76,6 +76,9 @@ import { generateTypedocConfig, generateTypedocWorkflow } from '../../cli/genera
76
76
  import { copyPreset } from '../../cli/utils/copy-preset.js';
77
77
  import { identifiablePresetHashes } from '../../cli/utils/copied-assets.js';
78
78
  import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
79
+ // The fixer contract moved to src/base/fixers.ts when Swift became the second
80
+ // module (#286) — import it from there.
81
+ import { FixerAbort } from '../../base/fixers.js';
79
82
  /** Exported so doctor can render the preset ci.yml it compares against (#349). */
80
83
  export function inferProjectConfig(pkg) {
81
84
  const deps = {
@@ -173,14 +176,21 @@ const GH_WORKFLOW_FIXERS = GH_WORKFLOWS.map((name) => ({
173
176
  export const FIXERS = [
174
177
  {
175
178
  target: 'biome',
176
- description: `Scaffold ${BIOME_CONFIG} extending the @rtorcato/repo-tooling preset, plus the scripts that run it`,
179
+ description: `Point ${BIOME_CONFIG} at the @rtorcato/repo-tooling preset (merged into any existing config), plus the scripts that run it`,
177
180
  appliesTo: ['Biome'],
178
181
  outputs: [BIOME_CONFIG, 'package.json (scripts)'],
182
+ // safe-merge: only $schema/extends are rewritten, other keys are kept.
183
+ riskLevel: 'safe-merge',
179
184
  canFixDrift: true,
180
185
  async run({ targetDir }) {
181
186
  // Shares generateBiomeConfig with the setup/resync path — the two used to
182
187
  // scaffold different filenames with different contents (#365).
183
188
  const written = await generateBiomeConfig(targetDir);
189
+ // null means an existing config that won't parse. Refuse rather than
190
+ // replace it: its settings are unreadable, so they're unrecoverable (#587).
191
+ if (!written) {
192
+ throw new FixerAbort('biome-unparseable', `refusing to overwrite ${BIOME_CONFIG} — it does not parse as JSON/JSONC, so its settings cannot be merged`, `fix the syntax error in ${BIOME_CONFIG} (or delete it) and re-run \`fix biome\``);
193
+ }
184
194
  const filesWritten = [written];
185
195
  // A config with no way to run it left `pnpm check` undefined, which the
186
196
  // generated CI called and `fix verify` needed to compose a chain (#364).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.35.0",
3
+ "version": "3.37.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -158,6 +158,7 @@
158
158
  "./typescript/node": "./tooling/typescript/tsconfig.node.json",
159
159
  "./typescript/bun": "./tooling/typescript/tsconfig.bun.json",
160
160
  "./typescript/react": "./tooling/typescript/tsconfig.react.json",
161
+ "./typescript/vite-app": "./tooling/typescript/tsconfig.vite-app.json",
161
162
  "./typescript/test": "./tooling/typescript/tsconfig.test.json",
162
163
  "./typescript/reset": "./tooling/typescript/reset.d.ts",
163
164
  "./typescript/base@1": "./tooling/typescript/v1/tsconfig.base.json",
@@ -264,7 +265,7 @@
264
265
  "cz-conventional-changelog": "^3.3.0",
265
266
  "esbuild": "^0.28.2",
266
267
  "esbuild-node-externals": "^2.0.0",
267
- "eslint": "10.8.1",
268
+ "eslint": "10.9.1",
268
269
  "eslint-plugin-import": "^2.32.0",
269
270
  "eslint-plugin-jest": "29.16.1",
270
271
  "husky": "^9.1.7",
@@ -232,6 +232,23 @@ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUs
232
232
  AGENT_USER=""; }; }
233
233
  ```
234
234
 
235
+ **Then prove `gh` is *authenticating as* that account — this one halts the
236
+ tick.** Assignability passes no matter who is calling, so on a machine where the
237
+ agent identity was never configured both checks above are green while `gh` is
238
+ the owner: worktrees, commits, PRs and reviews all land under the owner's
239
+ account, and the split only shows up in `git log` afterwards (#601).
240
+
241
+ ```bash
242
+ # Exit 0 continue, non-zero halt — also covers the bare-checkout repair. The
243
+ # identity check is skipped entirely when no agentUser is declared.
244
+ npx @rtorcato/repo-tooling loop guard --root "$ROOT" || exit 1
245
+ ```
246
+
247
+ Configured intent that is not met is a misconfiguration, not a degraded mode —
248
+ which is why this halts where the assignability check merely warns. Fix it by
249
+ pointing `gh` at the agent account on this machine, or by removing
250
+ `rules.aiLoop.agentUser`.
251
+
235
252
  **It lives in the repo, not a shell profile.** The agent account is a
236
253
  collaborator on *this* repo, so a machine-wide env var is both the wrong
237
254
  granularity and invisible — forgotten on a new laptop, with the only symptom
@@ -1483,13 +1500,31 @@ do the linking here:
1483
1500
 
1484
1501
  ```bash
1485
1502
  DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
1486
- for d in $DIRS; do
1503
+ printf '%s\n' "$DIRS" | while IFS= read -r d; do
1504
+ [ -n "$d" ] || continue
1487
1505
  [ -d "$ROOT/$d" ] || continue # an entry pointing at nothing links nothing
1488
1506
  mkdir -p "$(dirname "$WT_ROOT/$SLUG/$d")"
1489
1507
  ln -s "$ROOT/$d" "$WT_ROOT/$SLUG/$d"
1490
1508
  done
1509
+
1510
+ # assert it happened — an unlinked worktree must never reach an implementer
1511
+ MISSING=$(printf '%s\n' "$DIRS" | while IFS= read -r d; do
1512
+ [ -n "$d" ] && [ -d "$ROOT/$d" ] && [ ! -L "$WT_ROOT/$SLUG/$d" ] && printf '%s ' "$d"
1513
+ done)
1514
+ [ -z "$MISSING" ] || echo "FATAL: $SLUG has no symlink for: $MISSING"
1491
1515
  ```
1492
1516
 
1517
+ **Iterate line by line — never `for d in $DIRS`.** Your shell may be zsh, which
1518
+ does not word-split an unquoted expansion: `$DIRS` arrives as *one* word with
1519
+ embedded newlines, `[ -d ]` fails against that nonsense path, and the loop links
1520
+ **nothing** (#585). Same class as the Pass 2 glob hazard below, and just as
1521
+ silent — the `pnpm install` fallback is gated on `$DIRS` being *empty*, which it
1522
+ is not, so the worktree gets neither links nor an install, and the implementer
1523
+ meets `Cannot find module` on its first test run, reading as the issue's fault
1524
+ rather than the harness's. That is what the `MISSING` assertion is for: if it
1525
+ prints, do **not** spawn an implementer — run `pnpm install` in the worktree, or
1526
+ return the issue to `ai-ready`, drop `ai-wip`, and move on.
1527
+
1493
1528
  **Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
1494
1529
  **Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
1495
1530
  forbids outright (see below) and replaces with a raw `git worktree add`. So the
@@ -54,6 +54,12 @@
54
54
  "semicolons": "asNeeded"
55
55
  }
56
56
  },
57
+ "css": {
58
+ "parser": {
59
+ "tailwindDirectives": true,
60
+ "cssModules": true
61
+ }
62
+ },
57
63
  "json": {
58
64
  "formatter": {
59
65
  "indentStyle": "space",
@@ -63,7 +63,7 @@ npx @rtorcato/repo-tooling setup --config project.json -d ./my-lib --skip-instal
63
63
 
64
64
  ## Drift policy (don't surprise the user)
65
65
 
66
- - Safe-merge fixers (`engines`, `husky`, `package-json`) never overwrite — they add/merge.
66
+ - Safe-merge fixers (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge.
67
67
  - Drift on a config file (`biome`, `tsconfig`, …) is only overwritten with `--yes`.
68
68
  Before overwriting drift the user wrote by hand, show `fix <target> --diff` first.
69
69
  - `optional-missing` ≠ broken. Don't install opt-in tools (typedoc, size-limit,
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "./tsconfig.react.json",
3
+ "compilerOptions": {
4
+ // `vite/client` is what makes `import.meta.env` and the `?url` / `?raw`
5
+ // import suffixes typecheck. It lives here rather than in the react preset
6
+ // because every `types` entry must resolve: adding it there would fail with
7
+ // TS2688 on any React project that doesn't install Vite — the same trap that
8
+ // got "tailwindcss" dropped from the react preset.
9
+ "types": ["react", "react-dom", "vitest", "vite/client"]
10
+ },
11
+ // An app's `typecheck` is a CI gate, not a build, so the tests are checked
12
+ // too — they're the code most likely to drift after a signature change. The
13
+ // react preset excludes them because a library's tsc run emits dist/.
14
+ "include": ["src", "index.d.ts", "types", "tests"],
15
+ "exclude": ["node_modules", "dist", "build", "out"]
16
+ }