@rtorcato/repo-tooling 3.35.0 → 3.36.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
@@ -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
 
@@ -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));
@@ -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.36.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",
@@ -1483,13 +1483,31 @@ do the linking here:
1483
1483
 
1484
1484
  ```bash
1485
1485
  DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
1486
- for d in $DIRS; do
1486
+ printf '%s\n' "$DIRS" | while IFS= read -r d; do
1487
+ [ -n "$d" ] || continue
1487
1488
  [ -d "$ROOT/$d" ] || continue # an entry pointing at nothing links nothing
1488
1489
  mkdir -p "$(dirname "$WT_ROOT/$SLUG/$d")"
1489
1490
  ln -s "$ROOT/$d" "$WT_ROOT/$SLUG/$d"
1490
1491
  done
1492
+
1493
+ # assert it happened — an unlinked worktree must never reach an implementer
1494
+ MISSING=$(printf '%s\n' "$DIRS" | while IFS= read -r d; do
1495
+ [ -n "$d" ] && [ -d "$ROOT/$d" ] && [ ! -L "$WT_ROOT/$SLUG/$d" ] && printf '%s ' "$d"
1496
+ done)
1497
+ [ -z "$MISSING" ] || echo "FATAL: $SLUG has no symlink for: $MISSING"
1491
1498
  ```
1492
1499
 
1500
+ **Iterate line by line — never `for d in $DIRS`.** Your shell may be zsh, which
1501
+ does not word-split an unquoted expansion: `$DIRS` arrives as *one* word with
1502
+ embedded newlines, `[ -d ]` fails against that nonsense path, and the loop links
1503
+ **nothing** (#585). Same class as the Pass 2 glob hazard below, and just as
1504
+ silent — the `pnpm install` fallback is gated on `$DIRS` being *empty*, which it
1505
+ is not, so the worktree gets neither links nor an install, and the implementer
1506
+ meets `Cannot find module` on its first test run, reading as the issue's fault
1507
+ rather than the harness's. That is what the `MISSING` assertion is for: if it
1508
+ prints, do **not** spawn an implementer — run `pnpm install` in the worktree, or
1509
+ return the issue to `ai-ready`, drop `ai-wip`, and move on.
1510
+
1493
1511
  **Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
1494
1512
  **Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
1495
1513
  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
+ }