@rtorcato/js-tooling 2.9.0 → 2.11.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.
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
- import { getFixTargetForCheck } from './fix-targets.js';
4
+ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
5
+ import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
5
6
  const PACKAGE = '@rtorcato/js-tooling';
6
7
  const NODE_MIN_MAJOR = 22;
7
8
  const NODE_LTS_REQUIREMENTS = {
@@ -231,6 +232,100 @@ async function checkHusky(dir, pkg) {
231
232
  hint: 'Run `pnpm add -D husky && pnpm exec husky init` to enable git hooks',
232
233
  };
233
234
  }
235
+ async function checkHuskyPrePush(dir, pkg) {
236
+ const huskyDir = await fs.pathExists(path.join(dir, '.husky'));
237
+ if (!huskyDir) {
238
+ // If husky isn't in use, pre-push is not relevant
239
+ return {
240
+ check: 'Husky pre-push',
241
+ status: 'optional-missing',
242
+ detail: 'husky not configured',
243
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to enable git hooks (includes pre-push)',
244
+ };
245
+ }
246
+ const hookPath = path.join(dir, '.husky', 'pre-push');
247
+ if (!(await fs.pathExists(hookPath))) {
248
+ return {
249
+ check: 'Husky pre-push',
250
+ status: 'optional-missing',
251
+ detail: 'no .husky/pre-push',
252
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to scaffold a pre-push hook that runs `pnpm verify`',
253
+ };
254
+ }
255
+ const contents = await fs.readFile(hookPath, 'utf-8');
256
+ if (/\bpnpm\s+verify\b/.test(contents)) {
257
+ return {
258
+ check: 'Husky pre-push',
259
+ status: 'ok',
260
+ detail: '.husky/pre-push runs `pnpm verify`',
261
+ };
262
+ }
263
+ // Pre-push exists but doesn't call pnpm verify
264
+ const scripts = pkg?.scripts ?? {};
265
+ if (!scripts.verify) {
266
+ return {
267
+ check: 'Husky pre-push',
268
+ status: 'drift',
269
+ detail: '.husky/pre-push exists but no `verify` script in package.json',
270
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a verify script, then `fix husky` to align the hook',
271
+ };
272
+ }
273
+ return {
274
+ check: 'Husky pre-push',
275
+ status: 'drift',
276
+ detail: '.husky/pre-push exists but does not call `pnpm verify`',
277
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to align the hook with `pnpm verify`',
278
+ };
279
+ }
280
+ async function checkVerifyScript(dir, pkg) {
281
+ if (!pkg) {
282
+ return {
283
+ check: 'verify script',
284
+ status: 'missing',
285
+ detail: 'no package.json',
286
+ };
287
+ }
288
+ const scripts = pkg.scripts ?? {};
289
+ const body = scripts.verify;
290
+ if (!body) {
291
+ return {
292
+ check: 'verify script',
293
+ status: 'optional-missing',
294
+ detail: 'no `verify` script in package.json',
295
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a unified `pnpm verify` script',
296
+ };
297
+ }
298
+ // Lenient: only flag drift when an enabled tool is clearly absent from the script body.
299
+ const deps = {
300
+ ...(pkg.dependencies ?? {}),
301
+ ...(pkg.devDependencies ?? {}),
302
+ };
303
+ const missing = [];
304
+ if (scripts.typecheck && !/\btypecheck\b/.test(body))
305
+ missing.push('typecheck');
306
+ if ((scripts.check || deps['@biomejs/biome']) && !/\b(check|biome|lint)\b/.test(body)) {
307
+ missing.push('lint/check');
308
+ }
309
+ if ((deps.vitest || scripts.test) && !/(vitest|jest|test:e2e|pnpm\s+test)/.test(body)) {
310
+ missing.push('tests');
311
+ }
312
+ const hasTreeshakeApp = await fs.pathExists(path.join(dir, 'apps', 'treeshake-check', 'check.mjs'));
313
+ if (hasTreeshakeApp && !/\btreeshake\b/.test(body))
314
+ missing.push('treeshake');
315
+ if (missing.length > 0) {
316
+ return {
317
+ check: 'verify script',
318
+ status: 'drift',
319
+ detail: `\`verify\` script is missing: ${missing.join(', ')}`,
320
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to regenerate the verify chain',
321
+ };
322
+ }
323
+ return {
324
+ check: 'verify script',
325
+ status: 'ok',
326
+ detail: `\`verify\` = ${body}`,
327
+ };
328
+ }
234
329
  const LINT_STAGED_FILES = [
235
330
  '.lintstagedrc',
236
331
  '.lintstagedrc.json',
@@ -485,6 +580,56 @@ async function checkCodeQL(dir) {
485
580
  hint: 'Run `npx @rtorcato/js-tooling fix codeql` to scaffold CodeQL security scanning',
486
581
  };
487
582
  }
