@rtorcato/repo-tooling 3.35.0 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/README.md +5 -4
- package/dist/cli/commands/doctor.js +2 -1
- package/dist/cli/commands/loop-guard.js +47 -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 +36 -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
|
@@ -26,7 +26,7 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
26
26
|
| `fix --dry-run` | ✅ | ✅ | Print what each fixer would write without writing. Combine with `--json`. |
|
|
27
27
|
| `list --json` | ✅ | ✅ | Enumerate the library's surface area. Each entry has `{ name, description, exports, fixTarget }`. |
|
|
28
28
|
| `copy <name>` | ✅ | text only | Copy a single preset (`biome`, `tsconfig`) into the current directory. |
|
|
29
|
-
| `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`). Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout — both non-zero halt the tick. |
|
|
29
|
+
| `loop guard --root <path>` | ✅ | ✅ | Guard an `ai-issue-loop` tick: repair a wrongly-bare main checkout, gate the `node_modules` rebuild (`--removed`), and assert `gh` authenticates as the declared `rules.aiLoop.agentUser`. Exit `0` continue, `1` repair failed, `2` root is not a repairable checkout or the agent identity is wrong — both non-zero halt the tick. |
|
|
30
30
|
|
|
31
31
|
## Recommended workflows
|
|
32
32
|
|
|
@@ -94,11 +94,11 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
|
|
|
94
94
|
|
|
95
95
|
## Drift policy (important)
|
|
96
96
|
|
|
97
|
-
`fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
|
|
97
|
+
`fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
|
|
98
98
|
|
|
99
99
|
Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
|
|
100
100
|
|
|
101
|
-
A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
|
|
101
|
+
A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422), and `biome` against a `biome.json` that does not parse, whose settings cannot be merged (#587). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
|
|
102
102
|
|
|
103
103
|
## Source-of-truth files in the repo
|
|
104
104
|
|
package/README.md
CHANGED
|
@@ -67,9 +67,10 @@ Tailwind v4, exclude your stylesheet in `biome.json`:
|
|
|
67
67
|
```jsonc
|
|
68
68
|
{
|
|
69
69
|
"extends": ["@rtorcato/repo-tooling/biome"],
|
|
70
|
-
//
|
|
71
|
-
// the
|
|
72
|
-
|
|
70
|
+
// List only the extra negations — Biome merges them into the preset's
|
|
71
|
+
// `includes`. Do NOT repeat the leading `"**"`: that is a lint error,
|
|
72
|
+
// `lint/suspicious/noBiomeFirstException`.
|
|
73
|
+
"files": { "includes": ["!**/*.css"] }
|
|
73
74
|
}
|
|
74
75
|
```
|
|
75
76
|
|
|
@@ -91,7 +92,7 @@ See the [Getting Started guide](https://rtorcato.github.io/repo-tooling/guides/g
|
|
|
91
92
|
| `copy <config>` | Copy a single config file into the current project. | `npx @rtorcato/repo-tooling copy biome` |
|
|
92
93
|
| `doctor` | Diagnose an existing project for missing or drifted tooling. | `npx @rtorcato/repo-tooling doctor` |
|
|
93
94
|
| `fix [target]` | Apply scaffolders for what `doctor` flagged (`--yes`, `--dry-run`, `--diff`). | `npx @rtorcato/repo-tooling fix` |
|
|
94
|
-
| `loop guard` | Repair a main checkout that has gone `core.bare = true`,
|
|
95
|
+
| `loop guard` | Repair a main checkout that has gone `core.bare = true`, gate the `node_modules` rebuild after a worktree removal, and halt when `gh` is not authenticated as the `rules.aiLoop.agentUser` the repo declares. Exits `1` if the repair failed and `2` if the root is not a repairable checkout or the identity is wrong — see `--help`. | `npx @rtorcato/repo-tooling loop guard --root .` |
|
|
95
96
|
|
|
96
97
|
Prefer to run the audit in CI? `doctor` also ships as a GitHub Action:
|
|
97
98
|
|
|
@@ -22,7 +22,7 @@ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
|
22
22
|
import { compareRulesWithReference } from '../utils/reference-rules.js';
|
|
23
23
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
24
24
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
25
|
-
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
25
|
+
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPeerVersions, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
26
26
|
export { evaluateNodeVersion };
|
|
27
27
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
28
28
|
// Detects the broken-release-on-protected-main footgun: a workflow that runs
|
|
@@ -366,6 +366,7 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
366
366
|
results.push(await checkPackageManager(targetDir, pkg));
|
|
367
367
|
results.push(await checkConfigSchemaVersions(targetDir, pkg));
|
|
368
368
|
results.push(checkGitDependencies(pkg));
|
|
369
|
+
results.push(await checkPeerVersions(targetDir, pkg));
|
|
369
370
|
results.push(await checkVscodeExtensions(targetDir));
|
|
370
371
|
results.push(await checkNodeVersionPin(targetDir));
|
|
371
372
|
results.push(await checkNodeVersionConsistency(targetDir, pkg));
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import fs from 'fs-extra';
|
|
5
5
|
import { realGitExec } from '../../base/git-identity.js';
|
|
6
|
+
import { realGhExec } from '../../base/github-settings.js';
|
|
6
7
|
/**
|
|
7
8
|
* The invariant table from the skill, verified on git 2.55.0:
|
|
8
9
|
*
|
|
@@ -34,6 +35,45 @@ export function classifyRoot(insideWorkTree, gitEntry) {
|
|
|
34
35
|
return 'linked-worktree';
|
|
35
36
|
return 'genuinely-bare';
|
|
36
37
|
}
|
|
38
|
+
const LOCKFILE = '.repo-tooling.json';
|
|
39
|
+
/**
|
|
40
|
+
* `rules.aiLoop.agentUser`, with the flat pre-v4 fallback — the same pair the
|
|
41
|
+
* skill's `jq` reads. Read raw rather than through `readLockfile`, whose parser
|
|
42
|
+
* requires a `record.config`: a hand-written rules-only lockfile is exactly the
|
|
43
|
+
* file this has to see.
|
|
44
|
+
*/
|
|
45
|
+
export async function configuredAgentUser(root) {
|
|
46
|
+
const raw = await fs.readJson(path.join(root, LOCKFILE)).catch(() => null);
|
|
47
|
+
const user = raw?.rules?.aiLoop?.agentUser ?? raw?.aiLoop?.agentUser;
|
|
48
|
+
return typeof user === 'string' && user.trim() !== '' ? user.trim() : undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Declared intent that is not met is a misconfiguration, not a degraded mode —
|
|
52
|
+
* so this halts, unlike the assignability check in `base/agent-user.ts`, which
|
|
53
|
+
* warns and carries on. That check can never catch this: `agentUser` is
|
|
54
|
+
* assignable regardless of who is calling.
|
|
55
|
+
*/
|
|
56
|
+
export async function checkAgentIdentity(configured, gh) {
|
|
57
|
+
if (!configured) {
|
|
58
|
+
return {
|
|
59
|
+
verdict: 'not-configured',
|
|
60
|
+
message: `no aiLoop.agentUser in ${LOCKFILE} — identity check skipped`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const r = await gh(['api', 'user', '--jq', '.login']);
|
|
64
|
+
const effective = r.ok ? r.stdout.trim() : '';
|
|
65
|
+
// GitHub logins are case-insensitive, so a case difference is one account.
|
|
66
|
+
if (effective !== '' && effective.toLowerCase() === configured.toLowerCase()) {
|
|
67
|
+
return {
|
|
68
|
+
verdict: 'match',
|
|
69
|
+
message: `gh is authenticated as ${effective} — the configured agent account`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
verdict: 'mismatch',
|
|
74
|
+
message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
37
77
|
/**
|
|
38
78
|
* `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
|
|
39
79
|
* `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
|
|
@@ -99,6 +139,7 @@ export async function runLoopGuard(options = {}) {
|
|
|
99
139
|
? path.resolve(options.worktreeRoot)
|
|
100
140
|
: defaultWorktreeRoot(root);
|
|
101
141
|
const git = options.git ?? ((args) => realGitExec(args, root));
|
|
142
|
+
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
102
143
|
const install = options.install ?? realInstall;
|
|
103
144
|
const messages = [];
|
|
104
145
|
const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
|
|
@@ -127,6 +168,11 @@ export async function runLoopGuard(options = {}) {
|
|
|
127
168
|
else {
|
|
128
169
|
messages.push('main checkout is a work tree');
|
|
129
170
|
}
|
|
171
|
+
const { verdict: identity, message: identityMessage } = await checkAgentIdentity(await configuredAgentUser(root), gh);
|
|
172
|
+
messages.push(identityMessage);
|
|
173
|
+
// A failed repair (1) is the more specific verdict, so it keeps the code.
|
|
174
|
+
if (identity === 'mismatch' && exitCode === 0)
|
|
175
|
+
exitCode = 2;
|
|
130
176
|
const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
131
177
|
const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
|
|
132
178
|
let outcome = rebuild;
|
|
@@ -146,7 +192,7 @@ export async function runLoopGuard(options = {}) {
|
|
|
146
192
|
messages.push('node_modules rebuilt');
|
|
147
193
|
}
|
|
148
194
|
}
|
|
149
|
-
return { root, worktreeRoot, state, bare, rebuild: outcome, live, exitCode, messages };
|
|
195
|
+
return { root, worktreeRoot, state, bare, identity, rebuild: outcome, live, exitCode, messages };
|
|
150
196
|
}
|
|
151
197
|
/**
|
|
152
198
|
* The three load-bearing conditions, kept separate from the run so a test can
|
|
@@ -92,7 +92,10 @@ export function buildPresetConfig(name, projectName) {
|
|
|
92
92
|
...BASE,
|
|
93
93
|
projectName,
|
|
94
94
|
projectType: 'react-app',
|
|
95
|
-
|
|
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.37.0",
|
|
4
4
|
"description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -158,6 +158,7 @@
|
|
|
158
158
|
"./typescript/node": "./tooling/typescript/tsconfig.node.json",
|
|
159
159
|
"./typescript/bun": "./tooling/typescript/tsconfig.bun.json",
|
|
160
160
|
"./typescript/react": "./tooling/typescript/tsconfig.react.json",
|
|
161
|
+
"./typescript/vite-app": "./tooling/typescript/tsconfig.vite-app.json",
|
|
161
162
|
"./typescript/test": "./tooling/typescript/tsconfig.test.json",
|
|
162
163
|
"./typescript/reset": "./tooling/typescript/reset.d.ts",
|
|
163
164
|
"./typescript/base@1": "./tooling/typescript/v1/tsconfig.base.json",
|
|
@@ -264,7 +265,7 @@
|
|
|
264
265
|
"cz-conventional-changelog": "^3.3.0",
|
|
265
266
|
"esbuild": "^0.28.2",
|
|
266
267
|
"esbuild-node-externals": "^2.0.0",
|
|
267
|
-
"eslint": "10.
|
|
268
|
+
"eslint": "10.9.1",
|
|
268
269
|
"eslint-plugin-import": "^2.32.0",
|
|
269
270
|
"eslint-plugin-jest": "29.16.1",
|
|
270
271
|
"husky": "^9.1.7",
|
|
@@ -232,6 +232,23 @@ AGENT_USER="${AI_LOOP_AGENT:-$(jq -r '.rules.aiLoop.agentUser // .aiLoop.agentUs
|
|
|
232
232
|
AGENT_USER=""; }; }
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
+
**Then prove `gh` is *authenticating as* that account — this one halts the
|
|
236
|
+
tick.** Assignability passes no matter who is calling, so on a machine where the
|
|
237
|
+
agent identity was never configured both checks above are green while `gh` is
|
|
238
|
+
the owner: worktrees, commits, PRs and reviews all land under the owner's
|
|
239
|
+
account, and the split only shows up in `git log` afterwards (#601).
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# Exit 0 continue, non-zero halt — also covers the bare-checkout repair. The
|
|
243
|
+
# identity check is skipped entirely when no agentUser is declared.
|
|
244
|
+
npx @rtorcato/repo-tooling loop guard --root "$ROOT" || exit 1
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Configured intent that is not met is a misconfiguration, not a degraded mode —
|
|
248
|
+
which is why this halts where the assignability check merely warns. Fix it by
|
|
249
|
+
pointing `gh` at the agent account on this machine, or by removing
|
|
250
|
+
`rules.aiLoop.agentUser`.
|
|
251
|
+
|
|
235
252
|
**It lives in the repo, not a shell profile.** The agent account is a
|
|
236
253
|
collaborator on *this* repo, so a machine-wide env var is both the wrong
|
|
237
254
|
granularity and invisible — forgotten on a new laptop, with the only symptom
|
|
@@ -1483,13 +1500,31 @@ do the linking here:
|
|
|
1483
1500
|
|
|
1484
1501
|
```bash
|
|
1485
1502
|
DIRS=$(jq -r '.worktree.symlinkDirectories[]? // empty' "$ROOT/.claude/settings.json" 2>/dev/null)
|
|
1486
|
-
|
|
1503
|
+
printf '%s\n' "$DIRS" | while IFS= read -r d; do
|
|
1504
|
+
[ -n "$d" ] || continue
|
|
1487
1505
|
[ -d "$ROOT/$d" ] || continue # an entry pointing at nothing links nothing
|
|
1488
1506
|
mkdir -p "$(dirname "$WT_ROOT/$SLUG/$d")"
|
|
1489
1507
|
ln -s "$ROOT/$d" "$WT_ROOT/$SLUG/$d"
|
|
1490
1508
|
done
|
|
1509
|
+
|
|
1510
|
+
# assert it happened — an unlinked worktree must never reach an implementer
|
|
1511
|
+
MISSING=$(printf '%s\n' "$DIRS" | while IFS= read -r d; do
|
|
1512
|
+
[ -n "$d" ] && [ -d "$ROOT/$d" ] && [ ! -L "$WT_ROOT/$SLUG/$d" ] && printf '%s ' "$d"
|
|
1513
|
+
done)
|
|
1514
|
+
[ -z "$MISSING" ] || echo "FATAL: $SLUG has no symlink for: $MISSING"
|
|
1491
1515
|
```
|
|
1492
1516
|
|
|
1517
|
+
**Iterate line by line — never `for d in $DIRS`.** Your shell may be zsh, which
|
|
1518
|
+
does not word-split an unquoted expansion: `$DIRS` arrives as *one* word with
|
|
1519
|
+
embedded newlines, `[ -d ]` fails against that nonsense path, and the loop links
|
|
1520
|
+
**nothing** (#585). Same class as the Pass 2 glob hazard below, and just as
|
|
1521
|
+
silent — the `pnpm install` fallback is gated on `$DIRS` being *empty*, which it
|
|
1522
|
+
is not, so the worktree gets neither links nor an install, and the implementer
|
|
1523
|
+
meets `Cannot find module` on its first test run, reading as the issue's fault
|
|
1524
|
+
rather than the harness's. That is what the `MISSING` assertion is for: if it
|
|
1525
|
+
prints, do **not** spawn an implementer — run `pnpm install` in the worktree, or
|
|
1526
|
+
return the issue to `ai-ready`, drop `ai-wip`, and move on.
|
|
1527
|
+
|
|
1493
1528
|
**Read the setting, do not rely on it.** `worktree.symlinkDirectories` is a
|
|
1494
1529
|
**Claude Code** setting, honoured by `EnterWorktree` — which this pipeline
|
|
1495
1530
|
forbids outright (see below) and replaces with a raw `git worktree add`. So the
|
|
@@ -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
|
+
}
|