@rtorcato/repo-tooling 3.33.0 → 3.34.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.
@@ -22,7 +22,7 @@ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
22
22
  import { compareRulesWithReference } from '../utils/reference-rules.js';
23
23
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
24
24
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
25
- import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
25
+ import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
26
26
  export { evaluateNodeVersion };
27
27
  const PACKAGE = '@rtorcato/repo-tooling';
28
28
  // Detects the broken-release-on-protected-main footgun: a workflow that runs
@@ -385,6 +385,8 @@ export async function runDoctor(dir, skillsDir) {
385
385
  results.push(await checkTypedoc(targetDir, pkg));
386
386
  results.push(await checkAreTheTypesWrong(targetDir, pkg));
387
387
  results.push(await checkPublint(targetDir, pkg));
388
+ // The setup/doctor-time half of what publint catches at release time (#578).
389
+ results.push(await checkExportsBuildable(targetDir, pkg));
388
390
  results.push(await checkTreeshakeSetup(targetDir, pkg));
389
391
  results.push(await checkPnpmWorkspace(targetDir, pkg));
390
392
  results.push(await checkBuildApprovals(targetDir, pkg));
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { checkFile, hookHasUncommented } from '../../base/checks.js';
4
+ import { realGitExec } from '../../base/git-identity.js';
4
5
  import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
5
6
  import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
6
7
  const PACKAGE = '@rtorcato/repo-tooling';
@@ -964,6 +965,202 @@ export async function checkPublint(_dir, pkg) {
964
965
  hint: 'Run `npx @rtorcato/repo-tooling fix publint` to lint your package before publishing',
965
966
  };
966
967
  }
