@rtorcato/repo-tooling 3.8.4 → 3.8.6

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.
@@ -212,6 +212,10 @@ export function computeFileList(config) {
212
212
  if (config.typescript.enabled) {
213
213
  files.push('tsconfig.json', 'reset.d.ts');
214
214
  }
215
+ // Libraries get a bundle-size budget plus the `size-limit` script that runs it
216
+ // (#382). The scaffold's exports are root-only, so it's always the static form.
217
+ if (config.projectType === 'library')
218
+ files.push('.size-limit.json');
215
219
  if (config.linting.tool === 'biome' || config.linting.tool === 'both') {
216
220
  files.push(BIOME_CONFIG);
217
221
  }
@@ -2,9 +2,14 @@ import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { renderGitLabCI } from '../../base/ci.js';
4
4
  import { gitlabSpec } from '../../languages/js/ci.js';
5
- export async function generateGitLabCI(config, targetDir) {
5
+ /**
6
+ * @param scripts The target's package.json scripts, so the pipeline only calls
7
+ * commands that exist (#386). Omit on the `setup` path, which writes the
8
+ * scripts itself as part of the same scaffold.
9
+ */
10
+ export async function generateGitLabCI(config, targetDir, { scripts } = {}) {
6
11
  const yamlPath = path.join(targetDir, '.gitlab-ci.yml');
7
12
  // ponytail: direct JS import until a second language module ships CI (#287).
8
- await fs.writeFile(yamlPath, renderGitLabCI(gitlabSpec(config)));
13
+ await fs.writeFile(yamlPath, renderGitLabCI(gitlabSpec(config, { scripts })));
9
14
  return '.gitlab-ci.yml';
10
15
  }
@@ -8,7 +8,7 @@ import { ensurePnpmSettings, familyGlob } from './pnpm-workspace.js';
8
8
  import { generateGitConfigs } from './git.js';
9
9
  import { generateGitHubActions } from './github-actions.js';
10
10
  import { generateLintingConfigs } from './linting.js';
11
- import { generateKnipConfig, generateMiscBaseline, generateVscodeExtensions } from './misc.js';
11
+ import { generateKnipConfig, generateMiscBaseline, generateSizeLimitConfig, generateVscodeExtensions, } from './misc.js';
12
12
  import { generatePackageJson } from './package-json.js';
13
13
  import { generateReadme } from './readme.js';
14
14
  import { generateSecurityConfigs } from './security.js';
@@ -33,6 +33,12 @@ export async function generateConfigs(config, targetDir) {
33
33
  await generatePackageJson(config, targetDir);
34
34
  // Universal baseline: .editorconfig, .nvmrc, engines.node, knip.json
35
35
  await generateMiscBaseline(targetDir);
36
+ // Bundle-size budget for publishable libraries. getScripts() emits the
37
+ // matching `size-limit` script, so the budget is runnable from the first
38
+ // commit rather than an inert file implying enforcement (#382).
39
+ if (config.projectType === 'library') {
40
+ await generateSizeLimitConfig(targetDir);
41
+ }
36
42
  // Recommend the editor extensions matching the enabled tools
37
43
  await generateVscodeExtensions(config, targetDir);
38
44
  // Generate TypeScript configuration
@@ -141,6 +141,10 @@ function getScripts(config, opts = {}) {
141
141
  // profile fits the dual (cjs+esm) exports the library scaffold emits.
142
142
  if (config.projectType === 'library') {
143
143
  scripts['attw'] = 'attw --pack';
144
+ // The budget scaffolded alongside it (generateConfigs) is inert without a
145
+ // way to run it — a size budget nothing runs implies an enforcement that
146
+ // isn't there (#382).
147
+ Object.assign(scripts, SIZE_LIMIT_SCRIPTS);
144
148
  }
145
149
  // Git hooks
146
150
  if (config.gitHooks) {
@@ -169,6 +173,14 @@ function getScripts(config, opts = {}) {
169
173
  * refused to compose a chain, both because no target ever created that script
170
174
  * (#364).
171
175
  */
176
+ /**
177
+ * The one definition of the `size-limit` script, shared by `getScripts()` (setup)
178
+ * and the `size-limit` fixer, so the two paths can't drift (#382 — the divergence
179
+ * behind #371 and #377).
180
+ */
181
+ export const SIZE_LIMIT_SCRIPTS = { 'size-limit': 'size-limit' };
182
+ /** Shared by the same two paths, for the same reason. */
183
+ export const SIZE_LIMIT_VERSION = '^11.2.0';
172
184
  export async function ensureScripts(targetDir, scripts) {
173
185
  const pkgPath = path.join(targetDir, 'package.json');
174
186
  if (!(await fs.pathExists(pkgPath)))
@@ -352,8 +364,11 @@ function getDependencies(config) {
352
364
  deps['lint-staged'] = '^16.0.0';
353
365
  }
354
366
  // are-the-types-wrong — validate published type resolution for libraries.
367
+ // size-limit enforces the bundle-size budget scaffolded next to it; without
368
+ // the dependency the script dies with "command not found" (#382).
355
369
  if (config.projectType === 'library') {
356
370
  deps['@arethetypeswrong/cli'] = '^0.18.2';
371
+ deps['size-limit'] = SIZE_LIMIT_VERSION;
357
372
  }
358
373
  // Commit linting (enforcement) + commitizen (assistance)
359
374
  if (config.commitLint) {
@@ -22,25 +22,27 @@ export async function generateVitestConfig(config, targetDir) {
22
22
  // `oxc` (not `esbuild`) is the Vite 8 spelling — vitest ^4 resolves Vite 8,
23
23
  // and Vite 8 silently ignores the old `esbuild` key.
24
24
  const jsxOverride = config.projectType === 'nextjs-app'
25
- ? ` // tsconfig sets jsx: "preserve" for Next's compiler; vitest needs a
26
- // transform of its own or .tsx tests won't parse.
27
- oxc: { jsx: { runtime: 'automatic' } },
25
+ ? ` // tsconfig sets jsx: "preserve" for Next's compiler; vitest needs a
26
+ // transform of its own or .tsx tests won't parse.
27
+ oxc: { jsx: { runtime: 'automatic' } },
28
28
  `
29
29
  : '';
30
- const vitestConfig = `import { defineConfig } from 'vitest/config'
30
+ // Layered on the shipped preset rather than hand-rolled: the preset owns
31
+ // globals, the shared excludes and the v8/lcov coverage reporters, so preset
32
+ // improvements reach consumers — and doctor's `Vitest` check (which looks for
33
+ // this import) can actually pass after `fix vitest` (#387).
34
+ const vitestConfig = `import base from '@rtorcato/repo-tooling/vitest/config'
35
+ import { defineConfig, mergeConfig } from 'vitest/config'
31
36
 
32
- export default defineConfig({
33
- ${jsxOverride} test: {
34
- globals: true,
35
- environment: '${config.testing.environment === 'browser' ? 'jsdom' : 'node'}',
36
- setupFiles: ['./vitest.setup.ts'],
37
- coverage: {
38
- provider: 'v8',
39
- // lcov feeds Codecov; text is the local console summary.
40
- reporter: ['text', 'lcov']
37
+ export default mergeConfig(
38
+ base,
39
+ defineConfig({
40
+ ${jsxOverride} test: {
41
+ environment: '${config.testing.environment === 'browser' ? 'jsdom' : 'node'}',
42
+ setupFiles: ['./vitest.setup.ts']
41
43
  }
42
- }
43
- })
44
+ })
45
+ )
44
46
  `;
45
47
  await fs.writeFile(vitestConfigPath, vitestConfig);
46
48
  // Create setup file
@@ -89,14 +89,74 @@ function matchesBiomeConfig(contents) {
89
89
  schema.includes('biomejs.dev/schemas/') &&
90
90
  linter?.rules?.preset !== undefined);
91
91
  }
92
+ const TS_PRESET_REF = /@rtorcato\/(?:js|repo)-tooling\/typescript\//;
93
+ /**
94
+ * Options `tooling/typescript/tsconfig.base.json` sets that a tsconfig written
95
+ * by hand is unlikely to carry all of. `strict` alone is far too common to
96
+ * conclude anything from.
97
+ */
98
+ const TS_PRESET_STRICTNESS = [
99
+ 'strict',
100
+ 'noUncheckedIndexedAccess',
101
+ 'noImplicitOverride',
102
+ 'noPropertyAccessFromIndexSignature',
103
+ ];
104
+ /**
105
+ * `strict` and every flag it implies. TypeScript lets any one of these be
106
+ * switched off individually while `strict: true` is still literally present —
107
+ * the explicit override wins — so a config can keep every marker this check
108
+ * looks for and still ship with type safety off. Any of them explicitly
109
+ * `false` is drift.
110
+ */
111
+ const TS_STRICT_FLAGS = [
112
+ 'strict',
113
+ 'noImplicitAny',
114
+ 'strictNullChecks',
115
+ 'strictFunctionTypes',
116
+ 'strictBindCallApply',
117
+ 'strictPropertyInitialization',
118
+ 'noImplicitThis',
119
+ 'useUnknownInCatchVariables',
120
+ 'alwaysStrict',
121
+ ];
122
+ /**
123
+ * The same two shapes as `matchesBiomeConfig`, for the same reason (#385): the
124
+ * `extends` pointer `fix tsconfig` writes, and the whole preset inlined, which
125
+ * is what `copy tsconfig` drops — `tooling/typescript/tsconfig.base.json` *is*
126
+ * the preset, so it names it nowhere, and every `copy tsconfig` repo was
127
+ * reported as drifted with no way out. This check isn't optional, so that read
128
+ * as a real problem rather than an optional one.
129
+ *
130
+ * Structural rather than textual, and for the same reason: `strict: false` is
131
+ * drift whichever shape the config takes, a marker sitting in a comment proves
132
+ * nothing, and unparseable counts as drift rather than being waved through.
133
+ */
134
+ function matchesTsConfig(contents) {
135
+ const config = parseJsonc(contents);
136
+ if (!config)
137
+ return false;
138
+ const compilerOptions = (config.compilerOptions ?? {});
139
+ // Naming the preset and then switching strictness off is precisely the drift
140
+ // this check exists to catch, so it disqualifies either shape — `strict`
141
+ // itself or any single flag it implies.
142
+ if (TS_STRICT_FLAGS.some((key) => compilerOptions[key] === false))
143
+ return false;
144
+ if (TS_PRESET_REF.test(JSON.stringify(config.extends ?? '')))
145
+ return true;
146
+ // The inlined preset: a `${configDir}`-anchored rootDir — how a preset meant
147
+ // to be extended resolves against the consuming repo — plus the strict block
148
+ // the shipped preset sets.
149
+ const rootDir = compilerOptions.rootDir;
150
+ return (typeof rootDir === 'string' &&
151
+ rootDir.includes('${configDir}') &&
152
+ TS_PRESET_STRICTNESS.every((key) => compilerOptions[key] === true));
153
+ }
92
154
  export const FILE_CHECKS = [
93
155
  {
94
156
  check: 'TypeScript',
95
157
  candidates: ['tsconfig.json'],
96
- expected: `extends "${PACKAGE}/typescript/*"`,
97
- matcher: /@rtorcato\/(?:js|repo)-tooling\/typescript\//,
98
- // `fix tsconfig`, not `copy tsconfig`: copy drops the whole preset inline,
99
- // which carries no `extends` for the matcher above to find (#381).
158
+ expected: `extends "${PACKAGE}/typescript/*" or inlines the preset, with strict on`,
159
+ matcher: matchesTsConfig,
100
160
  hint: 'Run `npx @rtorcato/repo-tooling fix tsconfig` to scaffold',
101
161
  },
102
162
  {
@@ -74,9 +74,13 @@ export function scriptsOf(pkg) {
74
74
  return undefined;
75
75
  return pkg.scripts ?? {};
76
76
  }
77
+ /** The repo defines this script — or we have no scripts to check it against. */
78
+ function hasScript(opts, script) {
79
+ return !opts.scripts || script in opts.scripts;
80
+ }
77
81
  /** Emit a step only when the script it runs exists (or we can't know yet). */
78
82
  function stepFor(opts, script, step) {
79
- return !opts.scripts || script in opts.scripts ? step : null;
83
+ return hasScript(opts, script) ? step : null;
80
84
  }
81
85
  /** Join the steps that survived gating onto the shared setup preamble. */
82
86
  function jobSteps(steps) {
@@ -212,17 +216,33 @@ ${attw}${publint}
212
216
  }
213
217
  return jobs;
214
218
  }
215
- /** The `.gitlab-ci.yml` spec for a JS repo — node image, pnpm store cache, pnpm scripts. */
216
- export function gitlabSpec(config) {
219
+ /**
220
+ * The `.gitlab-ci.yml` spec for a JS repo — node image, pnpm store cache, pnpm
221
+ * scripts. `opts.scripts` gates each job on the script it runs exactly as
222
+ * `githubJobs` does (#386): `fix gitlab-ci` renders for a repo that already
223
+ * exists and may have none of them, and a job calling a missing script dies
224
+ * with ERR_PNPM_RECURSIVE_EXEC. Undefined still means "assume the setup shape".
225
+ *
226
+ * Dropping the `build` job takes its `artifacts` block with it; no other job
227
+ * references that artifact, so the pipeline stays coherent without it.
228
+ */
229
+ export function gitlabSpec(config, opts = {}) {
217
230
  const hasTypeScript = config.typescript.enabled;
218
231
  const hasTests = config.testing.framework !== 'none';
219
232
  const hasLint = config.linting.tool !== 'none';
220
233
  const hasBuild = config.bundler !== 'none';
234
+ const test = gitlabTest(config);
221
235
  const jobs = [
222
- ...(hasLint ? [{ id: 'lint', stage: 'test', script: [lintCommand(config)] }] : []),
223
- ...(hasTypeScript ? [{ id: 'typecheck', stage: 'test', script: ['pnpm typecheck'] }] : []),
224
- ...(hasTests ? [{ id: 'test', stage: 'test', script: [gitlabTestCommand(config)] }] : []),
225
- ...(hasBuild
236
+ ...(hasLint && hasScript(opts, lintScript(config))
237
+ ? [{ id: 'lint', stage: 'test', script: [lintCommand(config)] }]
238
+ : []),
239
+ ...(hasTypeScript && hasScript(opts, 'typecheck')
240
+ ? [{ id: 'typecheck', stage: 'test', script: ['pnpm typecheck'] }]
241
+ : []),
242
+ ...(hasTests && (test.script === null || hasScript(opts, test.script))
243
+ ? [{ id: 'test', stage: 'test', script: [test.command] }]
244
+ : []),
245
+ ...(hasBuild && hasScript(opts, 'build')
226
246
  ? [
227
247
  {
228
248
  id: 'build',
@@ -258,10 +278,16 @@ default:
258
278
  jobs,
259
279
  };
260
280
  }
261
- function gitlabTestCommand(config) {
281
+ /**
282
+ * The GitLab test command, paired with the package.json script it needs.
283
+ * Vitest is invoked through `pnpm exec` — a direct binary call, so `script` is
284
+ * null and the job is never gated away; the other frameworks run a script that
285
+ * has to exist.
286
+ */
287
+ function gitlabTest(config) {
262
288
  if (config.testing.framework === 'vitest')
263
- return 'pnpm exec vitest run';
289
+ return { command: 'pnpm exec vitest run', script: null };
264
290
  if (config.testing.framework === 'playwright' || config.testing.framework === 'cypress')
265
- return 'pnpm test:e2e';
266
- return 'pnpm test';
291
+ return { command: 'pnpm test:e2e', script: 'test:e2e' };
292
+ return { command: 'pnpm test', script: 'test' };
267
293
  }
@@ -11,7 +11,7 @@ import { BIOME_CONFIG, generateBiomeConfig, generateESLintConfig, generatePretti
11
11
  import { alignNodeVersion, ensureEnginesNode, ensurePackageManager, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, generateVscodeExtensions, } from '../../cli/generators/misc.js';
12
12
  import { scriptsOf } from './ci.js';
13
13
  import { usesPnpm } from './checks.js';
14
- import { composeVerifyScriptFromPkg, ensureScripts } from '../../cli/generators/package-json.js';
14
+ import { SIZE_LIMIT_SCRIPTS, SIZE_LIMIT_VERSION, composeVerifyScriptFromPkg, ensureScripts, } from '../../cli/generators/package-json.js';
15
15
  /** Kept in step with the biome branch of getScripts() in package-json.ts. */
16
16
  const BIOME_SCRIPTS = {
17
17
  lint: 'biome lint .',
@@ -32,6 +32,24 @@ const PRETTIER_SCRIPTS = {
32
32
  const KNIP_SCRIPTS = {
33
33
  knip: 'knip',
34
34
  };
35
+ /**
36
+ * Add `size-limit` to devDependencies unless the repo already pins it (either
37
+ * dependency list). Returns true when package.json was written.
38
+ */
39
+ async function ensureSizeLimitDependency(targetDir) {
40
+ const pkgPath = path.join(targetDir, 'package.json');
41
+ if (!(await fs.pathExists(pkgPath)))
42
+ return false;
43
+ const pkg = (await fs.readJson(pkgPath));
44
+ const deps = pkg.dependencies ?? {};
45
+ const devDeps = { ...(pkg.devDependencies ?? {}) };
46
+ if (devDeps['size-limit'] || deps['size-limit'])
47
+ return false;
48
+ devDeps['size-limit'] = SIZE_LIMIT_VERSION;
49
+ pkg.devDependencies = devDeps;
50
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
51
+ return true;
52
+ }
35
53
  /** Kept in step with the typescript branch of getScripts() in package-json.ts. */
36
54
  const TSCONFIG_SCRIPTS = {
37
55
  typecheck: 'tsc --noEmit',
@@ -409,7 +427,10 @@ export const FIXERS = [
409
427
  outputs: ['.gitlab-ci.yml'],
410
428
  canFixDrift: true,
411
429
  async run({ targetDir, pkg }) {
412
- const written = await generateGitLabCI(inferProjectConfig(pkg), targetDir);
430
+ // Only reference scripts this repo actually has (#386).
431
+ const written = await generateGitLabCI(inferProjectConfig(pkg), targetDir, {
432
+ scripts: scriptsOf(pkg),
433
+ });
413
434
  return { filesWritten: [written] };
414
435
  },
415
436
  },
@@ -555,13 +576,23 @@ export const FIXERS = [
555
576
  },
556
577
  {
557
578
  target: 'size-limit',
558
- description: 'Scaffold a size-limit budget — exports-driven .size-limit.cjs for multi-subpath libraries, else a static .size-limit.json',
579
+ description: 'Scaffold a size-limit budget — exports-driven .size-limit.cjs for multi-subpath libraries, else a static .size-limit.json — plus the `size-limit` script and devDependency',
559
580
  appliesTo: ['size-limit'],
560
- outputs: ['.size-limit.cjs', '.size-limit.json'],
581
+ outputs: ['.size-limit.cjs', '.size-limit.json', 'package.json (devDependencies + scripts)'],
582
+ // safe-merge: installs the CLI and only fills in a missing script.
583
+ riskLevel: 'safe-merge',
561
584
  canFixDrift: true,
562
585
  async run({ targetDir }) {
563
586
  const written = await generateSizeLimitConfig(targetDir);
564
- return { filesWritten: [written] };
587
+ const filesWritten = [written];
588
+ // A budget with nothing to run it is worse than no budget — the file's
589
+ // presence implies an enforcement that isn't there (#382). The script
590
+ // needs the CLI installed too, or it dies with "command not found".
591
+ const installed = await ensureSizeLimitDependency(targetDir);
592
+ const scripted = await ensureScripts(targetDir, SIZE_LIMIT_SCRIPTS);
593
+ if (installed || scripted)
594
+ filesWritten.push('package.json');
595
+ return { filesWritten };
565
596
  },
566
597
  },
567
598
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.8.4",
3
+ "version": "3.8.6",
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": [
@@ -1,4 +1,7 @@
1
- import type { UserConfig } from 'vitest/config'
1
+ // vitest 4 re-exports vite's UserConfig under the name ViteUserConfig; there is
2
+ // no `UserConfig` export on 'vitest/config'. Generated configs import this
3
+ // preset now, so a wrong name here surfaces in every consumer's `tsc --noEmit`.
4
+ import type { ViteUserConfig } from 'vitest/config'
2
5
 
3
- declare const config: UserConfig
6
+ declare const config: ViteUserConfig
4
7
  export default config
@@ -1,9 +1,10 @@
1
- import { dirname, resolve } from 'node:path'
2
- import { fileURLToPath } from 'node:url'
3
1
  import { configDefaults, defineConfig } from 'vitest/config'
4
2
 
5
- const __dirname = dirname(fileURLToPath(import.meta.url))
6
-
3
+ // No `resolve.alias` here on purpose: this file's `__dirname` is wherever the
4
+ // package got installed, so a `@` → `<preset dir>/src` alias resolves to a
5
+ // directory that exists in no consumer (nor in the published tarball). Now that
6
+ // generated configs actually import this preset, that alias would poison them.
7
+ // Consumers that want `@`/`~` declare them in their own config.
7
8
  export default defineConfig({
8
9
  test: {
9
10
  globals: true,
@@ -13,13 +14,9 @@ export default defineConfig({
13
14
  exclude: [...configDefaults.exclude, '.claude/**'],
14
15
  coverage: {
15
16
  provider: 'v8',
16
- reporter: ['text', 'json', 'html', 'json-summary'],
17
+ // lcov feeds Codecov (the CI we generate uploads it); text is the local
18
+ // console summary; the rest back the HTML report.
19
+ reporter: ['text', 'lcov', 'json', 'html', 'json-summary'],
17
20
  },
18
21
  },
19
- resolve: {
20
- alias: [
21
- { find: '@', replacement: resolve(__dirname, './src') },
22
- { find: '~', replacement: resolve(__dirname, './src') },
23
- ],
24
- },
25
22
  })
@@ -1,4 +1,4 @@
1
- import type { UserConfig } from 'vitest/config'
1
+ import type { ViteUserConfig } from 'vitest/config'
2
2
 
3
- declare const config: UserConfig
3
+ declare const config: ViteUserConfig
4
4
  export default config
@@ -1,11 +1,9 @@
1
1
  import base from '@rtorcato/repo-tooling/vitest/config'
2
2
  import react from '@vitejs/plugin-react'
3
- import { dirname, resolve } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
3
  import { defineConfig, mergeConfig } from 'vitest/config'
6
4
 
7
- const __dirname = dirname(fileURLToPath(import.meta.url))
8
-
5
+ // Same reason as the base preset: an alias built from this file's `__dirname`
6
+ // points inside node_modules, never at the consumer's `src`.
9
7
  export default mergeConfig(
10
8
  base,
11
9
  defineConfig({
@@ -17,11 +15,5 @@ export default mergeConfig(
17
15
  exclude: ['OLD/**'],
18
16
  setupFiles: ['./vitest.setup.ts'],
19
17
  },
20
- resolve: {
21
- alias: [
22
- { find: '@', replacement: resolve(__dirname, './src') },
23
- { find: '~', replacement: resolve(__dirname, './src') },
24
- ],
25
- },
26
18
  })
27
19
  )