@rtorcato/js-tooling 2.10.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 = {
@@ -606,6 +607,29 @@ async function checkTreeshakeSetup(dir, pkg) {
606
607
  hint: 'Run `npx @rtorcato/js-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
607
608
  };
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
+ }
609
633
  async function checkGitLabCI(dir) {
610
634
  for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
611
635
  if (await fs.pathExists(path.join(dir, candidate))) {
@@ -626,9 +650,11 @@ async function checkGitLabCI(dir) {
626
650
  export async function runDoctor(dir) {
627
651
  const targetDir = path.resolve(dir);
628
652
  const pkg = await readPackageJson(targetDir);
653
+ const lock = await readLockfile(targetDir);
629
654
  const results = [];
630
655
  results.push(evaluateNodeVersion(process.version));
631
656
  results.push(checkPackageJson(pkg));
657
+ results.push(checkLockfile(lock));
632
658
  results.push(checkEnginesNode(pkg));
633
659
  results.push(await checkEditorConfig(targetDir));
634
660
  results.push(await checkNodeVersionPin(targetDir));
@@ -647,6 +673,21 @@ export async function runDoctor(dir) {
647
673
  results.push(await checkCodeQL(targetDir));
648
674
  results.push(await checkGitLabCI(targetDir));
649
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
+ }
650
691
  return results;
651
692
  }
652
693
  const STATUS_ICONS = {
@@ -20,7 +20,101 @@ export const FIX_TARGETS = {
20
20
  'GitHub Actions': 'github-actions',
21
21
  Dependabot: 'dependabot',
22
22
  CodeQL: 'codeql',
23
+ lockfile: 'lockfile',
24
+ '.js-tooling.json': 'lockfile',
23
25
  };
24
26
  export function getFixTargetForCheck(checkName) {
25
27
  return FIX_TARGETS[checkName] ?? null;
26
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
+ }
@@ -8,11 +8,13 @@ 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
10
  import { composeVerifyScriptFromPkg } from '../generators/package-json.js';
11
- import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
12
11
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
13
12
  import { generateVitestConfig } from '../generators/testing.js';
13
+ import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
14
14
  import { copyPreset } from '../utils/copy-preset.js';
15
+ import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } from '../utils/lockfile.js';
15
16
  import { runDoctor } from './doctor.js';
17
+ import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
16
18
  function inferProjectConfig(pkg) {
17
19
  const deps = {
18
20
  ...(pkg?.dependencies ?? {}),
@@ -344,6 +346,23 @@ const FIXERS = [
344
346
  return { filesWritten: ['package.json'] };
345
347
  },
346
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
+ },
347
366
  ];
348
367
  export function getFixers() {
349
368
  return FIXERS;
@@ -361,17 +380,27 @@ function logTargets() {
361
380
  console.log(` ${chalk.green('●')} ${chalk.bold(f.target)}: ${chalk.gray(f.description)}`);
362
381
  }
363
382
  }
364
- async function applyFixer(fixer, result, targetDir, pkg, dryRun, silent) {
383
+ async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent) {
365
384
  if (dryRun) {
366
385
  if (!silent) {
367
386
  console.log(chalk.cyan(` [dry-run] would write: ${fixer.outputs.join(', ')}`));
368
387
  }
369
388
  return { filesWritten: [], dryRun: true };
370
389
  }
371
- const { filesWritten } = await fixer.run({ targetDir, pkg, result });
390
+ const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock });
372
391
  if (!silent && filesWritten.length > 0) {
373
392
  console.log(chalk.green(` ✅ wrote ${filesWritten.join(', ')}`));
374
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
+ }
375
404
  return { filesWritten, dryRun: false };
376
405
  }
377
406
  function promptMessageFor(fixer, result) {
@@ -400,8 +429,11 @@ async function confirmApply(fixer, result, assumeYes) {
400
429
  ]);
401
430
  return confirm === true;
402
431
  }
403
- function recordFor(target, check, doctorStatus, status, filesWritten) {
404
- 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;
405
437
  }