968
+ /**
969
+ * The file extensions each recognised build command drops in the output
970
+ * directory. Inferring this from an arbitrary shell command is inherently
971
+ * partial, so the table is deliberately small and anything missing from it
972
+ * makes the whole check stand down (see `emittedExtensions`) — a doctor check
973
+ * that cries wolf on a valid config is worse than one with a stated blind spot.
974
+ */
975
+ const BUILD_EMITS = {
976
+ // tsc mirrors its input extension: .ts → .js + .d.ts. It reaches .cjs/.d.cts
977
+ // only from .cts sources, which is why a repo that has any stands the check
978
+ // down below.
979
+ tsc: ['.js', '.d.ts'],
980
+ // tsup's real set is whatever `format`/`dts` say in a config file this does
981
+ // not parse, so it claims the broadest set tsup could emit. A tsup build is
982
+ // therefore passed rather than guessed at, while `rimraf dist && tsup` still
983
+ // resolves instead of standing the check down.
984
+ tsup: ['.js', '.cjs', '.mjs', '.d.ts', '.d.cts', '.d.mts'],
985
+ // Cleaners emit nothing. Recognised only so the very common
986
+ // `rimraf dist && tsc` is still judged on its tsc half.
987
+ rimraf: [],
988
+ rm: [],
989
+ del: [],
990
+ };
991
+ /** Package-manager noise to strip before the command name: `pnpm exec tsup`. */
992
+ const RUNNER_TOKENS = new Set([
993
+ 'npx',
994
+ 'npm',
995
+ 'pnpm',
996
+ 'yarn',
997
+ 'bun',
998
+ 'exec',
999
+ 'dlx',
1000
+ 'run',
1001
+ 'x',
1002
+ '-s',
1003
+ '--silent',
1004
+ ]);
1005
+ /**
1006
+ * Extensions this check will judge, longest suffix first. Anything else — a
1007
+ * package publishing `./src/index.ts` directly, a `./dist/style.css` from a
1008
+ * non-JS step — is left alone rather than measured against a JS emit table.
1009
+ */
1010
+ const ARTEFACT_EXTENSIONS = ['.d.cts', '.d.mts', '.d.ts', '.cjs', '.mjs', '.js'];
1011
+ function artefactExtension(p) {
1012
+ return ARTEFACT_EXTENSIONS.find((ext) => p.endsWith(ext)) ?? null;
1013
+ }
1014
+ /**
1015
+ * Every file path the publish contract names. Shared with the #570 regression
1016
+ * test so doctor and that test agree on what the contract is.
1017
+ */
1018
+ export function declaredEntryPoints(pkg) {
1019
+ const found = [];
1020
+ const walk = (node) => {
1021
+ if (typeof node === 'string')
1022
+ found.push(node);
1023
+ else if (node && typeof node === 'object')
1024
+ Object.values(node).forEach(walk);
1025
+ };
1026
+ walk(pkg.exports);
1027
+ for (const field of ['main', 'module', 'types']) {
1028
+ const value = pkg[field];
1029
+ if (typeof value === 'string')
1030
+ found.push(value);
1031
+ }
1032
+ return [...new Set(found)];
1033
+ }
1034
+ /**
1035
+ * What `command` puts in the output directory, or null when any part of it is
1036
+ * absent from BUILD_EMITS. Null means "don't know", and every caller stands
1037
+ * down on it rather than guessing.
1038
+ */
1039
+ function emittedExtensions(scripts, command, seen = new Set()) {
1040
+ // Only a plain `a && b` chain is read. Pipes, `||`, subshells and redirects
1041
+ // are not something to infer an output set from.
1042
+ if (/[|;`<>]|\$\(/.test(command))
1043
+ return null;
1044
+ const emitted = new Set();
1045
+ for (const segment of command.split('&&')) {
1046
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
1047
+ const before = tokens.length;
1048
+ while (tokens.length > 0 && RUNNER_TOKENS.has(tokens[0]))
1049
+ tokens.shift();
1050
+ const name = tokens[0];
1051
+ if (!name)
1052
+ return null;
1053
+ // `"build": "pnpm build-cli"` — one script delegating to another. Followed
1054
+ // only when a runner was actually stripped, because a bare `tsc` runs the
1055
+ // binary even in a repo that also happens to have a script by that name.
1056
+ const nested = tokens.length < before ? scripts[name] : undefined;
1057
+ if (nested !== undefined) {
1058
+ if (seen.has(name))
1059
+ return null;
1060
+ seen.add(name);
1061
+ const inner = emittedExtensions(scripts, nested, seen);
1062
+ if (!inner)
1063
+ return null;
1064
+ for (const ext of inner)
1065
+ emitted.add(ext);
1066
+ continue;
1067
+ }
1068
+ const known = BUILD_EMITS[name];
1069
+ if (!known)
1070
+ return null;
1071
+ for (const ext of known)
1072
+ emitted.add(ext);
1073
+ }
1074
+ return [...emitted];
1075
+ }
1076
+ /**
1077
+ * Catches a publish contract naming files the repo's own `build` cannot produce
1078
+ * (#578) — the class of bug that shipped `main: ./dist/index.cjs` alongside
1079
+ * `build: tsc`, which emits only .js and .d.ts, so every CJS consumer of the
1080
+ * published package got a 404 (#570). #577 fixed the preset that wrote that
1081
+ * pair; this catches it however else it arises, e.g. a repo editing `build` or
1082
+ * the contract by hand afterwards.
1083
+ *
1084
+ * Judged from the build *script*, never from `dist/` on disk: a clean checkout
1085
+ * has no dist/ and a stale one has whatever the last build left, so neither
1086
+ * answers the question. Git is what separates the two kinds of path a contract
1087
+ * may name — a tracked `./tooling/preset.mjs` ships as committed source and
1088
+ * needs no build, while an untracked `./dist/index.cjs` has to come out of
1089
+ * `build` or it will not exist at publish time.
1090
+ *
1091
+ * This overlaps publint, which the generated `verify` already runs — but not in
1092
+ * time. publint fires at release, against a dist/ just built on a machine where
1093
+ * it happens to work; this fires at setup/doctor time, on a checkout, before
1094
+ * anything is built.
1095
+ */
1096
+ export async function checkExportsBuildable(dir, pkg, exec) {
1097
+ const check = 'Exports buildable';
1098
+ if (!pkg || !isPublishableLibrary(pkg)) {
1099
+ return { check, status: 'ok', detail: 'not applicable (private or no published exports)' };
1100
+ }
1101
+ const scripts = pkg.scripts ?? {};
1102
+ const build = scripts.build;
1103
+ if (!build) {
1104
+ return { check, status: 'ok', detail: 'no build script — nothing is expected to be generated' };
1105
+ }
1106
+ const emitted = emittedExtensions(scripts, build);
1107
+ if (!emitted) {
1108
+ return {
1109
+ check,
1110
+ status: 'ok',
1111
+ detail: `not checked — cannot infer what \`${build}\` emits (recognised: ${Object.keys(BUILD_EMITS).join(', ')})`,
1112
+ };
1113
+ }
1114
+ const declared = declaredEntryPoints(pkg).filter((p) => artefactExtension(p));
1115
+ if (declared.length === 0) {
1116
+ return { check, status: 'ok', detail: 'no JS entry points named in main/module/types/exports' };
1117
+ }
1118
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
1119
+ return {
1120
+ check,
1121
+ status: 'ok',
1122
+ detail: 'not checked — not a git repository, so committed assets and build output are indistinguishable',
1123
+ };
1124
+ }
1125
+ // Declared paths are `./dist/index.js`; git speaks `dist/index.js`.
1126
+ const declaredByPath = new Map(declared.map((p) => [p.replace(/^\.\//, ''), p]));
1127
+ const git = exec ?? ((args) => realGitExec(args, dir));
1128
+ // One call answers both questions: which declared paths are committed, and
1129
+ // whether the repo has .cts/.mts sources. Every argument is a pathspec behind
1130
+ // `--`, so a package.json path beginning with `-` cannot become a flag.
1131
+ const listed = await git(['ls-files', '-z', '--', ...declaredByPath.keys(), '*.cts', '*.mts']);
1132
+ if (listed === null) {
1133
+ return { check, status: 'ok', detail: 'not checked — `git ls-files` is unavailable here' };
1134
+ }
1135
+ const tracked = new Set(listed.split('\0').filter(Boolean));
1136
+ const unbuildable = [...declaredByPath]
1137
+ .filter(([bare]) => !tracked.has(bare))
1138
+ .map(([, declaredPath]) => declaredPath)
1139
+ .filter((p) => !emitted.includes(artefactExtension(p)));
1140
+ if (unbuildable.length === 0) {
1141
+ return {
1142
+ check,
1143
+ status: 'ok',
1144
+ detail: `every declared entry point is committed or emitted by \`${build}\``,
1145
+ };
1146
+ }
1147
+ // tsc emits .cjs/.d.cts from a .cts input, so its narrow BUILD_EMITS entry is
1148
+ // wrong for a repo that has any. Checked last, and only when there is
1149
+ // something to report, so the common repo pays nothing for it.
1150
+ if ([...tracked].some((p) => !declaredByPath.has(p) && /\.[cm]ts$/.test(p))) {
1151
+ return {
1152
+ check,
1153
+ status: 'ok',
1154
+ detail: 'not checked — repo has .cts/.mts sources, whose emitted extensions this cannot infer',
1155
+ };
1156
+ }
1157
+ return {
1158
+ check,
1159
+ status: 'drift',
1160
+ detail: `\`${build}\` emits ${emitted.join(', ')}; package.json names ${unbuildable.length} entry point(s) nothing produces: ${unbuildable.join(', ')}`,
1161
+ hint: 'The published package will 404 on those paths. Either point `build` at a bundler that emits those formats (tsup with `format: ["cjs", "esm"]`), or narrow main/module/exports to the formats the current build produces. No autofix — both directions are valid and only the package author knows which one it promises.',
1162
+ };
1163
+ }
967
1164
  /** Who this package's README badges are for — feeds the base badge check (#309). */
968
1165
  export function jsBadgeAudience(pkg) {
969
1166
  if (!pkg || pkg.private === true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.33.0",
3
+ "version": "3.34.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [