@rtorcato/js-tooling 2.9.0 → 2.10.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.
@@ -231,6 +231,100 @@ async function checkHusky(dir, pkg) {
231
231
  hint: 'Run `pnpm add -D husky && pnpm exec husky init` to enable git hooks',
232
232
  };
233
233
  }
234
+ async function checkHuskyPrePush(dir, pkg) {
235
+ const huskyDir = await fs.pathExists(path.join(dir, '.husky'));
236
+ if (!huskyDir) {
237
+ // If husky isn't in use, pre-push is not relevant
238
+ return {
239
+ check: 'Husky pre-push',
240
+ status: 'optional-missing',
241
+ detail: 'husky not configured',
242
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to enable git hooks (includes pre-push)',
243
+ };
244
+ }
245
+ const hookPath = path.join(dir, '.husky', 'pre-push');
246
+ if (!(await fs.pathExists(hookPath))) {
247
+ return {
248
+ check: 'Husky pre-push',
249
+ status: 'optional-missing',
250
+ detail: 'no .husky/pre-push',
251
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to scaffold a pre-push hook that runs `pnpm verify`',
252
+ };
253
+ }
254
+ const contents = await fs.readFile(hookPath, 'utf-8');
255
+ if (/\bpnpm\s+verify\b/.test(contents)) {
256
+ return {
257
+ check: 'Husky pre-push',
258
+ status: 'ok',
259
+ detail: '.husky/pre-push runs `pnpm verify`',
260
+ };
261
+ }
262
+ // Pre-push exists but doesn't call pnpm verify
263
+ const scripts = pkg?.scripts ?? {};
264
+ if (!scripts.verify) {
265
+ return {
266
+ check: 'Husky pre-push',
267
+ status: 'drift',
268
+ detail: '.husky/pre-push exists but no `verify` script in package.json',
269
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a verify script, then `fix husky` to align the hook',
270
+ };
271
+ }
272
+ return {
273
+ check: 'Husky pre-push',
274
+ status: 'drift',
275
+ detail: '.husky/pre-push exists but does not call `pnpm verify`',
276
+ hint: 'Run `npx @rtorcato/js-tooling fix husky` to align the hook with `pnpm verify`',
277
+ };
278
+ }
279
+ async function checkVerifyScript(dir, pkg) {
280
+ if (!pkg) {
281
+ return {
282
+ check: 'verify script',
283
+ status: 'missing',
284
+ detail: 'no package.json',
285
+ };
286
+ }
287
+ const scripts = pkg.scripts ?? {};
288
+ const body = scripts.verify;
289
+ if (!body) {
290
+ return {
291
+ check: 'verify script',
292
+ status: 'optional-missing',
293
+ detail: 'no `verify` script in package.json',
294
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a unified `pnpm verify` script',
295
+ };
296
+ }
297
+ // Lenient: only flag drift when an enabled tool is clearly absent from the script body.
298
+ const deps = {
299
+ ...(pkg.dependencies ?? {}),
300
+ ...(pkg.devDependencies ?? {}),
301
+ };
302
+ const missing = [];
303
+ if (scripts.typecheck && !/\btypecheck\b/.test(body))
304
+ missing.push('typecheck');
305
+ if ((scripts.check || deps['@biomejs/biome']) && !/\b(check|biome|lint)\b/.test(body)) {
306
+ missing.push('lint/check');
307
+ }
308
+ if ((deps.vitest || scripts.test) && !/(vitest|jest|test:e2e|pnpm\s+test)/.test(body)) {
309
+ missing.push('tests');
310
+ }
311
+ const hasTreeshakeApp = await fs.pathExists(path.join(dir, 'apps', 'treeshake-check', 'check.mjs'));
312
+ if (hasTreeshakeApp && !/\btreeshake\b/.test(body))
313
+ missing.push('treeshake');
314
+ if (missing.length > 0) {
315
+ return {
316
+ check: 'verify script',
317
+ status: 'drift',
318
+ detail: `\`verify\` script is missing: ${missing.join(', ')}`,
319
+ hint: 'Run `npx @rtorcato/js-tooling fix verify` to regenerate the verify chain',
320
+ };
321
+ }
322
+ return {
323
+ check: 'verify script',
324
+ status: 'ok',
325
+ detail: `\`verify\` = ${body}`,
326
+ };
327
+ }
234
328
  const LINT_STAGED_FILES = [
235
329
  '.lintstagedrc',
236
330
  '.lintstagedrc.json',
@@ -485,6 +579,33 @@ async function checkCodeQL(dir) {
485
579
  hint: 'Run `npx @rtorcato/js-tooling fix codeql` to scaffold CodeQL security scanning',
486
580
  };
487
581
  }
