@rtorcato/repo-tooling 3.12.1 → 3.13.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.
@@ -18,7 +18,7 @@ import { checkGitIdentity } from '../../base/git-identity.js';
18
18
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
19
19
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
20
20
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
21
- import { allDeps, checkAreTheTypesWrong, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, 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';
21
+ import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, 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';
22
22
  export { evaluateNodeVersion };
23
23
  const PACKAGE = '@rtorcato/repo-tooling';
24
24
  // Detects the broken-release-on-protected-main footgun: a workflow that runs
@@ -295,7 +295,10 @@ export async function runDoctor(dir) {
295
295
  results.push(await checkNodeVersionPin(targetDir));
296
296
  results.push(await checkNodeVersionConsistency(targetDir, pkg));
297
297
  for (const spec of FILE_CHECKS) {
298
- results.push(await checkFile(targetDir, spec));
298
+ // Biome is the one spec that needs the directory as well as the file
299
+ // contents — it compares the `$schema` version against the installed
300
+ // CLI (#424). Dispatched here so it keeps its place in the output.
301
+ results.push(spec.check === 'Biome' ? await checkBiome(targetDir) : await checkFile(targetDir, spec));
299
302
  }
300
303
  results.push(await checkLintStaged(targetDir, pkg));
301
304
  results.push(await checkVerifyScript(targetDir, pkg));
@@ -48,6 +48,18 @@ async function readJsonOrNull(filepath) {
48
48
  return null;
49
49
  }
50
50
  }
51
+ /**
52
+ * The `@biomejs/biome` actually installed under `targetDir`, or null when
53
+ * node_modules isn't populated. This is the binary that will parse the config
54
+ * and emit the schema-mismatch warning, so it is the only version worth
55
+ * comparing a written `$schema` against — a declared range like `^2.5.0` says
56
+ * nothing about which 2.x resolved (#424).
57
+ */
58
+ export async function installedBiomeVersion(targetDir) {
59
+ const installed = await readJsonOrNull(path.join(targetDir, 'node_modules', '@biomejs', 'biome', 'package.json'));
60
+ const match = typeof installed?.version === 'string' ? /(\d+\.\d+\.\d+)/.exec(installed.version) : null;
61
+ return match?.[1] ?? null;
62
+ }
51
63
  /**
52
64
  * The Biome version the scaffolded `$schema` URL should name (#363).
53
65
  *
@@ -59,20 +71,18 @@ async function readJsonOrNull(filepath) {
59
71
  * best statement of intent when node_modules isn't populated yet.
60
72
  */
61
73
  export async function resolveBiomeSchemaVersion(targetDir) {
62
- const installed = await readJsonOrNull(path.join(targetDir, 'node_modules', '@biomejs', 'biome', 'package.json'));
74
+ const installed = await installedBiomeVersion(targetDir);
75
+ if (installed)
76
+ return installed;
63
77
  const pkg = await readJsonOrNull(path.join(targetDir, 'package.json'));
64
78
  const deps = {
65
79
  ...(pkg?.dependencies ?? {}),
66
80
  ...(pkg?.devDependencies ?? {}),
67
81
  };
68
- // Both an exact version ("2.5.7") and a range ("^2.5.0") carry the x.y.z we
69
- // need, so one pattern covers every source.
70
- for (const candidate of [installed?.version, deps['@biomejs/biome']]) {
71
- const match = typeof candidate === 'string' ? /(\d+\.\d+\.\d+)/.exec(candidate) : null;
72
- if (match?.[1])
73
- return match[1];
74
- }
75
- return BIOME_SCHEMA_FALLBACK;
82
+ // A range ("^2.5.0") carries the x.y.z we need just as an exact version does.
83
+ const declared = deps['@biomejs/biome'];
84
+ const match = typeof declared === 'string' ? /(\d+\.\d+\.\d+)/.exec(declared) : null;
85
+ return match?.[1] ?? BIOME_SCHEMA_FALLBACK;
76
86
  }
77
87
  /**
78
88
  * The thin pointer config — the same shape this repo dogfoods. `fix biome` used
@@ -82,6 +82,11 @@ export const PRESETS = {
82
82
  desc: 'Claude Code skill for driving the repo-tooling CLI',
83
83
  legacyTarget: '.claude/skills/js-tooling.md',
84
84
  },
85
+ 'claude-sync-agents': {
86
+ source: 'tooling/claude/sync-agents.mjs',
87
+ target: 'scripts/sync-agents.mjs',
88
+ desc: 'Canonical SKILL.md → AGENTS.md sync script (zero-config; --check mode for CI)',
89
+ },
85
90
  'mcp-example': {
86
91
  source: 'tooling/mcp/mcp.json.example',
87
92
  target: '.mcp.json.example',
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
- import { hookHasUncommented } from '../../base/checks.js';
3
+ import { checkFile, hookHasUncommented } from '../../base/checks.js';
4
+ import { installedBiomeVersion } from '../../cli/generators/linting.js';
4
5
  import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
5
6
  import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
6
7
  const PACKAGE = '@rtorcato/repo-tooling';
@@ -152,6 +153,54 @@ function matchesTsConfig(contents) {
152
153
  rootDir.includes('${configDir}') &&
153
154
  TS_PRESET_STRICTNESS.every((key) => compilerOptions[key] === true));
154
155
  }
156
+ export const BIOME_FILE_CHECK = {
157
+ check: 'Biome',
158
+ candidates: ['biome.json', 'biome.jsonc'],
159
+ expected: `extends "${PACKAGE}/biome" or inlines the preset, with the linter on`,
160
+ matcher: matchesBiomeConfig,
161
+ optional: true,
162
+ hint: 'Run `npx @rtorcato/repo-tooling fix biome` to scaffold',
163
+ };
164
+ /**
165
+ * The Biome check plus the one thing a contents-only matcher can't see: whether
166
+ * the `$schema` URL still names the Biome that will read it (#424).
167
+ *
168
+ * `fix biome` writes the URL from the version resolved at scaffold time, and
169
+ * nothing re-checked it afterwards — so once the repo's Biome moved on, every
170
+ * single invocation (the pre-commit hook included) printed "The configuration
171
+ * schema version does not match the CLI version", while doctor stayed clean.
172
+ *
173
+ * Compared against the *installed* package only, never the declared range: an
174
+ * exact match is what Biome itself demands, and `^2.5.0` doesn't say which 2.x
175
+ * resolved. With no node_modules there is nothing to be wrong about, so the
176
+ * check says nothing rather than guessing.
177
+ */
178
+ export async function checkBiome(dir) {
179
+ const result = await checkFile(dir, BIOME_FILE_CHECK);
180
+ if (result.status !== 'ok')
181
+ return result;
182
+ const installed = await installedBiomeVersion(dir);
183
+ if (!installed)
184
+ return result;
185
+ // The same candidate checkFile settled on — first one that exists wins.
186
+ for (const candidate of BIOME_FILE_CHECK.candidates) {
187
+ const filepath = path.join(dir, candidate);
188
+ if (!(await fs.pathExists(filepath)))
189
+ continue;
190
+ const url = readSchemaUrl(await fs.readFile(filepath, 'utf8'));
191
+ const targeted = url?.includes('biomejs.dev') ? schemaUrlVersion(url)?.join('.') : null;
192
+ if (targeted && targeted !== installed) {
193
+ return {
194
+ check: BIOME_FILE_CHECK.check,
195
+ status: 'drift',
196
+ detail: `${candidate} targets Biome ${targeted} but @biomejs/biome ${installed} is installed`,
197
+ hint: 'Run `npx @rtorcato/repo-tooling fix biome` to rewrite the $schema URL',
198
+ };
199
+ }
200
+ return result;
201
+ }
202
+ return result;
203
+ }
155
204
  export const FILE_CHECKS = [
156
205
  {
157
206
  check: 'TypeScript',
@@ -160,14 +209,7 @@ export const FILE_CHECKS = [
160
209
  matcher: matchesTsConfig,
161
210
  hint: 'Run `npx @rtorcato/repo-tooling fix tsconfig` to scaffold',
162
211
  },
163
- {
164
- check: 'Biome',
165
- candidates: ['biome.json', 'biome.jsonc'],
166
- expected: `extends "${PACKAGE}/biome" or inlines the preset, with the linter on`,
167
- matcher: matchesBiomeConfig,
168
- optional: true,
169
- hint: 'Run `npx @rtorcato/repo-tooling fix biome` to scaffold',
170
- },
212
+ BIOME_FILE_CHECK,
171
213
  {
172
214
  check: 'ESLint',
173
215
  candidates: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'],
@@ -685,6 +727,10 @@ export async function checkSizeLimit(dir, pkg) {
685
727
  hint: 'Add `size-limit` to enforce bundle-size budgets in CI for library projects',
686
728
  };
687
729
  }
730
+ /** Changesets is in use when its config file is present. */
731
+ export function usesChangesets(dir) {
732
+ return fs.pathExists(path.join(dir, '.changeset', 'config.json'));
733
+ }
688
734
  const SEMANTIC_RELEASE_FILES = [
689
735
  '.releaserc',
690
736
  '.releaserc.json',
@@ -714,7 +760,7 @@ export async function checkSemanticRelease(dir, pkg) {
714
760
  break;
715
761
  }
716
762
  }
717
- const hasChangesets = await fs.pathExists(path.join(dir, '.changeset', 'config.json'));
763
+ const hasChangesets = await usesChangesets(dir);
718
764
  const hasReleasePlease = await fs.pathExists(path.join(dir, 'release-please-config.json'));
719
765
  const hasSemanticRelease = inPkg || !!configFile;
720
766
  // Conflict: more than one of {semantic-release, Changesets, Release Please}.
@@ -1205,9 +1251,14 @@ export async function findDocsAppDir(dir) {
1205
1251
  export async function checkDocsSite(dir, docsAppDir) {
1206
1252
  const check = 'Docs site';
1207
1253
  const deltas = [];
1254
+ // sync-changelog pumps a root CHANGELOG.md into the site — a semantic-release
1255
+ // shape. Changesets writes per-package changelogs and leaves the (private)
1256
+ // root package without one, so don't require the script there (#425). If a
1257
+ // Changesets repo keeps the script anyway, it still has to be wired.
1208
1258
  const syncPath = path.join(dir, 'scripts', 'sync-changelog.mjs');
1209
1259
  if (!(await fs.pathExists(syncPath))) {
1210
- deltas.push('scripts/sync-changelog.mjs missing');
1260
+ if (!(await usesChangesets(dir)))
1261
+ deltas.push('scripts/sync-changelog.mjs missing');
1211
1262
  }
1212
1263
  else {
1213
1264
  const pkgPath = path.join(dir, docsAppDir, 'package.json');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.12.1",
3
+ "version": "3.13.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": [
@@ -100,6 +100,7 @@
100
100
  "tooling/semantic-release/*.mjs",
101
101
  "tooling/semantic-release/*.d.mts",
102
102
  "tooling/claude/*.md",
103
+ "tooling/claude/*.mjs",
103
104
  "tooling/mcp/mcp.json.example",
104
105
  "tooling/swift/*.yml",
105
106
  "tooling/swift/*.json",
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Canonical sync-agents for @rtorcato/* repos (shipped by @rtorcato/repo-tooling
3
+ // — copy via `repo-tooling copy claude-sync-agents`).
4
+ //
5
+ // Regenerates AGENTS.md from the repo's SKILL.md body so the two can never
6
+ // drift. SKILL.md (with its YAML frontmatter) is the single source of truth;
7
+ // AGENTS.md is its frontmatter-stripped mirror for non-Claude AI tools.
8
+ //
9
+ // node scripts/sync-agents.mjs # write AGENTS.md
10
+ // node scripts/sync-agents.mjs --check # exit 1 if AGENTS.md is stale (used by CI)
11
+ //
12
+ // Zero-config: the skill directory is derived from the root package.json `name`
13
+ // with any npm scope stripped — `@rtorcato/js-common` → `skills/js-common/`.
14
+ // That is the convention across the family, so this file is copied unmodified.
15
+
16
+ import { readFileSync, writeFileSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21
+ const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
22
+ const skillRelative = `skills/${name.replace(/^@[^/]+\//, '')}/SKILL.md`
23
+ const SKILL = join(root, skillRelative)
24
+ const AGENTS = join(root, 'AGENTS.md')
25
+
26
+ const HEADER = `<!-- Generated from ${skillRelative} by scripts/sync-agents.mjs — do not edit by hand; run \`pnpm sync:agents\`. -->\n\n`
27
+
28
+ // Strip the leading YAML frontmatter block, keep the markdown body.
29
+ const body = readFileSync(SKILL, 'utf8').replace(/^---\n[\s\S]*?\n---\n+/, '')
30
+ const expected = HEADER + body
31
+
32
+ if (process.argv.includes('--check')) {
33
+ let current = ''
34
+ try {
35
+ current = readFileSync(AGENTS, 'utf8')
36
+ } catch {
37
+ // AGENTS.md missing → treat as stale below.
38
+ }
39
+ if (current !== expected) {
40
+ console.error(`AGENTS.md is out of sync with ${skillRelative} — run \`pnpm sync:agents\`.`)
41
+ process.exit(1)
42
+ }
43
+ console.log(`AGENTS.md is in sync with ${skillRelative}.`)
44
+ } else {
45
+ writeFileSync(AGENTS, expected)
46
+ console.log(`Wrote AGENTS.md from ${skillRelative}`)
47
+ }