@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.
- package/dist/cli/commands/doctor.js +166 -1
- package/dist/cli/commands/fix-targets.js +97 -0
- package/dist/cli/commands/fix.js +145 -18
- package/dist/cli/commands/setup-presets.js +9 -2
- package/dist/cli/commands/setup.js +14 -0
- package/dist/cli/generators/git.js +28 -0
- package/dist/cli/generators/index.js +14 -0
- package/dist/cli/generators/package-json.js +62 -2
- package/dist/cli/generators/treeshake.js +113 -0
- package/dist/cli/utils/lockfile.js +54 -0
- package/package.json +4 -4
|
@@ -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 {
|
|
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
|
+
}
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
494
|
+
console.log(chalk.cyan(`\n🔧 ${fixer.target} — ${chalk.bold(result.check)} is ${effectiveResult.status}\n`));
|
|
370
495
|
}
|
|
371
|
-
const
|
|
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,
|
|
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,
|
|
380
|
-
actions.push(recordFor(fixer.target, result.check,
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|