583
+ async function checkTreeshakeSetup(dir, pkg) {
584
+ const appCheckPath = path.join(dir, 'apps', 'treeshake-check', 'check.mjs');
585
+ if (await fs.pathExists(appCheckPath)) {
586
+ return {
587
+ check: 'Tree-shake check',
588
+ status: 'ok',
589
+ detail: 'apps/treeshake-check/check.mjs found',
590
+ };
591
+ }
592
+ // Only nudge libraries that actually claim tree-shaking via multi-subpath exports + sideEffects: false.
593
+ const exports = pkg?.exports ?? {};
594
+ const subpaths = Object.keys(exports).filter((k) => k !== '.' && k.startsWith('./') && !k.includes('*'));
595
+ const sideEffectsFree = pkg?.sideEffects === false;
596
+ if (subpaths.length < 2 || !sideEffectsFree) {
597
+ return {
598
+ check: 'Tree-shake check',
599
+ status: 'ok',
600
+ detail: 'not applicable (single-export or has side effects)',
601
+ };
602
+ }
603
+ return {
604
+ check: 'Tree-shake check',
605
+ status: 'optional-missing',
606
+ detail: `package exports ${subpaths.length} subpaths with sideEffects: false but no apps/treeshake-check/`,
607
+ hint: 'Run `npx @rtorcato/js-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
608
+ };
609
+ }
610
+ function checkLockfile(lock) {
611
+ if (!lock) {
612
+ return {
613
+ check: 'lockfile',
614
+ status: 'optional-missing',
615
+ detail: 'no .js-tooling.json — doctor cannot tell intentional opt-outs from drift',
616
+ hint: 'Run `npx @rtorcato/js-tooling fix lockfile` to record current choices',
617
+ };
618
+ }
619
+ if (lock.version > LOCKFILE_VERSION) {
620
+ return {
621
+ check: 'lockfile',
622
+ status: 'drift',
623
+ detail: `.js-tooling.json version ${lock.version} is newer than this CLI supports (v${LOCKFILE_VERSION})`,
624
+ hint: 'Upgrade @rtorcato/js-tooling to a release that supports this lockfile version',
625
+ };
626
+ }
627
+ return {
628
+ check: 'lockfile',
629
+ status: 'ok',
630
+ detail: `.js-tooling.json v${lock.version} (written by ${lock.writtenBy})`,
631
+ };
632
+ }
488
633
  async function checkGitLabCI(dir) {
489
634
  for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
490
635
  if (await fs.pathExists(path.join(dir, candidate))) {
@@ -505,9 +650,11 @@ async function checkGitLabCI(dir) {
505
650
  export async function runDoctor(dir) {
506
651
  const targetDir = path.resolve(dir);
507
652
  const pkg = await readPackageJson(targetDir);
653
+ const lock = await readLockfile(targetDir);
508
654
  const results = [];
509
655
  results.push(evaluateNodeVersion(process.version));
510
656
  results.push(checkPackageJson(pkg));
657
+ results.push(checkLockfile(lock));
511
658
  results.push(checkEnginesNode(pkg));
512
659
  results.push(await checkEditorConfig(targetDir));
513
660
  results.push(await checkNodeVersionPin(targetDir));
@@ -516,6 +663,8 @@ export async function runDoctor(dir) {
516
663
  }
517
664
  results.push(await checkHusky(targetDir, pkg));
518
665
  results.push(await checkLintStaged(targetDir, pkg));
666
+ results.push(await checkVerifyScript(targetDir, pkg));
667
+ results.push(await checkHuskyPrePush(targetDir, pkg));
519
668
  results.push(await checkSemanticRelease(targetDir, pkg));
520
669
  results.push(await checkKnip(targetDir, pkg));
521
670
  results.push(await checkSizeLimit(targetDir, pkg));
@@ -523,6 +672,22 @@ export async function runDoctor(dir) {
523
672
  results.push(await checkDependabot(targetDir));
524
673
  results.push(await checkCodeQL(targetDir));
525
674
  results.push(await checkGitLabCI(targetDir));
675
+ results.push(await checkTreeshakeSetup(targetDir, pkg));
676
+ // Lockfile-driven demotion: if the lock records an intentional opt-out for a
677
+ // check that's currently optional-missing, demote it to ok with a clear detail.
678
+ if (lock) {
679
+ return results.map((r) => {
680
+ if (r.status !== 'optional-missing')
681
+ return r;
682
+ if (!declinedInLock(lock, r.check))
683
+ return r;
684
+ return {
685
+ check: r.check,
686
+ status: 'ok',
687
+ detail: 'intentionally declined (.js-tooling.json)',
688
+ };
689
+ });
690
+ }
526
691
  return results;
527
692
  }
528
693
  const STATUS_ICONS = {
@@ -11,13 +11,110 @@ export const FIX_TARGETS = {
11
11
  Commitlint: 'commitlint',
12
12
  Husky: 'husky',
13
13
  'lint-staged': 'husky',
14
+ 'Husky pre-push': 'husky',
15
+ 'verify script': 'verify',
14
16
  'semantic-release': 'semantic-release',
15
17
  knip: 'knip',
16
18
  'size-limit': 'size-limit',
19
+ 'Tree-shake check': 'treeshake-check',
17
20
  'GitHub Actions': 'github-actions',
18
21
  Dependabot: 'dependabot',
19
22
  CodeQL: 'codeql',
23
+ lockfile: 'lockfile',
24
+ '.js-tooling.json': 'lockfile',
20
25
  };
21
26
  export function getFixTargetForCheck(checkName) {
22
27
  return FIX_TARGETS[checkName] ?? null;
23
28
  }
29
+ /**
30
+ * For a given doctor check name, returns true when the lockfile records that
31
+ * the user intentionally opted out of the tool that check covers. Used by
32
+ * doctor to demote `optional-missing` to `ok` and by fix to print a conflict
33
+ * warning before overriding the recorded choice.
34
+ */
35
+ export function declinedInLock(lock, checkName) {
36
+ if (!lock)
37
+ return false;
38
+ const c = lock.config;
39
+ switch (checkName) {
40
+ case 'TypeScript':
41
+ return c.typescript?.enabled === false;
42
+ case 'Biome':
43
+ return c.linting?.tool !== 'biome' && c.linting?.tool !== 'both';
44
+ case 'ESLint':
45
+ return c.linting?.tool !== 'eslint' && c.linting?.tool !== 'both';
46
+ case 'Prettier':
47
+ return c.formatting?.tool !== 'prettier';
48
+ case 'Vitest':
49
+ return c.testing?.framework !== 'vitest';
50
+ case 'Commitlint':
51
+ return c.commitLint === false;
52
+ case 'Husky':
53
+ case 'lint-staged':
54
+ case 'Husky pre-push':
55
+ return c.gitHooks === false;
56
+ case 'verify script':
57
+ // Verify is derived from other tools; only "declined" if none of typecheck/lint/test are enabled.
58
+ return (c.typescript?.enabled === false &&
59
+ c.linting?.tool === 'none' &&
60
+ c.testing?.framework === 'none');
61
+ case 'semantic-release':
62
+ return c.semanticRelease === false;
63
+ case 'Dependabot':
64
+ case 'CodeQL':
65
+ return c.securityAutomation === false;
66
+ default:
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * When a fixer is about to scaffold a tool, return the patch to apply to the
72
+ * lockfile's recorded choices so intent stays in sync with reality. Returns
73
+ * null when the target either doesn't change any recorded choice (e.g. the
74
+ * `verify` fixer is derived, or `engines` writes a universal field) or when
75
+ * the lockfile already reflects the change.
76
+ */
77
+ export function lockfilePatchForTarget(target, lock) {
78
+ const c = lock.config;
79
+ switch (target) {
80
+ case 'biome':
81
+ if (c.linting.tool === 'biome' || c.linting.tool === 'both')
82
+ return null;
83
+ return {
84
+ linting: { tool: 'biome' },
85
+ formatting: { tool: 'biome' },
86
+ };
87
+ case 'eslint':
88
+ if (c.linting.tool === 'eslint' || c.linting.tool === 'both')
89
+ return null;
90
+ return {
91
+ linting: { tool: 'eslint', eslintConfig: c.linting.eslintConfig ?? 'base' },
92
+ formatting: { tool: 'prettier' },
93
+ };
94
+ case 'prettier':
95
+ if (c.formatting.tool === 'prettier')
96
+ return null;
97
+ return { formatting: { tool: 'prettier' } };
98
+ case 'vitest':
99
+ if (c.testing.framework === 'vitest')
100
+ return null;
101
+ return {
102
+ testing: { framework: 'vitest', environment: c.testing.environment ?? 'node' },
103
+ };
104
+ case 'commitlint':
105
+ return c.commitLint ? null : { commitLint: true };
106
+ case 'husky':
107
+ return c.gitHooks ? null : { gitHooks: true };
108
+ case 'semantic-release':
109
+ return c.semanticRelease ? null : { semanticRelease: true };
110
+ case 'dependabot':
111
+ case 'codeql':
112
+ return c.securityAutomation ? null : { securityAutomation: true };
113
+ case 'tsconfig':
114
+ return c.typescript.enabled ? null : { typescript: { enabled: true, config: 'base' } };
115
+ case 'treeshake-check':
116
+ return c.treeshakeCheck ? null : { treeshakeCheck: true };
117
+ default:
118
+ return null;
119
+ }
120
+ }
@@ -3,14 +3,18 @@ import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import inquirer from 'inquirer';
5
5
  import { generateSemanticReleaseConfig } from '../generators/build.js';
6
- import { generateCommitlintConfig, generateHuskyConfig } from '../generators/git.js';
6
+ import { generateCommitlintConfig, generateHuskyConfig, generatePrePushHook, } from '../generators/git.js';
7
7
  import { generateGitHubActions } from '../generators/github-actions.js';
8
8
  import { generateESLintConfig, generatePrettierConfig } from '../generators/linting.js';
9
9
  import { ensureEnginesNode, generateEditorConfig, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, } from '../generators/misc.js';
10
+ import { composeVerifyScriptFromPkg } from '../generators/package-json.js';
10
11
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
11
12
  import { generateVitestConfig } from '../generators/testing.js';
13
+ import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
12
14
  import { copyPreset } from '../utils/copy-preset.js';
15
+ import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } from '../utils/lockfile.js';
13
16
  import { runDoctor } from './doctor.js';
17
+ import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
14
18
  function inferProjectConfig(pkg) {
15
19
  const deps = {
16
20
  ...(pkg?.dependencies ?? {}),
@@ -124,9 +128,9 @@ const FIXERS = [
124
128
  },
125
129
  {
126
130
  target: 'husky',
127
- description: 'Set up Husky + lint-staged',
128
- appliesTo: ['Husky', 'lint-staged'],
129
- outputs: ['.husky/pre-commit', 'package.json (lint-staged field)'],
131
+ description: 'Set up Husky + lint-staged (and a `pnpm verify` pre-push hook)',
132
+ appliesTo: ['Husky', 'lint-staged', 'Husky pre-push'],
133
+ outputs: ['.husky/pre-commit', '.husky/pre-push', 'package.json (lint-staged field)'],
130
134
  riskLevel: 'safe-merge',
131
135
  canFixDrift: true,
132
136
  async run({ targetDir, pkg }) {
@@ -137,7 +141,46 @@ const FIXERS = [
137
141
  const generated = updated['lint-staged'] ?? {};
138
142
  updated['lint-staged'] = { ...generated, ...existingLintStaged };
139
143
  await fs.writeJson(pkgPath, updated, { spaces: 2 });
140
- return { filesWritten: ['.husky/pre-commit', 'package.json'] };
144
+ const filesWritten = ['.husky/pre-commit', 'package.json'];
145
+ const scripts = updated.scripts ?? {};
146
+ if (scripts.verify) {
147
+ await generatePrePushHook(targetDir);
148
+ filesWritten.push('.husky/pre-push');
149
+ }
150
+ return { filesWritten };
151
+ },
152
+ },
153
+ {
154
+ target: 'verify',
155
+ description: 'Add a unified `verify` script (typecheck && lint && tests) to package.json',
156
+ appliesTo: ['verify script'],
157
+ outputs: ['package.json (scripts.verify)'],
158
+ riskLevel: 'safe-merge',
159
+ canFixDrift: true,
160
+ async run({ targetDir, pkg }) {
161
+ const pkgPath = path.join(targetDir, 'package.json');
162
+ if (!pkg) {
163
+ console.log(chalk.yellow(' no package.json found — skipping'));
164
+ return { filesWritten: [] };
165
+ }
166
+ const includeTreeshake = await fs.pathExists(path.join(targetDir, 'apps', 'treeshake-check', 'check.mjs'));
167
+ const verify = composeVerifyScriptFromPkg(pkg, { includeTreeshake });
168
+ if (!verify) {
169
+ console.log(chalk.gray(' not enough tools enabled to compose a verify chain — skipping (need 2+ of typecheck/lint/tests)'));
170
+ return { filesWritten: [] };
171
+ }
172
+ const updated = { ...pkg };
173
+ const scripts = { ...(updated.scripts ?? {}) };
174
+ scripts.verify = verify;
175
+ if (includeTreeshake && !scripts.treeshake) {
176
+ scripts.treeshake = 'pnpm --filter=*treeshake-check run check';
177
+ if (!scripts.pretreeshake) {
178
+ scripts.pretreeshake = scripts.build ? 'pnpm build' : 'echo "no build step"';
179
+ }
180
+ }
181
+ updated.scripts = scripts;
182
+ await fs.writeJson(pkgPath, updated, { spaces: 2 });
183
+ return { filesWritten: ['package.json'] };
141
184
  },
142
185
  },
143
186
  {
@@ -242,6 +285,43 @@ const FIXERS = [
242
285
  return { filesWritten: ['.size-limit.json'] };
243
286
  },
244
287
  },
288
+ {
289
+ target: 'treeshake-check',
290
+ description: 'Scaffold apps/treeshake-check — esbuild + metafile assertion that one subpath bundles cleanly',
291
+ appliesTo: ['Tree-shake check'],
292
+ outputs: [
293
+ 'apps/treeshake-check/package.json',
294
+ 'apps/treeshake-check/check.mjs',
295
+ 'apps/treeshake-check/src/entry.ts',
296
+ ],
297
+ riskLevel: 'safe-add',
298
+ canFixDrift: false,
299
+ async run({ targetDir, pkg }) {
300
+ if (!pkg) {
301
+ console.log(chalk.yellow(' no package.json found — skipping'));
302
+ return { filesWritten: [] };
303
+ }
304
+ const workspaceName = pkg.name ?? null;
305
+ if (!workspaceName) {
306
+ console.log(chalk.yellow(' package.json has no `name` — skipping'));
307
+ return { filesWritten: [] };
308
+ }
309
+ const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
310
+ if (allCandidates.length < 2 || !defaultAllowed) {
311
+ console.log(chalk.yellow(' package.json does not expose ≥2 subpath exports — tree-shake check needs multiple subpaths to be meaningful. Skipping.'));
312
+ return { filesWritten: [] };
313
+ }
314
+ const allowedSubpath = defaultAllowed;
315
+ const forbiddenSubpaths = allCandidates.filter((s) => s !== allowedSubpath);
316
+ const written = await generateTreeshakeCheck(targetDir, {
317
+ workspaceName,
318
+ allowedSubpath,
319
+ forbiddenSubpaths,
320
+ });
321
+ console.log(chalk.dim(` Wired '${allowedSubpath}' as allowed; forbidden = [${forbiddenSubpaths.join(', ')}]. Edit apps/treeshake-check/check.mjs to tune.`));
322
+ return { filesWritten: written };
323
+ },
324
+ },
245
325
  {
246
326
  target: 'package-json',
247
327
  description: 'Add @rtorcato/js-tooling to devDependencies',
@@ -266,6 +346,23 @@ const FIXERS = [
266
346
  return { filesWritten: ['package.json'] };
267
347
  },
268
348
  },
349
+ {
350
+ target: 'lockfile',
351
+ description: `Scaffold ${LOCKFILE_NAME} recording current tool choices`,
352
+ appliesTo: ['lockfile'],
353
+ outputs: [LOCKFILE_NAME],
354
+ riskLevel: 'safe-add',
355
+ canFixDrift: false,
356
+ async run({ targetDir, pkg }) {
357
+ if (!pkg) {
358
+ console.log(chalk.yellow(' no package.json found — skipping'));
359
+ return { filesWritten: [] };
360
+ }
361
+ const config = inferProjectConfig(pkg);
362
+ await writeLockfile(targetDir, config);
363
+ return { filesWritten: [LOCKFILE_NAME] };
364
+ },
365
+ },
269
366
  ];
270
367
  export function getFixers() {
271
368
  return FIXERS;
@@ -283,17 +380,27 @@ function logTargets() {
283
380
  console.log(` ${chalk.green('●')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
284
381
  }
285
382
  }
286
- async function applyFixer(fixer, result, targetDir, pkg, dryRun, silent) {
383
+ async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
287
384
  if (dryRun) {
288
385
  if (!silent) {
289
386
  console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
290
387
  }
291
388
  return { filesWritten: [], dryRun: true };
292
389
  }
293
- const { filesWritten } = await fixer.run({ targetDir, pkg, result });
390
+ const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock });
294
391
  if (!silent && filesWritten.length > 0) {
295
392
  console.log(chalk.green(` ✅ wrote ${filesWritten.join(', ')}`));
296
393
  }
394
+ // Auto-resync the lockfile when a fix changes a recorded choice.
395
+ if (lock && fixer.target !== 'lockfile') {
396
+ const patch = lockfilePatchForTarget(fixer.target, lock);
397
+ if (patch) {
398
+ const ok = await updateLockfileConfig(targetDir, patch);
399
+ if (ok && !silent) {
400
+ console.log(chalk.dim(` ↻ ${LOCKFILE_NAME} updated to reflect the new choice`));
401
+ }
402
+ }
403
+ }
297
404
  return { filesWritten, dryRun: false };
298
405
  }
299
406
  function promptMessageFor(fixer, result) {
@@ -322,8 +429,11 @@ async function confirmApply(fixer, result, assumeYes) {
322
429
  ]);
323
430
  return confirm === true;
324
431
  }
325
- function recordFor(target, check, doctorStatus, status, filesWritten) {
326
- return { target, check, status, doctorStatus, filesWritten };
432
+ function recordFor(target, check, doctorStatus, status, filesWritten, lockfileConflict = false) {
433
+ const base = { target, check, status, doctorStatus, filesWritten };
434
+ if (lockfileConflict)
435
+ base.lockfileConflict = true;
436
+ return base;
327
437
  }
328
438
  export async function fixCommand(target, options = {}) {
329
439
  const targetDir = path.resolve(options.directory ?? process.cwd());
@@ -333,8 +443,18 @@ export async function fixCommand(target, options = {}) {
333
443
  const assumeYes = options.yes === true || json;
334
444
  const silent = json;
335
445
  const pkg = await readPackageJson(targetDir);
446
+ const lock = await readLockfile(targetDir);
336
447
  const results = await runDoctor(targetDir);
337
448
  const actions = [];
449
+ const noteLockConflict = (check) => {
450
+ if (!lock)
451
+ return false;
452
+ const conflict = declinedInLock(lock, check);
453
+ if (conflict && !silent) {
454
+ console.log(chalk.yellow(` ⚠ ${LOCKFILE_NAME} says this tool was declined — applying anyway will update the lockfile to reflect the new choice.`));
455
+ }
456
+ return conflict;
457
+ };
338
458
  const emitJson = (resolvedTarget) => {
339
459
  const payload = { directory: targetDir, target: resolvedTarget, actions };
340
460
  console.log(JSON.stringify(payload, null, 2));
@@ -358,7 +478,12 @@ export async function fixCommand(target, options = {}) {
358
478
  }
359
479
  const result = results.find((r) => fixer.appliesTo.includes(r.check)) ??
360
480
  { check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
361
- if (result.status === 'ok') {
481
+ // A check that's `ok` because the lockfile records an opt-out should still be
482
+ // fixable when the user explicitly targets it — treat it as optional-missing
483
+ // so the override + lockfile resync paths run.
484
+ const lockfileDemoted = lock !== null && declinedInLock(lock, result.check);
485
+ const effectiveResult = result.status === 'ok' && lockfileDemoted ? { ...result, status: 'optional-missing' } : result;
486
+ if (effectiveResult.status === 'ok') {
362
487
  actions.push(recordFor(fixer.target, result.check, 'ok', 'already-ok', []));
363
488
  if (json)
364
489
  return emitJson(fixer.target);
@@ -366,18 +491,19 @@ export async function fixCommand(target, options = {}) {
366
491
  return;
367
492
  }
368
493
  if (!silent) {
369
- console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${result.status}\n`));
494
+ console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
370
495
  }
371
- const ok = await confirmApply(fixer, result, assumeYes);
496
+ const conflict = noteLockConflict(result.check);
497
+ const ok = await confirmApply(fixer, effectiveResult, assumeYes);
372
498
  if (!ok) {
373
- actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
499
+ actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
374
500
  if (json)
375
501
  return emitJson(fixer.target);
376
502
  console.log(chalk.gray(' skipped\n'));
377
503
  return;
378
504
  }
379
- const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
380
- actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
505
+ const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent);
506
+ actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
381
507
  if (json)
382
508
  return emitJson(fixer.target);
383
509
  console.log();
@@ -408,16 +534,17 @@ export async function fixCommand(target, options = {}) {
408
534
  if (!silent) {
409
535
  console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
410
536
  }
537
+ const conflict = noteLockConflict(result.check);
411
538
  const ok = await confirmApply(fixer, result, assumeYes);
412
539
  if (!ok) {
413
- actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
540
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
414
541
  if (!silent)
415
542
  console.log(chalk.gray(' skipped'));
416
543
  skippedCount++;
417
544
  continue;
418
545
  }
419
- const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
420
- actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten));
546
+ const outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent);
547
+ actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
421
548
  appliedCount++;
422
549
  }
423
550
  if (json)
@@ -127,9 +127,13 @@ export const CONFIG_SCHEMA = {
127
127
  semanticRelease: { type: 'boolean' },
128
128
  securityAutomation: { type: 'boolean' },
129
129
  bundler: { type: 'string', enum: ['tsup', 'esbuild', 'vite', 'none'] },
130
+ treeshakeCheck: { type: 'boolean' },
130
131
  },
131
132
  };
132
- const ALLOWED_KEYS = new Set(CONFIG_SCHEMA.required);
133
+ const ALLOWED_KEYS = new Set([
134
+ ...CONFIG_SCHEMA.required,
135
+ ...Object.keys(CONFIG_SCHEMA.properties),
136
+ ]);
133
137
  export function validateProjectConfig(input) {
134
138
  const errors = [];
135
139
  if (typeof input !== 'object' || input === null || Array.isArray(input)) {
@@ -147,7 +151,7 @@ export function validateProjectConfig(input) {
147
151
  return { valid: errors.length === 0, errors };
148
152
  }
149
153
  export function computeFileList(config) {
150
- const files = ['package.json'];
154
+ const files = ['package.json', '.js-tooling.json'];
151
155
  files.push('.editorconfig', '.nvmrc', 'knip.json');
152
156
  if (config.typescript.enabled) {
153
157
  files.push('tsconfig.json', 'reset.d.ts');
@@ -188,6 +192,9 @@ export function computeFileList(config) {
188
192
  files.push('vite.config.ts');
189
193
  if (config.semanticRelease)
190
194
  files.push('release.config.mjs');
195
+ if (config.treeshakeCheck && config.projectType === 'library') {
196
+ files.push('apps/treeshake-check/package.json', 'apps/treeshake-check/check.mjs', 'apps/treeshake-check/src/entry.ts');
197
+ }
191
198
  files.push('README.md');
192
199
  return files;
193
200
  }
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
4
4
  import inquirer from 'inquirer';
5
5
  import { generateConfigs } from '../generators/index.js';
6
6
  import { installDependencies } from '../utils/install.js';
7
+ import { LOCKFILE_NAME, writeLockfile } from '../utils/lockfile.js';
7
8
  import { buildPresetConfig, computeFileList, CONFIG_SCHEMA, PRESET_NAMES, validateProjectConfig, } from './setup-presets.js';
8
9
  async function resolveConfig(options) {
9
10
  if (options.config && options.preset) {
@@ -55,6 +56,7 @@ export async function setupProject(options) {
55
56
  }
56
57
  console.log(chalk.cyan('\n📝 Generating configuration files...\n'));
57
58
  await generateConfigs(config, targetDir);
59
+ await writeLockfile(targetDir, config);
58
60
  if (!options.skipInstall) {
59
61
  console.log(chalk.cyan('\n📦 Installing dependencies...\n'));
60
62
  await installDependencies(config, targetDir);
@@ -188,6 +190,13 @@ async function promptForConfig() {
188
190
  default: (answers) => answers.projectType === 'library',
189
191
  when: (answers) => answers.projectType === 'library',
190
192
  },
193
+ {
194
+ type: 'confirm',
195
+ name: 'treeshakeCheck',
196
+ message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
197
+ default: false,
198
+ when: (answers) => answers.projectType === 'library',
199
+ },
191
200
  {
192
201
  type: 'confirm',
193
202
  name: 'securityAutomation',
@@ -239,6 +248,7 @@ async function promptForConfig() {
239
248
  semanticRelease: answers.semanticRelease || false,
240
249
  securityAutomation: answers.securityAutomation ?? false,
241
250
  bundler: answers.bundler || 'none',
251
+ treeshakeCheck: answers.treeshakeCheck || false,
242
252
  };
243
253
  }
244
254
  function showNextSteps(config, _targetDir) {
@@ -256,6 +266,7 @@ function showNextSteps(config, _targetDir) {
256
266
  if (config.gitHooks) {
257
267
  steps.push('🪝 Commit your changes to test the git hooks');
258
268
  }
269
+ steps.push(`🔒 ${LOCKFILE_NAME} records your setup choices — doctor uses it to suppress intentional opt-outs`);
259
270
  steps.push('📖 Check the generated README.md for more details');
260
271
  steps.forEach((step, index) => {
261
272
  console.log(` ${index + 1}. ${step}`);
@@ -290,6 +301,9 @@ function collectSkippedFixSuggestions(config) {
290
301
  if (config.testing.framework === 'none') {
291
302
  suggestions.push('Run `npx @rtorcato/js-tooling fix vitest` to add a test runner');
292
303
  }
304
+ if (config.projectType === 'library' && !config.treeshakeCheck) {
305
+ suggestions.push('Run `npx @rtorcato/js-tooling fix treeshake-check` to add an esbuild-based tree-shake assertion');
306
+ }
293
307
  suggestions.push('Run `npx @rtorcato/js-tooling doctor` any time to audit drift');
294
308
  return suggestions;
295
309
  }
@@ -1,5 +1,14 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
+ export const PRE_PUSH_HOOK_CONTENT = `echo "🔍 Running pre-push verify..."
4
+ pnpm verify
5
+ STATUS=$?
6
+ if [ $STATUS -ne 0 ]; then
7
+ echo "❌ Verify failed — push aborted."
8
+ exit 1
9
+ fi
10
+ echo "✅ Verify passed — pushing."
11
+ `;
3
12
  export async function generateGitConfigs(config, targetDir) {
4
13
  if (config.gitHooks) {
5
14
  await generateHuskyConfig(config, targetDir);
@@ -22,6 +31,18 @@ npx lint-staged
22
31
  `;
23
32
  await fs.writeFile(preCommitPath, preCommitContent);
24
33
  await fs.chmod(preCommitPath, 0o755);
34
+ // Pre-push hook — only when the package.json already has a `verify` script.
35
+ // In the setup flow, generatePackageJson runs before this and writes verify
36
+ // when 2+ tools are enabled. In the `fix husky` path, a pre-existing verify
37
+ // script is what unlocks the hook.
38
+ const pkgPath = path.join(targetDir, 'package.json');
39
+ if (await fs.pathExists(pkgPath)) {
40
+ const pkg = (await fs.readJson(pkgPath));
41
+ const scripts = pkg.scripts ?? {};
42
+ if (scripts.verify) {
43
+ await generatePrePushHook(targetDir);
44
+ }
45
+ }
25
46
  // Commit-msg hook (if commitlint is enabled)
26
47
  if (config.commitLint) {
27
48
  const commitMsgPath = path.join(huskyDir, 'commit-msg');
@@ -52,6 +73,13 @@ npx --no -- commitlint --edit $1
52
73
  };
53
74
  await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
54
75
  }
76
+ export async function generatePrePushHook(targetDir) {
77
+ const huskyDir = path.join(targetDir, '.husky');
78
+ await fs.ensureDir(huskyDir);
79
+ const prePushPath = path.join(huskyDir, 'pre-push');
80
+ await fs.writeFile(prePushPath, PRE_PUSH_HOOK_CONTENT);
81
+ await fs.chmod(prePushPath, 0o755);
82
+ }
55
83
  export async function generateCommitlintConfig(targetDir) {
56
84
  const commitlintConfigPath = path.join(targetDir, 'commitlint.config.mjs');
57
85
  const commitlintConfig = `export { default } from '@rtorcato/js-tooling/commitlint/config'
@@ -10,6 +10,7 @@ import { generatePackageJson } from './package-json.js';
10
10
  import { generateReadme } from './readme.js';
11
11
  import { generateSecurityConfigs } from './security.js';
12
12
  import { generateTestingConfigs } from './testing.js';
13
+ import { generateTreeshakeCheck, inferSubpathsFromExports } from './treeshake.js';
13
14
  import { generateTSConfig } from './tsconfig.js';
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = path.dirname(__filename);
@@ -45,6 +46,19 @@ export async function generateConfigs(config, targetDir) {
45
46
  if (config.bundler !== 'none') {
46
47
  await generateBuildConfigs(config, targetDir);
47
48
  }
49
+ // Tree-shake verification check (libraries only, when opted-in)
50
+ if (config.treeshakeCheck && config.projectType === 'library') {
51
+ const pkgPath = path.join(targetDir, 'package.json');
52
+ const pkg = (await fs.readJson(pkgPath));
53
+ const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
54
+ if (defaultAllowed && allCandidates.length >= 2) {
55
+ await generateTreeshakeCheck(targetDir, {
56
+ workspaceName: config.projectName,
57
+ allowedSubpath: defaultAllowed,
58
+ forbiddenSubpaths: allCandidates.filter((s) => s !== defaultAllowed),
59
+ });
60
+ }
61
+ }
48
62
  // Generate README
49
63
  await generateReadme(config, targetDir);
50
64
  // Copy ts-reset if TypeScript is enabled
@@ -6,6 +6,7 @@ export async function generatePackageJson(config, targetDir) {
6
6
  if (await fs.pathExists(packageJsonPath)) {
7
7
  existingPackageJson = await fs.readJson(packageJsonPath);
8
8
  }
9
+ const includeTreeshake = Boolean(config.treeshakeCheck && config.projectType === 'library');
9
10
  const packageJson = {
10
11
  name: config.projectName,
11
12
  version: '0.1.0',
@@ -13,7 +14,7 @@ export async function generatePackageJson(config, targetDir) {
13
14
  type: 'module',
14
15
  ...existingPackageJson,
15
16
  scripts: {
16
- ...getScripts(config),
17
+ ...getScripts(config, { includeTreeshake }),
17
18
  ...existingPackageJson?.scripts,
18
19
  },
19
20
  dependencies: {
@@ -44,7 +45,7 @@ export async function generatePackageJson(config, targetDir) {
44
45
  }
45
46
  await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
46
47
  }
47
- function getScripts(config) {
48
+ function getScripts(config, opts = {}) {
48
49
  const scripts = {};
49
50
  // TypeScript scripts
50
51
  if (config.typescript.enabled) {
@@ -99,8 +100,67 @@ function getScripts(config) {
99
100
  if (config.semanticRelease) {
100
101
  scripts['release'] = 'semantic-release';
101
102
  }
103
+ if (opts.includeTreeshake) {
104
+ scripts['pretreeshake'] = scripts['build'] ? 'pnpm build' : 'echo "no build step"';
105
+ scripts['treeshake'] = 'pnpm --filter=*treeshake-check run check';
106
+ }
107
+ const verify = composeVerifyScript(config, opts);
108
+ if (verify) {
109
+ scripts['verify'] = verify;
110
+ }
102
111
  return scripts;
103
112
  }
113
+ export function composeVerifyScript(config, opts = {}) {
114
+ const cmds = [];
115
+ if (config.typescript.enabled)
116
+ cmds.push('pnpm typecheck');
117
+ if (config.linting.tool === 'biome' || config.linting.tool === 'both') {
118
+ cmds.push('pnpm check');
119
+ }
120
+ else if (config.linting.tool === 'eslint') {
121
+ cmds.push('pnpm lint');
122
+ }
123
+ if (config.testing.framework === 'vitest') {
124
+ cmds.push('pnpm exec vitest run');
125
+ }
126
+ else if (config.testing.framework === 'jest') {
127
+ cmds.push('pnpm test --ci');
128
+ }
129
+ else if (config.testing.framework === 'playwright') {
130
+ cmds.push('pnpm test:e2e');
131
+ }
132
+ if (opts.includeTreeshake)
133
+ cmds.push('pnpm treeshake');
134
+ return cmds.length >= 2 ? cmds.join(' && ') : null;
135
+ }
136
+ /**
137
+ * Derive the verify chain from a real package.json's scripts + deps.
138
+ * Used by `fix verify`, where we shouldn't assume tools beyond what the
139
+ * project actually has.
140
+ */
141
+ export function composeVerifyScriptFromPkg(pkg, opts = {}) {
142
+ const scripts = pkg.scripts ?? {};
143
+ const deps = {
144
+ ...(pkg.dependencies ?? {}),
145
+ ...(pkg.devDependencies ?? {}),
146
+ };
147
+ const cmds = [];
148
+ if (scripts.typecheck || deps.typescript)
149
+ cmds.push('pnpm typecheck');
150
+ if (scripts.check)
151
+ cmds.push('pnpm check');
152
+ else if (scripts.lint && !scripts.check)
153
+ cmds.push('pnpm lint');
154
+ if (deps.vitest)
155
+ cmds.push('pnpm exec vitest run');
156
+ else if (deps.jest)
157
+ cmds.push('pnpm test --ci');
158
+ else if (deps['@playwright/test'])
159
+ cmds.push('pnpm test:e2e');
160
+ if (opts.includeTreeshake)
161
+ cmds.push('pnpm treeshake');
162
+ return cmds.length >= 2 ? cmds.join(' && ') : null;
163
+ }
104
164
  function getDependencies(config) {
105
165
  const deps = {};
106
166
  // TypeScript
@@ -0,0 +1,113 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ /**
4
+ * Inspect package.exports and return non-root subpath candidates (e.g. './foo' → 'foo').
5
+ * Skips '.', types-only entries, and wildcard patterns ('./*').
6
+ */
7
+ export function inferSubpathsFromExports(pkg) {
8
+ const exports = pkg?.exports;
9
+ if (!exports || typeof exports !== 'object') {
10
+ return { allCandidates: [], defaultAllowed: null };
11
+ }
12
+ const candidates = [];
13
+ for (const key of Object.keys(exports)) {
14
+ if (key === '.' || !key.startsWith('./'))
15
+ continue;
16
+ if (key.includes('*'))
17
+ continue;
18
+ const trimmed = key.replace(/^\.\//, '');
19
+ if (!trimmed)
20
+ continue;
21
+ candidates.push(trimmed);
22
+ }
23
+ return {
24
+ allCandidates: candidates,
25
+ defaultAllowed: candidates[0] ?? null,
26
+ };
27
+ }
28
+ function renderCheckScript(opts) {
29
+ const allowed = JSON.stringify([opts.allowedSubpath]);
30
+ const forbidden = JSON.stringify(opts.forbiddenSubpaths, null, '\t');
31
+ return `import { build } from 'esbuild'
32
+
33
+ const ALLOWED_MODULES = ${allowed}
34
+
35
+ const FORBIDDEN_MODULES = ${forbidden}
36
+
37
+ const result = await build({
38
+ \tentryPoints: ['src/entry.ts'],
39
+ \tbundle: true,
40
+ \tformat: 'esm',
41
+ \tplatform: 'browser',
42
+ \tconditions: ['import', 'browser'],
43
+ \twrite: false,
44
+ \tmetafile: true,
45
+ \tminify: false,
46
+ })
47
+
48
+ const inputs = Object.keys(result.metafile.inputs)
49
+ const distInputs = inputs.filter((p) => p.includes('/dist/'))
50
+ const leaks = distInputs.filter((p) =>
51
+ \tFORBIDDEN_MODULES.some((m) => new RegExp(\`/dist/\${m}/\`).test(p))
52
+ )
53
+ const allowed = distInputs.filter((p) =>
54
+ \tALLOWED_MODULES.some((m) => new RegExp(\`/dist/\${m}/\`).test(p))
55
+ )
56
+
57
+ const bundleBytes = result.outputFiles?.[0]?.contents.byteLength ?? 0
58
+
59
+ console.log(\`Bundle size: \${bundleBytes} bytes\`)
60
+ console.log(\`dist inputs in bundle (\${distInputs.length}):\`)
61
+ for (const p of distInputs) console.log(\` \${p}\`)
62
+
63
+ if (leaks.length > 0) {
64
+ \tconsole.error('\\n❌ Tree-shaking leak — forbidden modules in the bundle:')
65
+ \tfor (const p of leaks) console.error(\` \${p}\`)
66
+ \tprocess.exit(1)
67
+ }
68
+
69
+ if (allowed.length === 0) {
70
+ \tconsole.error(
71
+ \t\t'\\n❌ Expected at least one allowed-subpath input — entry may have failed to resolve.'
72
+ \t)
73
+ \tprocess.exit(1)
74
+ }
75
+
76
+ console.log('\\n✅ Tree-shaking OK — only allowed inputs present.')
77
+ `;
78
+ }
79
+ function renderEntry(workspaceName, allowedSubpath) {
80
+ return `export * from '${workspaceName}/${allowedSubpath}'\n`;
81
+ }
82
+ export async function generateTreeshakeCheck(targetDir, opts) {
83
+ const appDir = opts.appDir ?? 'apps/treeshake-check';
84
+ const root = path.join(targetDir, appDir);
85
+ await fs.ensureDir(path.join(root, 'src'));
86
+ const pkgName = `${opts.workspaceName.replace(/^@/, '').replace(/\//g, '-')}-treeshake-check`;
87
+ const packageJson = {
88
+ name: pkgName,
89
+ version: '0.0.0',
90
+ private: true,
91
+ type: 'module',
92
+ scripts: {
93
+ check: 'node check.mjs',
94
+ },
95
+ dependencies: {
96
+ [opts.workspaceName]: 'workspace:*',
97
+ },
98
+ devDependencies: {
99
+ esbuild: '^0.28.0',
100
+ },
101
+ };
102
+ const checkPath = path.join(root, 'check.mjs');
103
+ const entryPath = path.join(root, 'src', 'entry.ts');
104
+ const pkgPath = path.join(root, 'package.json');
105
+ await fs.writeJson(pkgPath, packageJson, { spaces: 2 });
106
+ await fs.writeFile(checkPath, renderCheckScript(opts));
107
+ await fs.writeFile(entryPath, renderEntry(opts.workspaceName, opts.allowedSubpath));
108
+ return [
109
+ path.join(appDir, 'package.json'),
110
+ path.join(appDir, 'check.mjs'),
111
+ path.join(appDir, 'src', 'entry.ts'),
112
+ ];
113
+ }
@@ -0,0 +1,54 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import packageJson from '../../../package.json' with { type: 'json' };
4
+ import { validateProjectConfig } from '../commands/setup-presets.js';
5
+ export const LOCKFILE_NAME = '.js-tooling.json';
6
+ export const LOCKFILE_VERSION = 1;
7
+ const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/js-tooling/schemas/lockfile.json';
8
+ export async function readLockfile(dir) {
9
+ const filepath = path.join(dir, LOCKFILE_NAME);
10
+ if (!(await fs.pathExists(filepath)))
11
+ return null;
12
+ try {
13
+ const raw = (await fs.readJson(filepath));
14
+ if (typeof raw !== 'object' || raw === null)
15
+ return null;
16
+ const obj = raw;
17
+ if (typeof obj.version !== 'number')
18
+ return null;
19
+ if (typeof obj.config !== 'object' || obj.config === null)
20
+ return null;
21
+ return obj;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export async function writeLockfile(dir, config) {
28
+ const { valid, errors } = validateProjectConfig(config);
29
+ if (!valid) {
30
+ throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
31
+ }
32
+ const filepath = path.join(dir, LOCKFILE_NAME);
33
+ const lockfile = {
34
+ $schema: LOCKFILE_SCHEMA_URL,
35
+ version: LOCKFILE_VERSION,
36
+ config,
37
+ writtenBy: `@rtorcato/js-tooling@${packageJson.version}`,
38
+ writtenAt: new Date().toISOString(),
39
+ };
40
+ await fs.writeJson(filepath, lockfile, { spaces: 2 });
41
+ return filepath;
42
+ }
43
+ /**
44
+ * Patch a subset of a lockfile's config in place, preserving everything else.
45
+ * Returns true when the file was rewritten, false when no lockfile exists.
46
+ */
47
+ export async function updateLockfileConfig(dir, patch) {
48
+ const existing = await readLockfile(dir);
49
+ if (!existing)
50
+ return false;
51
+ const merged = { ...existing.config, ...patch };
52
+ await writeLockfile(dir, merged);
53
+ return true;
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -170,7 +170,7 @@
170
170
  "@eslint/js": "^9.38.0",
171
171
  "@ianvs/prettier-plugin-sort-imports": "^4.4.2",
172
172
  "@next/eslint-plugin-next": "^16.2.7",
173
- "@playwright/test": "^1.56.1",
173
+ "@playwright/test": "^1.60.0",
174
174
  "@semantic-release/changelog": "^6.0.3",
175
175
  "@semantic-release/commit-analyzer": "^13.0.1",
176
176
  "@semantic-release/exec": "^7.1.0",
@@ -180,7 +180,7 @@
180
180
  "@semantic-release/release-notes-generator": "^14.1.1",
181
181
  "@total-typescript/ts-reset": "0.6.1",
182
182
  "@types/fs-extra": "^11.0.4",
183
- "@types/node": "^25.9.1",
183
+ "@types/node": "^25.9.2",
184
184
  "@typescript-eslint/eslint-plugin": "^8.46.2",
185
185
  "@typescript-eslint/parser": "^8.46.2",
186
186
  "@vitejs/plugin-react": "^5.1.0",
@@ -188,7 +188,7 @@
188
188
  "commitizen": "^4.3.1",
189
189
  "conventional-changelog-conventionalcommits": "^9.3.1",
190
190
  "cz-conventional-changelog": "^3.3.0",
191
- "esbuild": "^0.25.11",
191
+ "esbuild": "^0.28.0",
192
192
  "esbuild-node-externals": "^1.22.0",
193
193
  "eslint": "9.38.0",
194
194
  "eslint-plugin-import": "^2.32.0",