406
438
  export async function fixCommand(target, options = {}) {
407
439
  const targetDir = path.resolve(options.directory ?? process.cwd());
@@ -411,8 +443,18 @@ export async function fixCommand(target, options = {}) {
411
443
  const assumeYes = options.yes === true || json;
412
444
  const silent = json;
413
445
  const pkg = await readPackageJson(targetDir);
446
+ const lock = await readLockfile(targetDir);
414
447
  const results = await runDoctor(targetDir);
415
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
+ };
416
458
  const emitJson = (resolvedTarget) => {
417
459
  const payload = { directory: targetDir, target: resolvedTarget, actions };
418
460
  console.log(JSON.stringify(payload, null, 2));
@@ -436,7 +478,12 @@ export async function fixCommand(target, options = {}) {
436
478
  }
437
479
  const result = results.find((r) => fixer.appliesTo.includes(r.check)) ??
438
480
  { check: fixer.appliesTo[0] ?? fixer.target, status: 'missing', detail: '' };
439
- 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') {
440
487
  actions.push(recordFor(fixer.target, result.check, 'ok', 'already-ok', []));
441
488
  if (json)
442
489
  return emitJson(fixer.target);
@@ -444,18 +491,19 @@ export async function fixCommand(target, options = {}) {
444
491
  return;
445
492
  }
446
493
  if (!silent) {
447
- 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`));
448
495
  }
449
- const ok = await confirmApply(fixer, result, assumeYes);
496
+ const conflict = noteLockConflict(result.check);
497
+ const ok = await confirmApply(fixer, effectiveResult, assumeYes);
450
498
  if (!ok) {
451
- actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
499
+ actions.push(recordFor(fixer.target, result.check, effectiveResult.status, 'skipped', [], conflict));
452
500
  if (json)
453
501
  return emitJson(fixer.target);
454
502
  console.log(chalk.gray(' skipped\n'));
455
503
  return;
456
504
  }
457
- const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
458
- 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));
459
507
  if (json)
460
508
  return emitJson(fixer.target);
461
509
  console.log();
@@ -486,16 +534,17 @@ export async function fixCommand(target, options = {}) {
486
534
  if (!silent) {
487
535
  console.log(` ${chalk.bold(result.check)} (${result.status}) → ${fixer.target}`);
488
536
  }
537
+ const conflict = noteLockConflict(result.check);
489
538
  const ok = await confirmApply(fixer, result, assumeYes);
490
539
  if (!ok) {
491
- actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', []));
540
+ actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
492
541
  if (!silent)
493
542
  console.log(chalk.gray(' skipped'));
494
543
  skippedCount++;
495
544
  continue;
496
545
  }
497
- const outcome = await applyFixer(fixer, result, targetDir, pkg, dryRun, silent);
498
- 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));
499
548
  appliedCount++;
500
549
  }
501
550
  if (json)
@@ -151,7 +151,7 @@ export function validateProjectConfig(input) {
151
151
  return { valid: errors.length === 0, errors };
152
152
  }
153
153
  export function computeFileList(config) {
154
- const files = ['package.json'];
154
+ const files = ['package.json', '.js-tooling.json'];
155
155
  files.push('.editorconfig', '.nvmrc', 'knip.json');
156
156
  if (config.typescript.enabled) {
157
157
  files.push('tsconfig.json', 'reset.d.ts');
@@ -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);
@@ -264,6 +266,7 @@ function showNextSteps(config, _targetDir) {
264
266
  if (config.gitHooks) {
265
267
  steps.push('🪝 Commit your changes to test the git hooks');
266
268
  }
269
+ steps.push(`🔒 ${LOCKFILE_NAME} records your setup choices — doctor uses it to suppress intentional opt-outs`);
267
270
  steps.push('📖 Check the generated README.md for more details');
268
271
  steps.forEach((step, index) => {
269
272
  console.log(` ${index + 1}. ${step}`);
@@ -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.10.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": [
@@ -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",