582
+ async function checkTreeshakeSetup(dir, pkg) {
583
+ const appCheckPath = path.join(dir, 'apps', 'treeshake-check', 'check.mjs');
584
+ if (await fs.pathExists(appCheckPath)) {
585
+ return {
586
+ check: 'Tree-shake check',
587
+ status: 'ok',
588
+ detail: 'apps/treeshake-check/check.mjs found',
589
+ };
590
+ }
591
+ // Only nudge libraries that actually claim tree-shaking via multi-subpath exports + sideEffects: false.
592
+ const exports = pkg?.exports ?? {};
593
+ const subpaths = Object.keys(exports).filter((k) => k !== '.' && k.startsWith('./') && !k.includes('*'));
594
+ const sideEffectsFree = pkg?.sideEffects === false;
595
+ if (subpaths.length < 2 || !sideEffectsFree) {
596
+ return {
597
+ check: 'Tree-shake check',
598
+ status: 'ok',
599
+ detail: 'not applicable (single-export or has side effects)',
600
+ };
601
+ }
602
+ return {
603
+ check: 'Tree-shake check',
604
+ status: 'optional-missing',
605
+ detail: `package exports ${subpaths.length} subpaths with sideEffects: false but no apps/treeshake-check/`,
606
+ hint: 'Run `npx @rtorcato/js-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
607
+ };
608
+ }
488
609
  async function checkGitLabCI(dir) {
489
610
  for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
490
611
  if (await fs.pathExists(path.join(dir, candidate))) {
@@ -516,6 +637,8 @@ export async function runDoctor(dir) {
516
637
  }
517
638
  results.push(await checkHusky(targetDir, pkg));
518
639
  results.push(await checkLintStaged(targetDir, pkg));
640
+ results.push(await checkVerifyScript(targetDir, pkg));
641
+ results.push(await checkHuskyPrePush(targetDir, pkg));
519
642
  results.push(await checkSemanticRelease(targetDir, pkg));
520
643
  results.push(await checkKnip(targetDir, pkg));
521
644
  results.push(await checkSizeLimit(targetDir, pkg));
@@ -523,6 +646,7 @@ export async function runDoctor(dir) {
523
646
  results.push(await checkDependabot(targetDir));
524
647
  results.push(await checkCodeQL(targetDir));
525
648
  results.push(await checkGitLabCI(targetDir));
649
+ results.push(await checkTreeshakeSetup(targetDir, pkg));
526
650
  return results;
527
651
  }
528
652
  const STATUS_ICONS = {
@@ -11,9 +11,12 @@ 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',
@@ -3,10 +3,12 @@ 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';
11
+ import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
10
12
  import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
11
13
  import { generateVitestConfig } from '../generators/testing.js';
12
14
  import { copyPreset } from '../utils/copy-preset.js';
@@ -124,9 +126,9 @@ const FIXERS = [
124
126
  },
125
127
  {
126
128
  target: 'husky',
127
- description: 'Set up Husky + lint-staged',
128
- appliesTo: ['Husky', 'lint-staged'],
129
- outputs: ['.husky/pre-commit', 'package.json (lint-staged field)'],
129
+ description: 'Set up Husky + lint-staged (and a `pnpm verify` pre-push hook)',
130
+ appliesTo: ['Husky', 'lint-staged', 'Husky pre-push'],
131
+ outputs: ['.husky/pre-commit', '.husky/pre-push', 'package.json (lint-staged field)'],
130
132
  riskLevel: 'safe-merge',
131
133
  canFixDrift: true,
132
134
  async run({ targetDir, pkg }) {
@@ -137,7 +139,46 @@ const FIXERS = [
137
139
  const generated = updated['lint-staged'] ?? {};
138
140
  updated['lint-staged'] = { ...generated, ...existingLintStaged };
139
141
  await fs.writeJson(pkgPath, updated, { spaces: 2 });
140
- return { filesWritten: ['.husky/pre-commit', 'package.json'] };
142
+ const filesWritten = ['.husky/pre-commit', 'package.json'];
143
+ const scripts = updated.scripts ?? {};
144
+ if (scripts.verify) {
145
+ await generatePrePushHook(targetDir);
146
+ filesWritten.push('.husky/pre-push');
147
+ }
148
+ return { filesWritten };
149
+ },
150
+ },
151
+ {
152
+ target: 'verify',
153
+ description: 'Add a unified `verify` script (typecheck && lint && tests) to package.json',
154
+ appliesTo: ['verify script'],
155
+ outputs: ['package.json (scripts.verify)'],
156
+ riskLevel: 'safe-merge',
157
+ canFixDrift: true,
158
+ async run({ targetDir, pkg }) {
159
+ const pkgPath = path.join(targetDir, 'package.json');
160
+ if (!pkg) {
161
+ console.log(chalk.yellow(' no package.json found — skipping'));
162
+ return { filesWritten: [] };
163
+ }
164
+ const includeTreeshake = await fs.pathExists(path.join(targetDir, 'apps', 'treeshake-check', 'check.mjs'));
165
+ const verify = composeVerifyScriptFromPkg(pkg, { includeTreeshake });
166
+ if (!verify) {
167
+ console.log(chalk.gray(' not enough tools enabled to compose a verify chain — skipping (need 2+ of typecheck/lint/tests)'));
168
+ return { filesWritten: [] };
169
+ }
170
+ const updated = { ...pkg };
171
+ const scripts = { ...(updated.scripts ?? {}) };
172
+ scripts.verify = verify;
173
+ if (includeTreeshake && !scripts.treeshake) {
174
+ scripts.treeshake = 'pnpm --filter=*treeshake-check run check';
175
+ if (!scripts.pretreeshake) {
176
+ scripts.pretreeshake = scripts.build ? 'pnpm build' : 'echo "no build step"';
177
+ }
178
+ }
179
+ updated.scripts = scripts;
180
+ await fs.writeJson(pkgPath, updated, { spaces: 2 });
181
+ return { filesWritten: ['package.json'] };
141
182
  },
142
183
  },
143
184
  {
@@ -242,6 +283,43 @@ const FIXERS = [
242
283
  return { filesWritten: ['.size-limit.json'] };
243
284
  },
244
285
  },
286
+ {
287
+ target: 'treeshake-check',
288
+ description: 'Scaffold apps/treeshake-check — esbuild + metafile assertion that one subpath bundles cleanly',
289
+ appliesTo: ['Tree-shake check'],
290
+ outputs: [
291
+ 'apps/treeshake-check/package.json',
292
+ 'apps/treeshake-check/check.mjs',
293
+ 'apps/treeshake-check/src/entry.ts',
294
+ ],
295
+ riskLevel: 'safe-add',
296
+ canFixDrift: false,
297
+ async run({ targetDir, pkg }) {
298
+ if (!pkg) {
299
+ console.log(chalk.yellow(' no package.json found — skipping'));
300
+ return { filesWritten: [] };
301
+ }
302
+ const workspaceName = pkg.name ?? null;
303
+ if (!workspaceName) {
304
+ console.log(chalk.yellow(' package.json has no `name` — skipping'));
305
+ return { filesWritten: [] };
306
+ }
307
+ const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
308
+ if (allCandidates.length < 2 || !defaultAllowed) {
309
+ console.log(chalk.yellow(' package.json does not expose ≥2 subpath exports — tree-shake check needs multiple subpaths to be meaningful. Skipping.'));
310
+ return { filesWritten: [] };
311
+ }
312
+ const allowedSubpath = defaultAllowed;
313
+ const forbiddenSubpaths = allCandidates.filter((s) => s !== allowedSubpath);
314
+ const written = await generateTreeshakeCheck(targetDir, {
315
+ workspaceName,
316
+ allowedSubpath,
317
+ forbiddenSubpaths,
318
+ });
319
+ console.log(chalk.dim(` Wired '${allowedSubpath}' as allowed; forbidden = [${forbiddenSubpaths.join(', ')}]. Edit apps/treeshake-check/check.mjs to tune.`));
320
+ return { filesWritten: written };
321
+ },
322
+ },
245
323
  {
246
324
  target: 'package-json',
247
325
  description: 'Add @rtorcato/js-tooling to devDependencies',
@@ -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)) {
@@ -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
  }
@@ -188,6 +188,13 @@ async function promptForConfig() {
188
188
  default: (answers) => answers.projectType === 'library',
189
189
  when: (answers) => answers.projectType === 'library',
190
190
  },
191
+ {
192
+ type: 'confirm',
193
+ name: 'treeshakeCheck',
194
+ message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
195
+ default: false,
196
+ when: (answers) => answers.projectType === 'library',
197
+ },
191
198
  {
192
199
  type: 'confirm',
193
200
  name: 'securityAutomation',
@@ -239,6 +246,7 @@ async function promptForConfig() {
239
246
  semanticRelease: answers.semanticRelease || false,
240
247
  securityAutomation: answers.securityAutomation ?? false,
241
248
  bundler: answers.bundler || 'none',
249
+ treeshakeCheck: answers.treeshakeCheck || false,
242
250
  };
243
251
  }
244
252
  function showNextSteps(config, _targetDir) {
@@ -290,6 +298,9 @@ function collectSkippedFixSuggestions(config) {
290
298
  if (config.testing.framework === 'none') {
291
299
  suggestions.push('Run `npx @rtorcato/js-tooling fix vitest` to add a test runner');
292
300
  }
301
+ if (config.projectType === 'library' && !config.treeshakeCheck) {
302
+ suggestions.push('Run `npx @rtorcato/js-tooling fix treeshake-check` to add an esbuild-based tree-shake assertion');
303
+ }
293
304
  suggestions.push('Run `npx @rtorcato/js-tooling doctor` any time to audit drift');
294
305
  return suggestions;
295
306
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/js-tooling",
3
- "version": "2.9.0",
3
+ "version": "2.10.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",