@rtorcato/repo-tooling 3.34.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/claude-skills.js +10 -2
- 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/skills/dogfood/SKILL.md +209 -0
- 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 [
|
|
@@ -14,9 +14,17 @@ import { shellQuote } from '../utils/shell.js';
|
|
|
14
14
|
import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
|
|
15
15
|
/**
|
|
16
16
|
* Skills this package owns the content of and keeps up to date. The loop first —
|
|
17
|
-
* it is the pipeline; the
|
|
17
|
+
* it is the pipeline; the next three are its drivers (burst, on-ramp, status).
|
|
18
|
+
* `dogfood` stands apart: it tests the consuming repo's own tooling rather than
|
|
19
|
+
* driving the loop, and it is the only one that writes nothing outside a temp dir.
|
|
18
20
|
*/
|
|
19
|
-
export const SHIPPED_SKILLS = [
|
|
21
|
+
export const SHIPPED_SKILLS = [
|
|
22
|
+
'ai-issue-loop',
|
|
23
|
+
'ai-workflow',
|
|
24
|
+
'ai-issue',
|
|
25
|
+
'ai-loop-status',
|
|
26
|
+
'dogfood',
|
|
27
|
+
];
|
|
20
28
|
/** The primary skill — the default everywhere a single name is accepted. */
|
|
21
29
|
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
22
30
|
/**
|
|
@@ -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
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dogfood
|
|
3
|
+
description: |
|
|
4
|
+
Run this repo's own tooling against throwaway greenfield fixtures in a temp
|
|
5
|
+
directory and report what breaks. Use when the user asks to "dogfood",
|
|
6
|
+
"test the tool on a fresh repo", "see what happens on a new project", or
|
|
7
|
+
invokes `/dogfood`. Asks what to exercise before it starts. Writes only
|
|
8
|
+
under a temp directory, never touches the repo's working tree, and never
|
|
9
|
+
deletes anything — it hands the path back for the user to remove.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# dogfood
|
|
13
|
+
|
|
14
|
+
Point the repo's own tooling at repos it has never seen and find out what it
|
|
15
|
+
does wrong. Arguments: $ARGUMENTS
|
|
16
|
+
|
|
17
|
+
**The bugs live where the tool meets content it did not write.** An empty
|
|
18
|
+
directory finds nothing — every finding from the run this skill is based on came
|
|
19
|
+
from a fixture that already had a `package.json`, a manifest, or a source file
|
|
20
|
+
with an opinion in it. Scaffolding onto nothing is the one case the authors
|
|
21
|
+
already tested.
|
|
22
|
+
|
|
23
|
+
## What this never does
|
|
24
|
+
|
|
25
|
+
- **Never writes outside its temp directory.** Not the repo's working tree, not
|
|
26
|
+
`~/.claude`, not a global git config (`git config --global` writes a stowed
|
|
27
|
+
dotfile on this machine).
|
|
28
|
+
- **Never deletes.** `rm` is often permission-blocked for an agent, and a
|
|
29
|
+
half-deleted fixture is worse than a kept one. Report the path and size at the
|
|
30
|
+
end; the user removes it when they are done reading it.
|
|
31
|
+
- **Never files an issue without asking**, and never labels one `ai-ready` —
|
|
32
|
+
that label is the human's gate into `ai-issue-loop`.
|
|
33
|
+
|
|
34
|
+
## Step 1 — ask what to exercise
|
|
35
|
+
|
|
36
|
+
Use `AskUserQuestion`. Look at the repo first so the options are real — read its
|
|
37
|
+
`package.json` `bin`, its CLI's `--help`, or its presets/templates directory —
|
|
38
|
+
then ask:
|
|
39
|
+
|
|
40
|
+
1. **What to exercise.** Offer the actual entry points found (multiSelect).
|
|
41
|
+
For a scaffolding tool that is its presets; for a linter its rule sets; for a
|
|
42
|
+
codemod its transforms.
|
|
43
|
+
2. **Fixture shape.** *Realistic pre-existing repos* (recommended — this is what
|
|
44
|
+
finds bugs) vs *empty directories* (only worth it to check the happy path
|
|
45
|
+
still works).
|
|
46
|
+
3. **What to do with findings.** *Report in the transcript only* (recommended
|
|
47
|
+
for a first run) vs *also file GitHub issues*. If they choose issues, every
|
|
48
|
+
one opens with `🤖 *Filed by an agent via dogfood.*` and carries no
|
|
49
|
+
`ai-ready` label.
|
|
50
|
+
|
|
51
|
+
Skip a question the arguments already answer.
|
|
52
|
+
|
|
53
|
+
## Step 2 — pin the build and the version
|
|
54
|
+
|
|
55
|
+
**A finding with no version stamp is unreproducible and will be argued with.**
|
|
56
|
+
Build from source, and record the commit — not the version in `package.json`,
|
|
57
|
+
which under semantic-release without `@semantic-release/git` never moves:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ROOT=$(git rev-parse --show-toplevel)
|
|
61
|
+
cd "$ROOT" && pnpm build-cli # or whatever CLAUDE.md says the build is
|
|
62
|
+
REF=$(git -C "$ROOT" rev-parse --short HEAD)
|
|
63
|
+
DIRTY=$(git -C "$ROOT" status --porcelain | head -1)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Say in every finding: *Reproduced with `dist/` built from `<REF>`*, and mention
|
|
67
|
+
it if the tree was dirty. Invoke the built artefact directly
|
|
68
|
+
(`node "$ROOT/dist/cli/index.js" …`) — never `npx <package>`, which silently
|
|
69
|
+
tests the *published* version instead of the working tree.
|
|
70
|
+
|
|
71
|
+
## Step 3 — make the temp root, once
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
BASE="${TMPDIR:-/tmp}/dogfood-$REF-$$"
|
|
75
|
+
mkdir -p "$BASE"
|
|
76
|
+
echo "$BASE"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Pin `$BASE` as an absolute path and reuse that literal for the rest of the
|
|
80
|
+
run.** `$TMPDIR` resolves differently inside and outside the command sandbox, so
|
|
81
|
+
re-expanding it in a later command lands in a different directory and the run
|
|
82
|
+
silently splits in two. A unique suffix means a re-run never collides, which is
|
|
83
|
+
what makes never-deleting safe.
|
|
84
|
+
|
|
85
|
+
## Step 4 — build each fixture, and commit it
|
|
86
|
+
|
|
87
|
+
A fixture is a *plausible* repo, not a stub. Give it the things the tool will
|
|
88
|
+
read and be tempted to rewrite: a real name (scoped and unscoped both matter —
|
|
89
|
+
a scope is a code path), a source file, a manifest that already declares
|
|
90
|
+
something, an existing script.
|
|
91
|
+
|
|
92
|
+
Then **`git init` and commit it**. That baseline commit is the whole trick:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git -C "$BASE/$NAME" init -q
|
|
96
|
+
git -C "$BASE/$NAME" add -A
|
|
97
|
+
git -C "$BASE/$NAME" -c user.email=dogfood@local -c user.name=dogfood commit -qm baseline
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`-c` on the command, never `git config --global`. Without the commit, "what did
|
|
101
|
+
the tool overwrite" is a question nobody can answer afterwards.
|
|
102
|
+
|
|
103
|
+
## Step 5 — before, run, after
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node "$ROOT/dist/cli/index.js" doctor --json > "$BASE/$NAME.before.json" 2>&1 || true
|
|
107
|
+
node "$ROOT/dist/cli/index.js" setup --preset <p> --yes > "$BASE/$NAME.setup.log" 2>&1; echo "exit=$?"
|
|
108
|
+
node "$ROOT/dist/cli/index.js" doctor --json > "$BASE/$NAME.after.json" 2>&1 || true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`> file 2>&1`, in that order — `2>&1 > file` leaves stderr on the terminal, which
|
|
112
|
+
is where the interesting output usually is. `|| true` because a diagnostic
|
|
113
|
+
command exiting non-zero *is* data, not a reason to abort the run.
|
|
114
|
+
|
|
115
|
+
## Step 6 — the three checks that actually find things
|
|
116
|
+
|
|
117
|
+
Run all three on every fixture. Each one found a distinct real bug.
|
|
118
|
+
|
|
119
|
+
### a. What did it overwrite?
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
git -C "$BASE/$NAME" diff --stat
|
|
123
|
+
git -C "$BASE/$NAME" diff -- <every file the tool claims to merge rather than replace>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A tool that says it preserves your `name`, `version`, and scripts is making a
|
|
127
|
+
promise; the diff is where you check it. This is how a preset was caught
|
|
128
|
+
renaming a package after its directory and orphaning the sources — **and the
|
|
129
|
+
build still exited 0**, because the build system ignored the now-undeclared
|
|
130
|
+
directory. A green build is not evidence.
|
|
131
|
+
|
|
132
|
+
### b. Does it pass its own check?
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node "$ROOT/dist/cli/index.js" doctor; echo "exit=$?"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Non-zero on a repo the tool itself just created is a bug every time, however
|
|
139
|
+
small the detail. "Run setup, you're aligned" either holds or the promise is
|
|
140
|
+
worthless — and a user's CI or pre-commit hook keys on exactly that exit code.
|
|
141
|
+
Diff `before.json` against `after.json` too: a check that *stopped* running is
|
|
142
|
+
invisible in the exit code.
|
|
143
|
+
|
|
144
|
+
### c. Does the contract it wrote actually hold?
|
|
145
|
+
|
|
146
|
+
The subtlest class, and the most damaging. The tool writes a *declaration* —
|
|
147
|
+
`exports`, `main`, entry points, a target list — and separately preserves a
|
|
148
|
+
*producer* — the build script. Nothing checks that the producer emits what the
|
|
149
|
+
declaration promises. So:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
cd "$BASE/$NAME" && <the repo's own build> && ls -R dist 2>/dev/null
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
then read the manifest and confirm every path it names exists on disk. A package
|
|
156
|
+
whose `main` points at a file its own `build` cannot emit publishes green and
|
|
157
|
+
breaks every consumer.
|
|
158
|
+
|
|
159
|
+
## Step 7 — report
|
|
160
|
+
|
|
161
|
+
Per fixture, one short block: preset, exit codes, and each finding as
|
|
162
|
+
*what happened → why it matters → the evidence*. Paste the diff hunk or the JSON
|
|
163
|
+
line; a paraphrase is not a reproduction.
|
|
164
|
+
|
|
165
|
+
Rank by **silence, not severity**. A loud failure gets noticed by whoever hits
|
|
166
|
+
it. A wrong result that exits 0 does not, and that is the finding worth the
|
|
167
|
+
user's attention — lead with it.
|
|
168
|
+
|
|
169
|
+
If the user chose to file issues, one issue per finding, ≤30 lines each: what,
|
|
170
|
+
the minimal reproduction, the version stamp, and a `## What to change` section
|
|
171
|
+
that names the options rather than picking one. Attach no `ai-ready`.
|
|
172
|
+
|
|
173
|
+
**Say what you could not test.** A preset you skipped, a build you could not
|
|
174
|
+
run, an area still in flight — an unexamined corner reported as unexamined is
|
|
175
|
+
useful; one left silent reads as covered.
|
|
176
|
+
|
|
177
|
+
## Step 8 — hand back the temp directory
|
|
178
|
+
|
|
179
|
+
Last line of the run, always:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
du -sh "$BASE"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Tell the user the path and the size, and give them the exact command:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
! rm -rf <BASE>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The `!` prefix runs it in their session. Do not run it yourself, do not offer to
|
|
192
|
+
run it later, and do not delete it on a subsequent invocation — the fixtures are
|
|
193
|
+
the evidence behind every finding, and they are worth more than the disk.
|
|
194
|
+
|
|
195
|
+
## Gotchas that cost real time
|
|
196
|
+
|
|
197
|
+
- **Shell aliases corrupt captured output.** `ls` aliased to a colouriser emits
|
|
198
|
+
escape codes into anything you parse. `unalias ls` / `unalias g` first, or use
|
|
199
|
+
`command ls`.
|
|
200
|
+
- **Ambient `GIT_DIR` / `GIT_WORK_TREE` / `GIT_CONFIG*` outrank `-C`.** If any is
|
|
201
|
+
exported, every fixture git command silently operates on the wrong repo.
|
|
202
|
+
`env -u GIT_DIR -u GIT_WORK_TREE git …` when in doubt.
|
|
203
|
+
- **A function, not a variable, for a wrapped command.** `G="env -u X git"` does
|
|
204
|
+
not word-split under zsh; define `g() { env -u X git "$@"; }`.
|
|
205
|
+
- **`pnpm install` may need the sandbox disabled** — it writes to a store outside
|
|
206
|
+
the working directory. That is a legitimate escalation for this one command.
|
|
207
|
+
- **Never reuse a fixture between runs.** A second `setup` over an
|
|
208
|
+
already-set-up repo tests idempotency, which is a different question; mixing
|
|
209
|
+
the two makes both answers unreliable.
|
|
@@ -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
|
+
}
|