@rtorcato/repo-tooling 2.59.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/AGENTS.md +113 -0
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/cli/commands/doctor.js +336 -0
- package/dist/cli/commands/fix-targets.js +192 -0
- package/dist/cli/commands/fix.js +440 -0
- package/dist/cli/commands/setup-presets.js +281 -0
- package/dist/cli/commands/setup.js +501 -0
- package/dist/cli/generators/agent-rules.js +103 -0
- package/dist/cli/generators/badges.js +88 -0
- package/dist/cli/generators/build.js +216 -0
- package/dist/cli/generators/bun.js +25 -0
- package/dist/cli/generators/community-health.js +145 -0
- package/dist/cli/generators/docs-site.js +436 -0
- package/dist/cli/generators/git.js +164 -0
- package/dist/cli/generators/github-actions.js +35 -0
- package/dist/cli/generators/github-workflows.js +24 -0
- package/dist/cli/generators/gitlab-ci.js +10 -0
- package/dist/cli/generators/index.js +123 -0
- package/dist/cli/generators/linting.js +54 -0
- package/dist/cli/generators/misc.js +298 -0
- package/dist/cli/generators/nx.js +16 -0
- package/dist/cli/generators/package-json.js +317 -0
- package/dist/cli/generators/pnpm-workspace.js +126 -0
- package/dist/cli/generators/postcss.js +24 -0
- package/dist/cli/generators/readme.js +268 -0
- package/dist/cli/generators/security.js +156 -0
- package/dist/cli/generators/skills-install.js +70 -0
- package/dist/cli/generators/tailwind.js +34 -0
- package/dist/cli/generators/testing.js +88 -0
- package/dist/cli/generators/treeshake.js +148 -0
- package/dist/cli/generators/tsconfig.js +27 -0
- package/dist/cli/generators/turborepo.js +35 -0
- package/dist/cli/generators/typedoc.js +40 -0
- package/dist/cli/index.js +332 -0
- package/dist/cli/utils/copy-preset.js +98 -0
- package/dist/cli/utils/detect-language.js +22 -0
- package/dist/cli/utils/format.js +32 -0
- package/dist/cli/utils/install.js +28 -0
- package/dist/cli/utils/lockfile.js +84 -0
- package/dist/languages/js/checks.js +949 -0
- package/dist/languages/js/ci.js +251 -0
- package/dist/languages/js/fixers.js +735 -0
- package/dist/languages/registry.js +37 -0
- package/dist/languages/swift/checks.js +127 -0
- package/dist/languages/swift/ci.js +165 -0
- package/dist/languages/swift/fixers.js +102 -0
- package/dist/languages/swift/git-hooks.js +63 -0
- package/dist/languages/swift/gitignore.js +43 -0
- package/dist/languages/swift/scaffold.js +244 -0
- package/package.json +461 -0
- package/tooling/biome/README.md +90 -0
- package/tooling/biome/biome.json +63 -0
- package/tooling/bun/bunfig.toml +14 -0
- package/tooling/changesets/README.md +35 -0
- package/tooling/changesets/config.json +11 -0
- package/tooling/claude/repo-tooling.md +87 -0
- package/tooling/commitlint/commitlint.d.mts +4 -0
- package/tooling/commitlint/commitlint.mjs +40 -0
- package/tooling/cypress/cypress.config.d.mts +4 -0
- package/tooling/cypress/cypress.config.mjs +11 -0
- package/tooling/docusaurus/index.d.mts +17 -0
- package/tooling/docusaurus/index.mjs +38 -0
- package/tooling/docusaurus/sync-changelog.mjs +42 -0
- package/tooling/docusaurus/theme-tokens.css +79 -0
- package/tooling/docusaurus/theme.css +378 -0
- package/tooling/esbuild/index.d.mts +6 -0
- package/tooling/esbuild/index.mjs +102 -0
- package/tooling/eslint/base.d.mts +6 -0
- package/tooling/eslint/base.mjs +122 -0
- package/tooling/eslint/nextjs.d.mts +4 -0
- package/tooling/eslint/nextjs.mjs +22 -0
- package/tooling/eslint/types.d.ts +58 -0
- package/tooling/github-actions/workflows/cloudflare-pages.yml +42 -0
- package/tooling/github-actions/workflows/docker-publish.yml +44 -0
- package/tooling/github-actions/workflows/preview-deployments.yml +54 -0
- package/tooling/github-actions/workflows/vercel-deploy.yml +40 -0
- package/tooling/jest-presets/browser/jest-preset.d.mts +4 -0
- package/tooling/jest-presets/browser/jest-preset.mjs +14 -0
- package/tooling/jest-presets/node/jest-preset.d.mts +4 -0
- package/tooling/jest-presets/node/jest-preset.mjs +13 -0
- package/tooling/mcp/mcp.json.example +27 -0
- package/tooling/nx/nx.json +24 -0
- package/tooling/oxlint/README.md +25 -0
- package/tooling/oxlint/oxlintrc.json +28 -0
- package/tooling/playwright/playwright.config.d.mts +4 -0
- package/tooling/playwright/playwright.config.mjs +19 -0
- package/tooling/prettier/index.d.mts +4 -0
- package/tooling/prettier/index.mjs +36 -0
- package/tooling/release-please/.release-please-manifest.json +3 -0
- package/tooling/release-please/release-please-config.json +9 -0
- package/tooling/rolldown/rolldown.config.d.mts +18 -0
- package/tooling/rolldown/rolldown.config.mjs +59 -0
- package/tooling/rollup/rollup.config.d.mts +20 -0
- package/tooling/rollup/rollup.config.mjs +70 -0
- package/tooling/semantic-release/docker.d.mts +4 -0
- package/tooling/semantic-release/docker.mjs +59 -0
- package/tooling/semantic-release/github.d.mts +4 -0
- package/tooling/semantic-release/github.mjs +79 -0
- package/tooling/semantic-release/index.d.mts +4 -0
- package/tooling/semantic-release/index.mjs +80 -0
- package/tooling/swift/periphery.yml +4 -0
- package/tooling/swift/swiftlint.yml +23 -0
- package/tooling/tests/exports-resolution.d.mts +14 -0
- package/tooling/tests/exports-resolution.mjs +68 -0
- package/tooling/tests/ssr-safety.d.mts +14 -0
- package/tooling/tests/ssr-safety.mjs +53 -0
- package/tooling/tsup/index.d.mts +8 -0
- package/tooling/tsup/index.mjs +33 -0
- package/tooling/typedoc/typedoc.json +7 -0
- package/tooling/typescript/README.md +49 -0
- package/tooling/typescript/reset.d.ts +9 -0
- package/tooling/typescript/tsconfig.base.json +84 -0
- package/tooling/typescript/tsconfig.build.json +11 -0
- package/tooling/typescript/tsconfig.bun.json +9 -0
- package/tooling/typescript/tsconfig.express.json +9 -0
- package/tooling/typescript/tsconfig.next.json +20 -0
- package/tooling/typescript/tsconfig.node.json +9 -0
- package/tooling/typescript/tsconfig.react.json +15 -0
- package/tooling/typescript/tsconfig.test.json +8 -0
- package/tooling/typescript/v1/tsconfig.base.json +81 -0
- package/tooling/typescript/v1/tsconfig.express.json +9 -0
- package/tooling/typescript/v1/tsconfig.next.json +19 -0
- package/tooling/typescript/v1/tsconfig.node.json +9 -0
- package/tooling/typescript/v1/tsconfig.react.json +14 -0
- package/tooling/typescript/v1/tsconfig.test.json +8 -0
- package/tooling/vite/vite.config.d.mts +4 -0
- package/tooling/vite/vite.config.mjs +18 -0
- package/tooling/vitest/jsdom-shims.d.mts +1 -0
- package/tooling/vitest/jsdom-shims.mjs +58 -0
- package/tooling/vitest/vitest.config.d.mts +4 -0
- package/tooling/vitest/vitest.config.mjs +25 -0
- package/tooling/vitest/vitest.config.react.d.mts +4 -0
- package/tooling/vitest/vitest.config.react.mjs +27 -0
- package/tooling/vitest/vitest.setup.d.mts +1 -0
- package/tooling/vitest/vitest.setup.mjs +3 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { hookHasUncommented } from '../../base/checks.js';
|
|
4
|
+
import { WORKSPACE_FILE, dependsOnEsbuild, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
|
|
5
|
+
const PACKAGE = '@rtorcato/repo-tooling';
|
|
6
|
+
const NODE_MIN_MAJOR = 22;
|
|
7
|
+
const NODE_LTS_REQUIREMENTS = {
|
|
8
|
+
22: { minor: 22, patch: 2 },
|
|
9
|
+
24: { minor: 15, patch: 0 },
|
|
10
|
+
};
|
|
11
|
+
function parseNodeVersion(version) {
|
|
12
|
+
const clean = version.replace(/^v/, '').split('-')[0] ?? '';
|
|
13
|
+
const [maj, min, pat] = clean.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
14
|
+
return [maj ?? 0, min ?? 0, pat ?? 0];
|
|
15
|
+
}
|
|
16
|
+
export function evaluateNodeVersion(version) {
|
|
17
|
+
const [major, minor, patch] = parseNodeVersion(version);
|
|
18
|
+
const display = `v${major}.${minor}.${patch}`;
|
|
19
|
+
if (major < NODE_MIN_MAJOR) {
|
|
20
|
+
return {
|
|
21
|
+
check: 'Node',
|
|
22
|
+
status: 'missing',
|
|
23
|
+
detail: `${display} is below required Node ${NODE_MIN_MAJOR}+`,
|
|
24
|
+
hint: `Install Node ${NODE_MIN_MAJOR} LTS or newer (https://nodejs.org)`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const lts = NODE_LTS_REQUIREMENTS[major];
|
|
28
|
+
if (lts) {
|
|
29
|
+
const meets = minor > lts.minor || (minor === lts.minor && patch >= lts.patch);
|
|
30
|
+
if (!meets) {
|
|
31
|
+
return {
|
|
32
|
+
check: 'Node',
|
|
33
|
+
status: 'drift',
|
|
34
|
+
detail: `${display} — npm may emit EBADENGINE warnings from transitive deps`,
|
|
35
|
+
hint: `Upgrade to Node ${major}.${lts.minor}.${lts.patch}+ (or 26+) to silence transitive engine warnings`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
check: 'Node',
|
|
41
|
+
status: 'ok',
|
|
42
|
+
detail: display,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export const FILE_CHECKS = [
|
|
46
|
+
{
|
|
47
|
+
check: 'TypeScript',
|
|
48
|
+
candidates: ['tsconfig.json'],
|
|
49
|
+
expected: `extends "${PACKAGE}/typescript/*"`,
|
|
50
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/typescript\//,
|
|
51
|
+
hint: 'Set `"extends": "@rtorcato/repo-tooling/typescript/base"` in tsconfig.json',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
check: 'Biome',
|
|
55
|
+
candidates: ['biome.json', 'biome.jsonc'],
|
|
56
|
+
expected: `extends "${PACKAGE}/biome"`,
|
|
57
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/biome/,
|
|
58
|
+
optional: true,
|
|
59
|
+
hint: 'Run `npx @rtorcato/repo-tooling copy biome` to scaffold',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
check: 'ESLint',
|
|
63
|
+
candidates: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'],
|
|
64
|
+
expected: `imports "${PACKAGE}/eslint/*"`,
|
|
65
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/eslint\//,
|
|
66
|
+
optional: true,
|
|
67
|
+
hint: 'Import from @rtorcato/repo-tooling/eslint/base in eslint.config.mjs',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
check: 'Prettier',
|
|
71
|
+
candidates: ['prettier.config.js', 'prettier.config.mjs', 'prettier.config.cjs'],
|
|
72
|
+
expected: `imports "${PACKAGE}/prettier"`,
|
|
73
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/prettier/,
|
|
74
|
+
optional: true,
|
|
75
|
+
hint: `Re-export from "${PACKAGE}/prettier" in prettier.config.mjs`,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
check: 'Vitest',
|
|
79
|
+
candidates: ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'],
|
|
80
|
+
expected: `imports "${PACKAGE}/vitest/config"`,
|
|
81
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/vitest\/config/,
|
|
82
|
+
optional: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
check: 'Oxlint',
|
|
86
|
+
candidates: ['.oxlintrc.json', 'oxlintrc.json'],
|
|
87
|
+
// Oxlint configs are project-owned (extends from npm packages isn't
|
|
88
|
+
// reliably supported), so any well-formed file counts as ok.
|
|
89
|
+
expected: 'is a valid Oxlint configuration',
|
|
90
|
+
matcher: /"(rules|plugins|categories|extends)"/,
|
|
91
|
+
optional: true,
|
|
92
|
+
hint: 'Run `npx @rtorcato/repo-tooling copy oxlint` to scaffold',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
check: 'Changesets',
|
|
96
|
+
candidates: ['.changeset/config.json'],
|
|
97
|
+
expected: 'is a valid Changesets configuration',
|
|
98
|
+
matcher: /"(changelog|access|baseBranch)"/,
|
|
99
|
+
optional: true,
|
|
100
|
+
hint: 'Run `npx @rtorcato/repo-tooling copy changesets` to scaffold',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
check: 'Release Please',
|
|
104
|
+
candidates: ['release-please-config.json'],
|
|
105
|
+
expected: 'is a valid Release Please configuration',
|
|
106
|
+
matcher: /"(packages|release-type|bootstrap-sha)"/,
|
|
107
|
+
optional: true,
|
|
108
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix release-please` to scaffold',
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
/** Merged dependencies + devDependencies of a package.json, for presence checks. */
|
|
112
|
+
export function allDeps(pkg) {
|
|
113
|
+
if (!pkg)
|
|
114
|
+
return {};
|
|
115
|
+
return {
|
|
116
|
+
...(pkg.dependencies ?? {}),
|
|
117
|
+
...(pkg.devDependencies ?? {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export async function readPackageJson(dir) {
|
|
121
|
+
const filepath = path.join(dir, 'package.json');
|
|
122
|
+
if (!(await fs.pathExists(filepath)))
|
|
123
|
+
return null;
|
|
124
|
+
try {
|
|
125
|
+
return (await fs.readJson(filepath));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function checkPackageJson(pkg) {
|
|
132
|
+
if (!pkg) {
|
|
133
|
+
return {
|
|
134
|
+
check: 'package.json',
|
|
135
|
+
status: 'missing',
|
|
136
|
+
detail: 'no package.json found',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const deps = {
|
|
140
|
+
...(pkg.dependencies ?? {}),
|
|
141
|
+
...(pkg.devDependencies ?? {}),
|
|
142
|
+
};
|
|
143
|
+
if (deps[PACKAGE]) {
|
|
144
|
+
return {
|
|
145
|
+
check: 'package.json',
|
|
146
|
+
status: 'ok',
|
|
147
|
+
detail: `${PACKAGE}@${deps[PACKAGE]} in dependencies`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
check: 'package.json',
|
|
152
|
+
status: 'drift',
|
|
153
|
+
detail: `${PACKAGE} not in dependencies or devDependencies`,
|
|
154
|
+
hint: `Run \`pnpm add -D ${PACKAGE}\``,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function checkEnginesNode(pkg) {
|
|
158
|
+
if (!pkg) {
|
|
159
|
+
return {
|
|
160
|
+
check: 'engines.node',
|
|
161
|
+
status: 'missing',
|
|
162
|
+
detail: 'no package.json',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const engines = pkg.engines ?? {};
|
|
166
|
+
if (!engines.node) {
|
|
167
|
+
return {
|
|
168
|
+
check: 'engines.node',
|
|
169
|
+
status: 'drift',
|
|
170
|
+
detail: 'engines.node not set in package.json',
|
|
171
|
+
hint: `Add \`"engines": { "node": ">=${NODE_MIN_MAJOR}" }\` to package.json`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
check: 'engines.node',
|
|
176
|
+
status: 'ok',
|
|
177
|
+
detail: `engines.node = ${engines.node}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Maps a present tool-config file to the VS Code extension that should be
|
|
181
|
+
// recommended for it. Mirrors recommendedExtensions() in the generator, but
|
|
182
|
+
// keyed off files on disk (doctor audits an existing repo, not a config object).
|
|
183
|
+
const EXTENSION_SIGNALS = [
|
|
184
|
+
{ candidates: ['.editorconfig'], ext: 'EditorConfig.EditorConfig' },
|
|
185
|
+
{ candidates: ['biome.json', 'biome.jsonc'], ext: 'biomejs.biome' },
|
|
186
|
+
{
|
|
187
|
+
candidates: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'],
|
|
188
|
+
ext: 'dbaeumer.vscode-eslint',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
candidates: ['prettier.config.js', 'prettier.config.mjs', 'prettier.config.cjs'],
|
|
192
|
+
ext: 'esbenp.prettier-vscode',
|
|
193
|
+
},
|
|
194
|
+
{ candidates: ['.oxlintrc.json', 'oxlintrc.json'], ext: 'oxc.oxc-vscode' },
|
|
195
|
+
{
|
|
196
|
+
candidates: ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'],
|
|
197
|
+
ext: 'vitest.explorer',
|
|
198
|
+
},
|
|
199
|
+
{ candidates: ['playwright.config.ts', 'playwright.config.js'], ext: 'ms-playwright.playwright' },
|
|
200
|
+
];
|
|
201
|
+
export async function checkVscodeExtensions(dir) {
|
|
202
|
+
const wanted = [];
|
|
203
|
+
for (const { candidates, ext } of EXTENSION_SIGNALS) {
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
if (await fs.pathExists(path.join(dir, c))) {
|
|
206
|
+
wanted.push(ext);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (wanted.length === 0) {
|
|
212
|
+
return {
|
|
213
|
+
check: 'VS Code extensions',
|
|
214
|
+
status: 'ok',
|
|
215
|
+
detail: 'no tool configs that map to an editor extension',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
let recommended = [];
|
|
219
|
+
const extPath = path.join(dir, '.vscode', 'extensions.json');
|
|
220
|
+
if (await fs.pathExists(extPath)) {
|
|
221
|
+
try {
|
|
222
|
+
const json = (await fs.readJson(extPath));
|
|
223
|
+
if (Array.isArray(json.recommendations)) {
|
|
224
|
+
recommended = json.recommendations.filter((r) => typeof r === 'string');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
recommended = [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const missing = wanted.filter((ext) => !recommended.includes(ext));
|
|
232
|
+
if (missing.length === 0) {
|
|
233
|
+
return {
|
|
234
|
+
check: 'VS Code extensions',
|
|
235
|
+
status: 'ok',
|
|
236
|
+
detail: '.vscode/extensions.json recommends the matching extensions',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
check: 'VS Code extensions',
|
|
241
|
+
status: 'optional-missing',
|
|
242
|
+
detail: `enabled tools without a recommended extension: ${missing.join(', ')}`,
|
|
243
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix vscode-extensions` to recommend matching editor extensions',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
export async function checkNodeVersionPin(dir) {
|
|
247
|
+
for (const candidate of ['.nvmrc', '.node-version']) {
|
|
248
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
249
|
+
return {
|
|
250
|
+
check: 'Node version pin',
|
|
251
|
+
status: 'ok',
|
|
252
|
+
detail: `${candidate} found`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
check: 'Node version pin',
|
|
258
|
+
status: 'optional-missing',
|
|
259
|
+
detail: 'no .nvmrc / .node-version',
|
|
260
|
+
hint: 'Add .nvmrc to pin Node version per repo (e.g. `echo 22 > .nvmrc`)',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** First integer in an `engines.node` range (the floor major), or null. */
|
|
264
|
+
function enginesFloorMajor(pkg) {
|
|
265
|
+
const engines = pkg?.engines ?? {};
|
|
266
|
+
const raw = engines.node;
|
|
267
|
+
if (!raw)
|
|
268
|
+
return null;
|
|
269
|
+
const m = raw.match(/\d+/);
|
|
270
|
+
return m ? Number.parseInt(m[0], 10) : null;
|
|
271
|
+
}
|
|
272
|
+
async function nvmrcMajor(dir) {
|
|
273
|
+
for (const candidate of ['.nvmrc', '.node-version']) {
|
|
274
|
+
const p = path.join(dir, candidate);
|
|
275
|
+
if (await fs.pathExists(p)) {
|
|
276
|
+
const m = (await fs.readFile(p, 'utf-8')).trim().match(/\d+/);
|
|
277
|
+
if (m)
|
|
278
|
+
return { file: candidate, major: Number.parseInt(m[0], 10) };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
// Matches a hardcoded scalar `node-version: <major>` — a leading digit (after
|
|
284
|
+
// an optional quote) is required. This skips matrix arrays (`[22, 24]`),
|
|
285
|
+
// `${{ matrix.node-version }}` expressions, bare `node-version:` input keys, and
|
|
286
|
+
// `node-version-file:` (a different key entirely) — those aren't drift signals.
|
|
287
|
+
const HARDCODED_NODE_VERSION = /node-version:\s*['"]?(\d+)/g;
|
|
288
|
+
async function workflowNodeMajors(dir) {
|
|
289
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
290
|
+
if (!(await fs.pathExists(workflowsDir)))
|
|
291
|
+
return [];
|
|
292
|
+
let files;
|
|
293
|
+
try {
|
|
294
|
+
files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
const signals = [];
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
const contents = await fs.readFile(path.join(workflowsDir, file), 'utf-8');
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
for (const match of contents.matchAll(HARDCODED_NODE_VERSION)) {
|
|
304
|
+
const major = Number.parseInt(match[1] ?? '', 10);
|
|
305
|
+
if (!Number.isNaN(major) && !seen.has(major)) {
|
|
306
|
+
seen.add(major);
|
|
307
|
+
signals.push({ source: `.github/workflows/${file}`, major });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return signals;
|
|
312
|
+
}
|
|
313
|
+
// Root-cause check for the #94 class: a workflow hardcoding a Node major that
|
|
314
|
+
// disagrees with .nvmrc / engines.node (e.g. ci.yml pinned Node 20 while
|
|
315
|
+
// .nvmrc said 22 → node:sqlite crash under pnpm). Only flags genuine
|
|
316
|
+
// disagreement; a matrix that tests several majors uses arrays/expressions and
|
|
317
|
+
// is not counted.
|
|
318
|
+
export async function checkNodeVersionConsistency(dir, pkg) {
|
|
319
|
+
const signals = [];
|
|
320
|
+
const nvmrc = await nvmrcMajor(dir);
|
|
321
|
+
if (nvmrc)
|
|
322
|
+
signals.push({ source: nvmrc.file, major: nvmrc.major });
|
|
323
|
+
const eng = enginesFloorMajor(pkg);
|
|
324
|
+
if (eng !== null)
|
|
325
|
+
signals.push({ source: 'engines.node', major: eng });
|
|
326
|
+
signals.push(...(await workflowNodeMajors(dir)));
|
|
327
|
+
if (signals.length < 2) {
|
|
328
|
+
return {
|
|
329
|
+
check: 'Node version consistency',
|
|
330
|
+
status: 'ok',
|
|
331
|
+
detail: signals.length === 0
|
|
332
|
+
? 'no Node version pins to cross-check'
|
|
333
|
+
: `single Node version source (${signals[0]?.source} → ${signals[0]?.major})`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const distinct = [...new Set(signals.map((s) => s.major))];
|
|
337
|
+
if (distinct.length === 1) {
|
|
338
|
+
return {
|
|
339
|
+
check: 'Node version consistency',
|
|
340
|
+
status: 'ok',
|
|
341
|
+
detail: `Node ${distinct[0]} agrees across ${signals.length} sources`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const summary = signals.map((s) => `${s.source}→${s.major}`).join(', ');
|
|
345
|
+
return {
|
|
346
|
+
check: 'Node version consistency',
|
|
347
|
+
status: 'drift',
|
|
348
|
+
detail: `Node major disagreement: ${summary}`,
|
|
349
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix node-version` to point workflows at `node-version-file: .nvmrc` (one source of truth)',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The JS shape of the base `Git hooks` / `Pre-push hook` checks (#309): husky,
|
|
354
|
+
* installed by the `prepare` script so a fresh clone wires the hooks on
|
|
355
|
+
* `pnpm install`.
|
|
356
|
+
*/
|
|
357
|
+
export function jsGitHooksProfile(pkg) {
|
|
358
|
+
const scripts = pkg?.scripts ?? {};
|
|
359
|
+
return {
|
|
360
|
+
dir: '.husky',
|
|
361
|
+
install: {
|
|
362
|
+
present: /\bhusky\b/.test(scripts.prepare ?? ''),
|
|
363
|
+
label: '`prepare: husky` script',
|
|
364
|
+
},
|
|
365
|
+
verifyCommand: 'pnpm verify',
|
|
366
|
+
fixTarget: 'husky',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
export async function checkVerifyScript(dir, pkg) {
|
|
370
|
+
if (!pkg) {
|
|
371
|
+
return {
|
|
372
|
+
check: 'verify script',
|
|
373
|
+
status: 'missing',
|
|
374
|
+
detail: 'no package.json',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const scripts = pkg.scripts ?? {};
|
|
378
|
+
const body = scripts.verify;
|
|
379
|
+
if (!body) {
|
|
380
|
+
return {
|
|
381
|
+
check: 'verify script',
|
|
382
|
+
status: 'optional-missing',
|
|
383
|
+
detail: 'no `verify` script in package.json',
|
|
384
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix verify` to add a unified `pnpm verify` script',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
// Lenient: only flag drift when an enabled tool is clearly absent from the script body.
|
|
388
|
+
const deps = {
|
|
389
|
+
...(pkg.dependencies ?? {}),
|
|
390
|
+
...(pkg.devDependencies ?? {}),
|
|
391
|
+
};
|
|
392
|
+
const missing = [];
|
|
393
|
+
if (scripts.typecheck && !/\btypecheck\b/.test(body))
|
|
394
|
+
missing.push('typecheck');
|
|
395
|
+
if ((scripts.check || deps['@biomejs/biome']) && !/\b(check|biome|lint)\b/.test(body)) {
|
|
396
|
+
missing.push('lint/check');
|
|
397
|
+
}
|
|
398
|
+
if ((deps.vitest || scripts.test) && !/(vitest|jest|test:e2e|pnpm\s+test)/.test(body)) {
|
|
399
|
+
missing.push('tests');
|
|
400
|
+
}
|
|
401
|
+
const hasTreeshakeApp = await fs.pathExists(path.join(dir, 'apps', 'treeshake-check', 'check.mjs'));
|
|
402
|
+
if (hasTreeshakeApp && !/\btreeshake\b/.test(body))
|
|
403
|
+
missing.push('treeshake');
|
|
404
|
+
if (missing.length > 0) {
|
|
405
|
+
return {
|
|
406
|
+
check: 'verify script',
|
|
407
|
+
status: 'drift',
|
|
408
|
+
detail: `\`verify\` script is missing: ${missing.join(', ')}`,
|
|
409
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix verify` to regenerate the verify chain',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
check: 'verify script',
|
|
414
|
+
status: 'ok',
|
|
415
|
+
detail: `\`verify\` = ${body}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const LINT_STAGED_FILES = [
|
|
419
|
+
'.lintstagedrc',
|
|
420
|
+
'.lintstagedrc.json',
|
|
421
|
+
'.lintstagedrc.yaml',
|
|
422
|
+
'.lintstagedrc.yml',
|
|
423
|
+
'.lintstagedrc.js',
|
|
424
|
+
'.lintstagedrc.cjs',
|
|
425
|
+
'.lintstagedrc.mjs',
|
|
426
|
+
'lint-staged.config.js',
|
|
427
|
+
'lint-staged.config.cjs',
|
|
428
|
+
'lint-staged.config.mjs',
|
|
429
|
+
];
|
|
430
|
+
/**
|
|
431
|
+
* True when any .husky hook has an uncommented line that invokes lint-staged.
|
|
432
|
+
* A commented-out `# npx lint-staged` line (react-common's repro) does not
|
|
433
|
+
* count — lint-staged never actually runs.
|
|
434
|
+
*/
|
|
435
|
+
async function huskyHookCallsLintStaged(dir) {
|
|
436
|
+
const huskyDir = path.join(dir, '.husky');
|
|
437
|
+
if (!(await fs.pathExists(huskyDir)))
|
|
438
|
+
return false;
|
|
439
|
+
for (const name of await fs.readdir(huskyDir)) {
|
|
440
|
+
const hookPath = path.join(huskyDir, name);
|
|
441
|
+
if (!(await fs.stat(hookPath)).isFile())
|
|
442
|
+
continue;
|
|
443
|
+
const contents = await fs.readFile(hookPath, 'utf-8');
|
|
444
|
+
if (hookHasUncommented(contents, /\blint-staged\b/))
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
export async function checkLintStaged(dir, pkg) {
|
|
450
|
+
const inPkg = pkg ? 'lint-staged' in pkg : false;
|
|
451
|
+
let inFile = null;
|
|
452
|
+
for (const candidate of LINT_STAGED_FILES) {
|
|
453
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
454
|
+
inFile = candidate;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (inPkg || inFile) {
|
|
459
|
+
const where = inPkg ? '`lint-staged` field in package.json' : `${inFile} found`;
|
|
460
|
+
// Config presence isn't enough — verify a husky hook actually runs it.
|
|
461
|
+
// Only assert wiring when husky is in use; a non-husky setup may invoke
|
|
462
|
+
// lint-staged another way and shouldn't be flagged.
|
|
463
|
+
const huskyInUse = await fs.pathExists(path.join(dir, '.husky'));
|
|
464
|
+
if (huskyInUse && !(await huskyHookCallsLintStaged(dir))) {
|
|
465
|
+
return {
|
|
466
|
+
check: 'lint-staged',
|
|
467
|
+
status: 'drift',
|
|
468
|
+
detail: `${where} but no husky hook runs it`,
|
|
469
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix husky` to wire lint-staged into the pre-commit hook',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
check: 'lint-staged',
|
|
474
|
+
status: 'ok',
|
|
475
|
+
detail: where,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
check: 'lint-staged',
|
|
480
|
+
status: 'optional-missing',
|
|
481
|
+
detail: 'lint-staged not configured',
|
|
482
|
+
hint: 'Add a `lint-staged` field to package.json and wire it into the husky pre-commit hook',
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const KNIP_FILES = [
|
|
486
|
+
'knip.json',
|
|
487
|
+
'knip.jsonc',
|
|
488
|
+
'knip.ts',
|
|
489
|
+
'knip.config.ts',
|
|
490
|
+
'knip.config.js',
|
|
491
|
+
'knip.config.mjs',
|
|
492
|
+
];
|
|
493
|
+
export async function checkKnip(dir, pkg) {
|
|
494
|
+
const inPkg = pkg ? 'knip' in pkg : false;
|
|
495
|
+
let inFile = null;
|
|
496
|
+
for (const candidate of KNIP_FILES) {
|
|
497
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
498
|
+
inFile = candidate;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (inPkg || inFile) {
|
|
503
|
+
return {
|
|
504
|
+
check: 'knip',
|
|
505
|
+
status: 'ok',
|
|
506
|
+
detail: inPkg ? '`knip` field in package.json' : `${inFile} found`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
check: 'knip',
|
|
511
|
+
status: 'optional-missing',
|
|
512
|
+
detail: 'knip not configured',
|
|
513
|
+
hint: 'Add `knip` to detect unused files, deps, and exports',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const SIZE_LIMIT_FILES = [
|
|
517
|
+
'.size-limit.json',
|
|
518
|
+
'.size-limit.js',
|
|
519
|
+
'.size-limit.cjs',
|
|
520
|
+
'.size-limit.mjs',
|
|
521
|
+
'.size-limit.ts',
|
|
522
|
+
];
|
|
523
|
+
export async function checkSizeLimit(dir, pkg) {
|
|
524
|
+
const inPkg = pkg ? 'size-limit' in pkg : false;
|
|
525
|
+
let inFile = null;
|
|
526
|
+
for (const candidate of SIZE_LIMIT_FILES) {
|
|
527
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
528
|
+
inFile = candidate;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (inPkg || inFile) {
|
|
533
|
+
return {
|
|
534
|
+
check: 'size-limit',
|
|
535
|
+
status: 'ok',
|
|
536
|
+
detail: inPkg ? '`size-limit` field in package.json' : `${inFile} found`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
check: 'size-limit',
|
|
541
|
+
status: 'optional-missing',
|
|
542
|
+
detail: 'size-limit not configured',
|
|
543
|
+
hint: 'Add `size-limit` to enforce bundle-size budgets in CI for library projects',
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const SEMANTIC_RELEASE_FILES = [
|
|
547
|
+
'.releaserc',
|
|
548
|
+
'.releaserc.json',
|
|
549
|
+
'.releaserc.yaml',
|
|
550
|
+
'.releaserc.yml',
|
|
551
|
+
'.releaserc.js',
|
|
552
|
+
'.releaserc.cjs',
|
|
553
|
+
'release.config.js',
|
|
554
|
+
'release.config.cjs',
|
|
555
|
+
'release.config.mjs',
|
|
556
|
+
];
|
|
557
|
+
export async function checkSemanticRelease(dir, pkg) {
|
|
558
|
+
const isPrivate = pkg?.private === true;
|
|
559
|
+
const inPkg = pkg ? 'release' in pkg : false;
|
|
560
|
+
let configFile = null;
|
|
561
|
+
let configContent = null;
|
|
562
|
+
for (const candidate of SEMANTIC_RELEASE_FILES) {
|
|
563
|
+
const filepath = path.join(dir, candidate);
|
|
564
|
+
if (await fs.pathExists(filepath)) {
|
|
565
|
+
configFile = candidate;
|
|
566
|
+
try {
|
|
567
|
+
configContent = await fs.readFile(filepath, 'utf-8');
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
configContent = '';
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const hasChangesets = await fs.pathExists(path.join(dir, '.changeset', 'config.json'));
|
|
576
|
+
const hasReleasePlease = await fs.pathExists(path.join(dir, 'release-please-config.json'));
|
|
577
|
+
const hasSemanticRelease = inPkg || !!configFile;
|
|
578
|
+
// Conflict: more than one of {semantic-release, Changesets, Release Please}.
|
|
579
|
+
const configured = [
|
|
580
|
+
hasSemanticRelease && 'semantic-release',
|
|
581
|
+
hasChangesets && 'Changesets',
|
|
582
|
+
hasReleasePlease && 'Release Please',
|
|
583
|
+
].filter((v) => Boolean(v));
|
|
584
|
+
if (configured.length >= 2) {
|
|
585
|
+
return {
|
|
586
|
+
check: 'semantic-release',
|
|
587
|
+
status: 'drift',
|
|
588
|
+
detail: `multiple release tools configured (${configured.join(', ')})`,
|
|
589
|
+
hint: 'Pick one release tool — remove the extra config(s)',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (!hasSemanticRelease) {
|
|
593
|
+
// Another release tool is present — treat semantic-release as intentionally unused.
|
|
594
|
+
if (hasChangesets || hasReleasePlease) {
|
|
595
|
+
return {
|
|
596
|
+
check: 'semantic-release',
|
|
597
|
+
status: 'ok',
|
|
598
|
+
detail: `using ${hasChangesets ? 'Changesets' : 'Release Please'} instead`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
check: 'semantic-release',
|
|
603
|
+
status: isPrivate ? 'optional-missing' : 'drift',
|
|
604
|
+
detail: isPrivate
|
|
605
|
+
? 'semantic-release not configured (package is private)'
|
|
606
|
+
: 'semantic-release not configured',
|
|
607
|
+
hint: isPrivate
|
|
608
|
+
? undefined
|
|
609
|
+
: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github" in a release config`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const presetRegex = /@rtorcato\/(?:js|repo)-tooling\/semantic-release/;
|
|
613
|
+
const pkgReleaseStr = inPkg ? JSON.stringify(pkg?.release ?? '') : '';
|
|
614
|
+
const usesPreset = (configContent && presetRegex.test(configContent)) || presetRegex.test(pkgReleaseStr);
|
|
615
|
+
if (usesPreset) {
|
|
616
|
+
return {
|
|
617
|
+
check: 'semantic-release',
|
|
618
|
+
status: 'ok',
|
|
619
|
+
detail: configFile
|
|
620
|
+
? `${configFile} extends ${PACKAGE}/semantic-release`
|
|
621
|
+
: `release field extends ${PACKAGE}/semantic-release`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
check: 'semantic-release',
|
|
626
|
+
status: 'drift',
|
|
627
|
+
detail: configFile
|
|
628
|
+
? `${configFile} does not extend ${PACKAGE}/semantic-release`
|
|
629
|
+
: '`release` field does not extend our preset',
|
|
630
|
+
hint: `Extend "${PACKAGE}/semantic-release" or "${PACKAGE}/semantic-release/github"`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const TYPEDOC_CONFIGS = [
|
|
634
|
+
'typedoc.json',
|
|
635
|
+
'typedoc.config.js',
|
|
636
|
+
'typedoc.config.mjs',
|
|
637
|
+
'typedoc.config.cjs',
|
|
638
|
+
'typedoc.config.ts',
|
|
639
|
+
];
|
|
640
|
+
export async function checkTypedoc(dir, pkg) {
|
|
641
|
+
if (pkg?.private === true) {
|
|
642
|
+
return {
|
|
643
|
+
check: 'TypeDoc',
|
|
644
|
+
status: 'ok',
|
|
645
|
+
detail: 'not applicable (package is private)',
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
let configFile = null;
|
|
649
|
+
let configContent = null;
|
|
650
|
+
for (const candidate of TYPEDOC_CONFIGS) {
|
|
651
|
+
const fp = path.join(dir, candidate);
|
|
652
|
+
if (await fs.pathExists(fp)) {
|
|
653
|
+
configFile = candidate;
|
|
654
|
+
try {
|
|
655
|
+
configContent = await fs.readFile(fp, 'utf-8');
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
configContent = '';
|
|
659
|
+
}
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const deps = {
|
|
664
|
+
...(pkg?.dependencies ?? {}),
|
|
665
|
+
...(pkg?.devDependencies ?? {}),
|
|
666
|
+
};
|
|
667
|
+
const hasDep = !!deps['typedoc'];
|
|
668
|
+
const usesPreset = configContent
|
|
669
|
+
? /@rtorcato\/(?:js|repo)-tooling\/typedoc/.test(configContent)
|
|
670
|
+
: false;
|
|
671
|
+
if (configFile && usesPreset) {
|
|
672
|
+
return {
|
|
673
|
+
check: 'TypeDoc',
|
|
674
|
+
status: 'ok',
|
|
675
|
+
detail: `${configFile} extends the preset`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (configFile && !usesPreset) {
|
|
679
|
+
return {
|
|
680
|
+
check: 'TypeDoc',
|
|
681
|
+
status: 'drift',
|
|
682
|
+
detail: `${configFile} found but does not extend @rtorcato/repo-tooling/typedoc`,
|
|
683
|
+
hint: 'Add `"extends": ["@rtorcato/repo-tooling/typedoc"]` to typedoc.json',
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (hasDep && !configFile) {
|
|
687
|
+
return {
|
|
688
|
+
check: 'TypeDoc',
|
|
689
|
+
status: 'drift',
|
|
690
|
+
detail: 'typedoc installed but no typedoc.json found',
|
|
691
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix typedoc` to scaffold typedoc.json',
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
check: 'TypeDoc',
|
|
696
|
+
status: 'optional-missing',
|
|
697
|
+
detail: 'TypeDoc not configured',
|
|
698
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix typedoc` to scaffold API docs generation',
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function isPublishableLibrary(pkg) {
|
|
702
|
+
if (!pkg || pkg.private === true)
|
|
703
|
+
return false;
|
|
704
|
+
return !!(pkg.exports || pkg.main || pkg.module || pkg.files);
|
|
705
|
+
}
|
|
706
|
+
export async function checkAreTheTypesWrong(_dir, pkg) {
|
|
707
|
+
if (!isPublishableLibrary(pkg)) {
|
|
708
|
+
return {
|
|
709
|
+
check: 'are-the-types-wrong',
|
|
710
|
+
status: 'ok',
|
|
711
|
+
detail: 'not applicable (private or no published exports)',
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const deps = {
|
|
715
|
+
...(pkg?.dependencies ?? {}),
|
|
716
|
+
...(pkg?.devDependencies ?? {}),
|
|
717
|
+
};
|
|
718
|
+
const scripts = pkg?.scripts ?? {};
|
|
719
|
+
const hasDep = !!deps['@arethetypeswrong/cli'];
|
|
720
|
+
const hasScript = Object.values(scripts).some((s) => /\battw\b/.test(s));
|
|
721
|
+
if (hasDep && hasScript) {
|
|
722
|
+
return {
|
|
723
|
+
check: 'are-the-types-wrong',
|
|
724
|
+
status: 'ok',
|
|
725
|
+
detail: '@arethetypeswrong/cli installed and wired into a script',
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (hasDep) {
|
|
729
|
+
return {
|
|
730
|
+
check: 'are-the-types-wrong',
|
|
731
|
+
status: 'drift',
|
|
732
|
+
detail: '@arethetypeswrong/cli installed but no script runs it',
|
|
733
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix attw` to add an `attw` script and wire it into verify',
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
check: 'are-the-types-wrong',
|
|
738
|
+
status: 'optional-missing',
|
|
739
|
+
detail: '@arethetypeswrong/cli not configured',
|
|
740
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix attw` to validate TypeScript exports before publishing',
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
export async function checkPublint(_dir, pkg) {
|
|
744
|
+
if (!isPublishableLibrary(pkg)) {
|
|
745
|
+
return {
|
|
746
|
+
check: 'publint',
|
|
747
|
+
status: 'ok',
|
|
748
|
+
detail: 'not applicable (private or no published exports)',
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const deps = {
|
|
752
|
+
...(pkg?.dependencies ?? {}),
|
|
753
|
+
...(pkg?.devDependencies ?? {}),
|
|
754
|
+
};
|
|
755
|
+
const scripts = pkg?.scripts ?? {};
|
|
756
|
+
const hasDep = !!deps['publint'];
|
|
757
|
+
const hasScript = Object.values(scripts).some((s) => /\bpublint\b/.test(s));
|
|
758
|
+
if (hasDep && hasScript) {
|
|
759
|
+
return {
|
|
760
|
+
check: 'publint',
|
|
761
|
+
status: 'ok',
|
|
762
|
+
detail: 'publint installed and wired into a script',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
if (hasDep) {
|
|
766
|
+
return {
|
|
767
|
+
check: 'publint',
|
|
768
|
+
status: 'drift',
|
|
769
|
+
detail: 'publint installed but no script runs it',
|
|
770
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix publint` to add a `publint` script and wire it into verify',
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
check: 'publint',
|
|
775
|
+
status: 'optional-missing',
|
|
776
|
+
detail: 'publint not configured',
|
|
777
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix publint` to lint your package before publishing',
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** Who this package's README badges are for — feeds the base badge check (#309). */
|
|
781
|
+
export function jsBadgeAudience(pkg) {
|
|
782
|
+
if (!pkg || pkg.private === true)
|
|
783
|
+
return 'private';
|
|
784
|
+
return isPublishableLibrary(pkg) ? 'public' : 'not-applicable';
|
|
785
|
+
}
|
|
786
|
+
export async function checkTreeshakeSetup(dir, pkg) {
|
|
787
|
+
const appCheckPath = path.join(dir, 'apps', 'treeshake-check', 'check.mjs');
|
|
788
|
+
if (await fs.pathExists(appCheckPath)) {
|
|
789
|
+
return {
|
|
790
|
+
check: 'Tree-shake check',
|
|
791
|
+
status: 'ok',
|
|
792
|
+
detail: 'apps/treeshake-check/check.mjs found',
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
// Only nudge libraries that actually claim tree-shaking via multi-subpath exports + sideEffects: false.
|
|
796
|
+
const exports = pkg?.exports ?? {};
|
|
797
|
+
const subpaths = Object.keys(exports).filter((k) => k !== '.' && k.startsWith('./') && !k.includes('*'));
|
|
798
|
+
const sideEffectsFree = pkg?.sideEffects === false;
|
|
799
|
+
if (subpaths.length < 2 || !sideEffectsFree) {
|
|
800
|
+
return {
|
|
801
|
+
check: 'Tree-shake check',
|
|
802
|
+
status: 'ok',
|
|
803
|
+
detail: 'not applicable (single-export or has side effects)',
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
check: 'Tree-shake check',
|
|
808
|
+
status: 'optional-missing',
|
|
809
|
+
detail: `package exports ${subpaths.length} subpaths with sideEffects: false but no apps/treeshake-check/`,
|
|
810
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
export async function checkTurborepo(dir) {
|
|
814
|
+
const hasTurbo = await fs.pathExists(path.join(dir, 'turbo.json'));
|
|
815
|
+
const hasNx = await fs.pathExists(path.join(dir, 'nx.json'));
|
|
816
|
+
if (hasTurbo && hasNx) {
|
|
817
|
+
return {
|
|
818
|
+
check: 'Turborepo',
|
|
819
|
+
status: 'drift',
|
|
820
|
+
detail: 'both turbo.json and nx.json present',
|
|
821
|
+
hint: 'Pick one monorepo orchestrator — remove either turbo.json or nx.json',
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (hasTurbo)
|
|
825
|
+
return { check: 'Turborepo', status: 'ok', detail: 'turbo.json found' };
|
|
826
|
+
if (hasNx)
|
|
827
|
+
return { check: 'Turborepo', status: 'ok', detail: 'nx.json found (using Nx)' };
|
|
828
|
+
return {
|
|
829
|
+
check: 'Turborepo',
|
|
830
|
+
status: 'optional-missing',
|
|
831
|
+
detail: 'pnpm workspace without a task orchestrator',
|
|
832
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix turborepo` (or `fix nx`) to scaffold a task pipeline',
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* pnpm settings that would otherwise be hand-copied across the family (#314).
|
|
837
|
+
* Only reported for pnpm repos — an npm or yarn repo has no use for the file,
|
|
838
|
+
* and inventing one there would be noise.
|
|
839
|
+
*/
|
|
840
|
+
export async function checkPnpmWorkspace(dir, pkg) {
|
|
841
|
+
const check = 'pnpm settings';
|
|
842
|
+
const hint = `Run \`npx ${PACKAGE} fix pnpm-workspace\` to merge them in`;
|
|
843
|
+
const file = path.join(dir, WORKSPACE_FILE);
|
|
844
|
+
const exists = await fs.pathExists(file);
|
|
845
|
+
const usesPnpm = exists ||
|
|
846
|
+
(await fs.pathExists(path.join(dir, 'pnpm-lock.yaml'))) ||
|
|
847
|
+
(pkg?.packageManager ?? '').startsWith('pnpm');
|
|
848
|
+
if (!usesPnpm) {
|
|
849
|
+
return { check, status: 'ok', detail: 'not a pnpm repo' };
|
|
850
|
+
}
|
|
851
|
+
const yaml = exists ? await fs.readFile(file, 'utf-8') : '';
|
|
852
|
+
const missing = missingPnpmSettings(yaml, dependsOnEsbuild(allDeps(pkg)));
|
|
853
|
+
if (missing.length === 0) {
|
|
854
|
+
return { check, status: 'ok', detail: `${WORKSPACE_FILE} carries the managed settings` };
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
check,
|
|
858
|
+
// A repo with no workspace file at all hasn't drifted — it never opted in.
|
|
859
|
+
status: exists ? 'drift' : 'optional-missing',
|
|
860
|
+
detail: `missing ${missing.join(', ')}`,
|
|
861
|
+
hint,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/** The docs app dir (apps/docs or apps/doc) if a Docusaurus config lives there. */
|
|
865
|
+
export async function findDocsAppDir(dir) {
|
|
866
|
+
for (const app of ['apps/docs', 'apps/doc']) {
|
|
867
|
+
if (await fs.pathExists(path.join(dir, app, 'docusaurus.config.ts')))
|
|
868
|
+
return app;
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
// Only surfaced when a Docusaurus site exists (see runDoctor). Verifies the
|
|
873
|
+
// shared-asset wiring #54 standardizes: sync-changelog present + chained into
|
|
874
|
+
// the docs app's build/start, and the deploy artifact path is `build` (not
|
|
875
|
+
// `dist`). Opt-in, so a repo without a docs site never sees this.
|
|
876
|
+
export async function checkDocsSite(dir, docsAppDir) {
|
|
877
|
+
const check = 'Docs site';
|
|
878
|
+
const deltas = [];
|
|
879
|
+
const syncPath = path.join(dir, 'scripts', 'sync-changelog.mjs');
|
|
880
|
+
if (!(await fs.pathExists(syncPath))) {
|
|
881
|
+
deltas.push('scripts/sync-changelog.mjs missing');
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
const pkgPath = path.join(dir, docsAppDir, 'package.json');
|
|
885
|
+
if (await fs.pathExists(pkgPath)) {
|
|
886
|
+
try {
|
|
887
|
+
const docsPkg = (await fs.readJson(pkgPath));
|
|
888
|
+
const scripts = docsPkg.scripts ?? {};
|
|
889
|
+
const chained = ['build', 'start'].some((s) => typeof scripts[s] === 'string' && /sync-changelog/.test(scripts[s]));
|
|
890
|
+
// pnpm 8 doesn't run pre* hooks reliably, so build/start must chain it explicitly.
|
|
891
|
+
if (!chained)
|
|
892
|
+
deltas.push(`${docsAppDir} build/start does not chain sync-changelog`);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
// Unparseable package.json — a separate check owns that; skip here.
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// The GitHub Pages deploy must upload `apps/doc*/build` (Docusaurus emits
|
|
900
|
+
// `build/`, not `dist/`).
|
|
901
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
902
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
903
|
+
for (const file of await fs.readdir(workflowsDir)) {
|
|
904
|
+
if (!/\.ya?ml$/.test(file))
|
|
905
|
+
continue;
|
|
906
|
+
const body = await fs.readFile(path.join(workflowsDir, file), 'utf-8').catch(() => '');
|
|
907
|
+
if (new RegExp(`${docsAppDir}/dist`).test(body)) {
|
|
908
|
+
deltas.push(`${file} deploys ${docsAppDir}/dist (should be ${docsAppDir}/build)`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (deltas.length)
|
|
913
|
+
return {
|
|
914
|
+
check,
|
|
915
|
+
status: 'drift',
|
|
916
|
+
detail: deltas.join('; '),
|
|
917
|
+
hint: 'Align the docs site with the shared standard (sync-changelog wiring, build/ artifact path)',
|
|
918
|
+
};
|
|
919
|
+
return { check, status: 'ok', detail: `${docsAppDir} wired per standard` };
|
|
920
|
+
}
|
|
921
|
+
// Only called when `tailwindcss` is a dependency (see runDoctor) — Tailwind is
|
|
922
|
+
// opt-in per project, so nudging repos that don't use it would be noise. v4 is
|
|
923
|
+
// CSS-first: the wiring is a PostCSS plugin (or the Vite plugin), not a config
|
|
924
|
+
// file, so that's what we look for.
|
|
925
|
+
export async function checkTailwind(dir, pkg) {
|
|
926
|
+
const hasVitePlugin = '@tailwindcss/vite' in allDeps(pkg);
|
|
927
|
+
let postcssWired = false;
|
|
928
|
+
for (const candidate of ['postcss.config.mjs', 'postcss.config.js', 'postcss.config.cjs']) {
|
|
929
|
+
const p = path.join(dir, candidate);
|
|
930
|
+
if ((await fs.pathExists(p)) &&
|
|
931
|
+
(await fs.readFile(p, 'utf8')).includes('@tailwindcss/postcss')) {
|
|
932
|
+
postcssWired = true;
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (hasVitePlugin || postcssWired) {
|
|
937
|
+
return {
|
|
938
|
+
check: 'Tailwind',
|
|
939
|
+
status: 'ok',
|
|
940
|
+
detail: hasVitePlugin ? '@tailwindcss/vite configured' : '@tailwindcss/postcss configured',
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
check: 'Tailwind',
|
|
945
|
+
status: 'optional-missing',
|
|
946
|
+
detail: 'tailwindcss installed without a PostCSS (or Vite) plugin',
|
|
947
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix tailwind` to scaffold the v4 PostCSS wiring',
|
|
948
|
+
};
|
|
949
|
+
}
|