@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 +2 -2
- package/dist/cli/commands/doctor.js +2 -1
- package/dist/cli/commands/setup-presets.js +5 -2
- package/dist/cli/commands/setup.js +7 -1
- package/dist/cli/generators/linting.js +39 -2
- package/dist/cli/generators/pnpm-workspace.js +49 -16
- package/dist/cli/utils/jsonc.js +18 -0
- package/dist/languages/js/checks.js +60 -17
- package/dist/languages/js/fixers.js +11 -1
- package/package.json +3 -2
- package/skills/ai-issue-loop/SKILL.md +19 -1
- package/tooling/biome/preset.json +6 -0
- package/tooling/claude/repo-tooling.md +1 -1
- package/tooling/typescript/tsconfig.vite-app.json +16 -0
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
|
-
|
|
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 [
|
|
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:
|
|
97
|
+
extends: extendsList,
|
|
98
|
+
...(nextLinter && Object.keys(nextLinter).length > 0 ? { linter: nextLinter } : {}),
|
|
99
|
+
...rest,
|
|
63
100
|
};
|
|
64
|
-
await fs.writeJson(
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
137
|
+
${packages.map(entry).join('\n')}
|
|
109
138
|
`,
|
|
110
|
-
|
|
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) =>
|
|
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 (
|
|
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: `
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
+
}
|