@rtorcato/repo-tooling 3.31.1 → 3.32.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.
@@ -232,9 +232,11 @@ async function runBaseChecks(dir, lock, opts) {
232
232
  results.push(await checkAiSetup(dir));
233
233
  // User-global, not repo state — see checkClaudeSkills on why it never returns drift.
234
234
  results.push(await checkClaudeSkills(opts.skillsDir));
235
- // #533: gated on `aiLoop`, which is already the "this repo uses the pipeline"
235
+ // #533: gated on `aiLoop.agentUser`, which is the "this repo uses the pipeline"
236
236
  // signal, so a repo that doesn't gets no line at all rather than an empty one.
237
- if (lock?.rules?.aiLoop && lock.rules.requiredSkills?.length) {
237
+ // The key itself no longer says that — since #571 every repo is scaffolded with
238
+ // an empty `aiLoop`, and the skills have always read the login, not the key.
239
+ if (lock?.rules?.aiLoop?.agentUser && lock.rules.requiredSkills?.length) {
238
240
  results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
239
241
  }
240
242
  // #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
@@ -11,6 +11,7 @@ import fs from 'fs-extra';
11
11
  import { realGitExec } from '../../base/git-identity.js';
12
12
  import { getPackageRoot } from '../utils/copy-preset.js';
13
13
  import { shellQuote } from '../utils/shell.js';
14
+ import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
14
15
  /**
15
16
  * Skills this package owns the content of and keeps up to date. The loop first —
16
17
  * it is the pipeline; the other three are its drivers (burst, on-ramp, status).
@@ -115,47 +116,6 @@ export function classifySkillContent(installed, shipped) {
115
116
  return 'unknown';
116
117
  return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
117
118
  }
118
- function versionParts(version) {
119
- return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
120
- }
121
- /** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
122
- export function isNewerVersion(a, b) {
123
- const [left, right] = [versionParts(a), versionParts(b)];
124
- for (let i = 0; i < 3; i++) {
125
- const [x, y] = [left[i] ?? 0, right[i] ?? 0];
126
- if (x !== y)
127
- return x > y;
128
- }
129
- return false;
130
- }
131
- /**
132
- * The version to stamp, given what `package.json` claims.
133
- *
134
- * In a published tarball that field is authoritative — `@semantic-release/npm`
135
- * rewrites it before packing. In a *git checkout* it is not: this repo runs
136
- * semantic-release without `@semantic-release/git` (#417), so nothing ever
137
- * writes the released version back and the field sits at whatever it was last
138
- * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
139
- * ten minor versions of drift.
140
- *
141
- * That mattered because the stamp feeds the downgrade guard below: a skill
142
- * installed from npm could not be updated from a local checkout, and the
143
- * refusal claimed the installed copy was "newer" when only its *label* was.
144
- *
145
- * So when the package root is a git checkout, take the nearest tag as well and
146
- * keep whichever is higher. Monotonic on purpose — this can only ever raise the
147
- * answer, so a tagless, shallow, or git-less environment keeps today's
148
- * behaviour rather than silently stamping something lower.
149
- */
150
- export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
151
- if (!(await fs.pathExists(path.join(root, '.git'))))
152
- return pkgVersion;
153
- const described = await git(['describe', '--tags', '--abbrev=0'], root);
154
- const tag = described?.trim().replace(/^v/, '');
155
- if (!tag || !/^\d+\.\d+/.test(tag))
156
- return pkgVersion;
157
- return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
158
- }
159
119
  /** The skill source and the package version that will be stamped into it. */
160
120
  export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
161
121
  const root = getPackageRoot();
@@ -91,7 +91,14 @@ export async function generateConfigs(config, targetDir) {
91
91
  await ensureBuildApprovals(config, targetDir);
92
92
  // Family-wide pnpm settings (#314). Runs last of the workspace writers so it
93
93
  // merges into whatever they wrote rather than racing them for the file.
94
- await ensurePnpmSettings(targetDir, bundlerNeedsEsbuild(config), familyGlob(config.projectName));
94
+ //
95
+ // The scope is read back off the emitted package.json rather than taken from
96
+ // config.projectName: under `--preset` that name is only the directory
97
+ // basename, while generatePackageJson preserves an existing scoped `name`.
98
+ // doctor derives the glob from that same field, so reading the basename left
99
+ // the two disagreeing on every scoped repo (#573).
100
+ const { name } = (await fs.readJson(path.join(targetDir, 'package.json')));
101
+ await ensurePnpmSettings(targetDir, bundlerNeedsEsbuild(config), familyGlob(name));
95
102
  // Turborepo task pipeline (pnpm-workspace monorepos, when opted-in)
96
103
  if (config.turborepo) {
97
104
  await generateTurborepo(targetDir);
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import fs from 'fs-extra';
2
3
  import path from 'node:path';
3
4
  import { PNPM_FALLBACK_VERSION, detectPnpmVersion } from './misc.js';
@@ -8,6 +9,8 @@ export async function generatePackageJson(config, targetDir) {
8
9
  existingPackageJson = await fs.readJson(packageJsonPath);
9
10
  }
10
11
  const includeTreeshake = Boolean(config.treeshakeCheck && config.projectType === 'library');
12
+ const generatedScripts = getScripts(config, { includeTreeshake });
13
+ const existingScripts = existingPackageJson?.scripts ?? {};
11
14
  const packageJson = {
12
15
  name: config.projectName,
13
16
  version: '0.1.0',
@@ -17,10 +20,7 @@ export async function generatePackageJson(config, targetDir) {
17
20
  // generated workflows carry no `version:` input (#364).
18
21
  packageManager: `pnpm@${detectPnpmVersion() ?? PNPM_FALLBACK_VERSION}`,
19
22
  ...existingPackageJson,
20
- scripts: {
21
- ...getScripts(config, { includeTreeshake }),
22
- ...existingPackageJson?.scripts,
23
- },
23
+ scripts: resolveScripts(config, generatedScripts, existingScripts),
24
24
  dependencies: {
25
25
  ...existingPackageJson?.dependencies,
26
26
  },
@@ -66,6 +66,30 @@ export async function generatePackageJson(config, targetDir) {
66
66
  // (see ensureBuildApprovals in build.ts).
67
67
  await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
68
68
  }
69
+ /**
70
+ * Merge the generated scripts with what the package.json already had. Existing
71
+ * values win — that merge-don't-clobber policy is what makes re-running `setup`
72
+ * on a live repo safe.
73
+ *
74
+ * `build` on a library is the one exception. The library branch of
75
+ * generatePackageJson overwrites the whole publish contract
76
+ * (main/module/types/exports) unconditionally, and that contract names files
77
+ * only the preset's bundler emits: a preserved `build: tsc` produces
78
+ * dist/index.js + dist/index.d.ts and never the ./dist/index.cjs that `main`
79
+ * and every `require` condition point at, so `pnpm build && npm publish` ships
80
+ * a package whose CJS entry points 404 (#570). Owning the contract means owning
81
+ * its producer; the replacement is announced rather than silent.
82
+ */
83
+ function resolveScripts(config, generated, existing) {
84
+ const scripts = { ...generated, ...existing };
85
+ if (config.projectType !== 'library' || !generated.build)
86
+ return scripts;
87
+ if (existing.build && existing.build !== generated.build) {
88
+ console.warn(chalk.yellow(`⚠️ Replaced "build": "${existing.build}" with "${generated.build}" — the library exports map names files only ${config.bundler} emits.`));
89
+ }
90
+ scripts.build = generated.build;
91
+ return scripts;
92
+ }
69
93
  function getScripts(config, opts = {}) {
70
94
  const scripts = {};
71
95
  // TypeScript scripts
@@ -24,10 +24,16 @@ export const WORKSPACE_FILE = 'pnpm-workspace.yaml';
24
24
  *
25
25
  * One glob covers a whole scope: pnpm matches these entries with
26
26
  * `@pnpm/config.matcher`, so there's no package list to keep in sync.
27
+ *
28
+ * The scope is matched against npm's own name charset, not just "anything up
29
+ * to the slash": the name comes verbatim from a pre-existing `package.json`
30
+ * and is never validated as an npm name on the way here, so a scope of a lone
31
+ * wildcard would otherwise be taken as a glob and exempt every scoped package
32
+ * from the release-age delay. An unparseable scope gets no setting at all.
27
33
  */
28
34
  export function familyGlob(packageName) {
29
35
  const name = typeof packageName === 'string' ? packageName : '';
30
- const scope = /^(@[^/]+)\//.exec(name)?.[1];
36
+ const scope = /^(@[a-z0-9-][a-z0-9._-]*)\//i.exec(name)?.[1];
31
37
  return scope ? `${scope}/*` : null;
32
38
  }
33
39
  /** Bundlers that pull in esbuild, whose install script pnpm 11 refuses to run unapproved. */
package/dist/cli/index.js CHANGED
@@ -3,12 +3,12 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { Command } from 'commander';
5
5
  import fs from 'fs-extra';
6
- import packageJson from '../../package.json' with { type: 'json' };
7
6
  import { doctorCommand } from './commands/doctor.js';
8
7
  import { fixCommand } from './commands/fix.js';
9
8
  import { loopGuardCommand } from './commands/loop-guard.js';
10
9
  import { setupProject } from './commands/setup.js';
11
10
  import { copyPreset, PRESETS } from './utils/copy-preset.js';
11
+ import { getToolVersion } from './utils/version.js';
12
12
  async function isSelfRepo(dir) {
13
13
  try {
14
14
  const pkg = await fs.readJson(path.join(dir, 'package.json'));
@@ -22,7 +22,7 @@ const program = new Command();
22
22
  program
23
23
  .name('@rtorcato/repo-tooling')
24
24
  .description('🛠️ JavaScript and TypeScript tooling setup for modern projects')
25
- .version(packageJson.version);
25
+ .version(await getToolVersion());
26
26
  program
27
27
  .command('setup')
28
28
  .alias('init')
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
- import packageJson from '../../../package.json' with { type: 'json' };
4
3
  import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
5
4
  import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
5
+ import { getToolVersion } from './version.js';
6
6
  export const LOCKFILE_NAME = '.repo-tooling.json';
7
7
  // Package and bin name used before the js-tooling→repo-tooling rename (#272).
8
8
  // The bin no longer exists and the package is 404 on the registry, so any
@@ -27,6 +27,21 @@ const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/loc
27
27
  * branches on it beyond printing it.
28
28
  */
29
29
  export const MCP_IMPORTANCE = ['nice-to-have', 'important', 'critical'];
30
+ /**
31
+ * The empty ruleset a repo with no stated rules gets (#571). `rules` used to
32
+ * appear only via v1–v3 migration, so the repos this tool creates were exactly
33
+ * the ones with nothing to edit and nothing to compare. Writing the containers
34
+ * — with `$schema` stamped alongside — makes the file teach its own shape.
35
+ *
36
+ * Values, never defaults: every entry is empty, because a populated one would
37
+ * be this tool asserting a rule on a repo whose humans have not stated any.
38
+ */
39
+ export const DEFAULT_RULES = {
40
+ aiLoop: {},
41
+ requiredSkills: [],
42
+ mcp: { recommended: [] },
43
+ exceptions: {},
44
+ };
30
45
  /**
31
46
  * JSON Schema for the lockfile, published with the docs site at the exact URL
32
47
  * every written lockfile's `$schema` points to (#529). The `satisfies` clauses
@@ -243,10 +258,11 @@ export async function writeLockfile(dir, config, assets) {
243
258
  record: {
244
259
  config,
245
260
  ...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
246
- writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
261
+ writtenBy: `@rtorcato/repo-tooling@${await getToolVersion()}`,
247
262
  writtenAt: new Date().toISOString(),
248
263
  },
249
- ...(existing?.rules ? { rules: existing.rules } : {}),
264
+ // A repo that has stated no rules gets the empty scaffold, not nothing (#571).
265
+ rules: existing?.rules ?? DEFAULT_RULES,
250
266
  };
251
267
  await fs.writeJson(filepath, lockfile, { spaces: 2 });
252
268
  // Migrate a pre-rename repo to the new name: now that the canonical file is
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one answer to "what version of this package is running" (#572).
3
+ *
4
+ * There were two. `--version` and the lockfile's `writtenBy` read the imported
5
+ * `package.json` directly; the Claude-skills stamp went through
6
+ * `resolveShippedVersion`, which corrects for the drift documented below. A
7
+ * single `doctor` run could report both — the skills check saying 3.31.0 while
8
+ * `writtenBy` in the same run said 3.11.0. Everything now calls
9
+ * `getToolVersion()`.
10
+ */
11
+ import path from 'node:path';
12
+ import fs from 'fs-extra';
13
+ import packageJson from '../../../package.json' with { type: 'json' };
14
+ import { realGitExec } from '../../base/git-identity.js';
15
+ import { getPackageRoot } from './copy-preset.js';
16
+ function versionParts(version) {
17
+ return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
18
+ }
19
+ /** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
20
+ export function isNewerVersion(a, b) {
21
+ const [left, right] = [versionParts(a), versionParts(b)];
22
+ for (let i = 0; i < 3; i++) {
23
+ const [x, y] = [left[i] ?? 0, right[i] ?? 0];
24
+ if (x !== y)
25
+ return x > y;
26
+ }
27
+ return false;
28
+ }
29
+ /**
30
+ * The version to report, given what `package.json` claims.
31
+ *
32
+ * In a published tarball that field is authoritative — `@semantic-release/npm`
33
+ * rewrites it before packing. In a *git checkout* it is not: this repo runs
34
+ * semantic-release without `@semantic-release/git` (#417), so nothing ever
35
+ * writes the released version back and the field sits at whatever it was last
36
+ * hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
37
+ * ten minor versions of drift.
38
+ *
39
+ * That mattered first because the stamp feeds the skills downgrade guard: a
40
+ * skill installed from npm could not be updated from a local checkout, and the
41
+ * refusal claimed the installed copy was "newer" when only its *label* was.
42
+ *
43
+ * So when the package root is a git checkout, take the nearest tag as well and
44
+ * keep whichever is higher. Monotonic on purpose — this can only ever raise the
45
+ * answer, so a tagless, shallow, or git-less environment keeps today's
46
+ * behaviour rather than silently reporting something lower.
47
+ */
48
+ export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
49
+ if (!(await fs.pathExists(path.join(root, '.git'))))
50
+ return pkgVersion;
51
+ const described = await git(['describe', '--tags', '--abbrev=0'], root);
52
+ const tag = described?.trim().replace(/^v/, '');
53
+ if (!tag || !/^\d+\.\d+/.test(tag))
54
+ return pkgVersion;
55
+ return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
56
+ }
57
+ let cached;
58
+ /**
59
+ * This package's version, for `--version`, `writtenBy`, and the skill stamps.
60
+ *
61
+ * Memoized: in a checkout `resolveShippedVersion` spawns `git describe`, and
62
+ * nothing about the answer changes mid-process. An npm install has no `.git`
63
+ * under the package root, so that path costs one `pathExists` and no subprocess.
64
+ */
65
+ export function getToolVersion() {
66
+ cached ??= resolveShippedVersion(getPackageRoot(), String(packageJson.version));
67
+ return cached;
68
+ }
@@ -18,7 +18,7 @@ import { generateEditorConfig } from '../../cli/generators/misc.js';
18
18
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../../cli/generators/security.js';
19
19
  import { copyPreset } from '../../cli/utils/copy-preset.js';
20
20
  import { LANGUAGES } from '../registry.js';
21
- import { readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow } from './ci.js';
21
+ import { parsePackageSwift, readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow, } from './ci.js';
22
22
  import { SWIFT_HOOKS_DIR, installSwiftGitHooks } from './git-hooks.js';
23
23
  import { ensureSwiftGitignore } from './gitignore.js';
24
24
  /**
@@ -196,6 +196,10 @@ git tag 1.0.0 && git push origin 1.0.0
196
196
  /**
197
197
  * Relative paths `setup --dry-run` reports for a Swift config, in write order.
198
198
  * Kept beside the writer so the two can't drift.
199
+ *
200
+ * This is the greenfield list. Against a directory that already has a
201
+ * Package.swift the writer preserves it and skips the target scaffolding, so the
202
+ * preview over-reports there — the safe direction for a dry run.
199
203
  */
200
204
  export function swiftFileList(config) {
201
205
  const module = swiftModuleName(config.projectName);
@@ -225,12 +229,29 @@ export function swiftFileList(config) {
225
229
  files.push('README.md');
226
230
  return files;
227
231
  }
228
- /** Scaffold a SwiftPM library. The Swift arm of `generateConfigs`. */
232
+ /**
233
+ * Scaffold a SwiftPM library. The Swift arm of `generateConfigs`.
234
+ *
235
+ * Package.swift is source, not config (#574): rewriting one that already exists
236
+ * renames the package after the *directory* and leaves the real `Sources/<Target>/`
237
+ * declared by no target — and `swift build` still exits 0, because SwiftPM just
238
+ * ignores the orphaned directory. So an existing manifest is left byte-identical
239
+ * and the module name is read back off it rather than derived from the directory,
240
+ * the same way the JS presets merge into a package.json instead of replacing it.
241
+ */
229
242
  export async function generateSwiftProject(config, targetDir) {
230
- const module = swiftModuleName(config.projectName);
231
- await fs.writeFile(path.join(targetDir, 'Package.swift'), packageSwift(module));
232
- await fs.outputFile(path.join(targetDir, 'Sources', module, `${module}.swift`), sourceFile(module));
233
- await fs.outputFile(path.join(targetDir, 'Tests', `${module}Tests`, `${module}Tests.swift`), testFile(module));
243
+ const manifestPath = path.join(targetDir, 'Package.swift');
244
+ const existingManifest = (await fs.pathExists(manifestPath))
245
+ ? parsePackageSwift(await fs.readFile(manifestPath, 'utf-8'))
246
+ : null;
247
+ const module = existingManifest?.products[0] ?? swiftModuleName(config.projectName);
248
+ // Nothing is scaffolded under Sources/ or Tests/ either: those would be
249
+ // directories for a target the existing manifest doesn't declare.
250
+ if (!existingManifest) {
251
+ await fs.writeFile(manifestPath, packageSwift(module));
252
+ await fs.outputFile(path.join(targetDir, 'Sources', module, `${module}.swift`), sourceFile(module));
253
+ await fs.outputFile(path.join(targetDir, 'Tests', `${module}Tests`, `${module}Tests.swift`), testFile(module));
254
+ }
234
255
  await copyPreset('swiftlint', targetDir);
235
256
  await copyPreset('periphery', targetDir);
236
257
  await ensureSwiftGitignore(targetDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.31.1",
3
+ "version": "3.32.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": [