@rtorcato/repo-tooling 3.38.0 → 3.38.2
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/base/checks.js +6 -9
- package/dist/base/fixers.js +22 -6
- package/dist/cli/commands/doctor.js +18 -13
- package/dist/cli/commands/fix.js +59 -17
- package/dist/cli/generators/badges.js +23 -3
- package/dist/cli/generators/git.js +20 -7
- package/dist/cli/generators/security.js +37 -8
- package/dist/cli/generators/typedoc.js +7 -4
- package/dist/cli/utils/detect-language.js +25 -0
- package/dist/languages/js/checks.js +4 -1
- package/dist/languages/js/fixers.js +6 -1
- package/dist/languages/registry.js +5 -3
- package/package.json +1 -1
package/dist/base/checks.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
|
|
4
4
|
import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
|
|
5
5
|
import { claudeSkillStatus, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
|
|
6
|
-
import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
|
|
6
|
+
import { DEPENDABOT_CONFIG_PATHS, dependabotConfigDeltas, dependabotEcosystemFor, dependabotIgnoreRules, } from '../cli/generators/security.js';
|
|
7
7
|
import { detectNestedLanguages } from '../cli/utils/detect-language.js';
|
|
8
8
|
/**
|
|
9
9
|
* Root-only detection is the decision (#317), not an oversight — but it used to
|
|
@@ -244,12 +244,7 @@ export async function checkDependabot(dir) {
|
|
|
244
244
|
// depends on it. Flag any config that predates it so `fix dependabot` can
|
|
245
245
|
// bring the pair up to standard.
|
|
246
246
|
const content = await fs.readFile(candidatePath, 'utf8');
|
|
247
|
-
const deltas =
|
|
248
|
-
for (const group of ['production-minor', 'dev-minor', 'major-updates']) {
|
|
249
|
-
if (!new RegExp(`^\\s*${group}:`, 'm').test(content)) {
|
|
250
|
-
deltas.push(`missing \`${group}\` group`);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
247
|
+
const deltas = dependabotConfigDeltas(content, await dependabotEcosystemFor(dir));
|
|
253
248
|
const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
|
|
254
249
|
if (await fs.pathExists(automergePath)) {
|
|
255
250
|
// Pre-#423 workflows gated on the semver type alone, so production
|
|
@@ -720,7 +715,9 @@ export async function checkPrePushHook(dir, p) {
|
|
|
720
715
|
}
|
|
721
716
|
export async function checkReadmeBadges(dir, audience,
|
|
722
717
|
/** `fix` target that rebuilds the badge block, or null when the language has none. */
|
|
723
|
-
fixTarget
|
|
718
|
+
fixTarget,
|
|
719
|
+
/** The repo's own package name — only its badges can 404; others' resolve fine (#634). */
|
|
720
|
+
name) {
|
|
724
721
|
const check = 'README badges';
|
|
725
722
|
const hint = fixTarget
|
|
726
723
|
? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to add CI/npm/coverage/license badges`
|
|
@@ -729,7 +726,7 @@ fixTarget) {
|
|
|
729
726
|
const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
|
|
730
727
|
if (audience === 'private') {
|
|
731
728
|
// Only a problem if a private/app repo carries badges that would 404.
|
|
732
|
-
if (readme && hasPublicOnlyBadges(readme)) {
|
|
729
|
+
if (readme && hasPublicOnlyBadges(readme, name)) {
|
|
733
730
|
return {
|
|
734
731
|
check,
|
|
735
732
|
status: 'drift',
|
package/dist/base/fixers.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import chalk from 'chalk';
|
|
17
|
+
import fs from 'fs-extra';
|
|
17
18
|
import inquirer from 'inquirer';
|
|
18
19
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
19
20
|
import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
|
|
@@ -21,10 +22,10 @@ import { generateBrand } from '../cli/generators/brand.js';
|
|
|
21
22
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
22
23
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
23
24
|
import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
|
|
24
|
-
import { findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
|
|
25
|
+
import { DEPENDABOT_AUTOMERGE_WORKFLOW, dependabotConfigDeltas, dependabotEcosystemFor, findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, readDependabotConfig, } from '../cli/generators/security.js';
|
|
25
26
|
import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
|
|
26
27
|
import { copyPreset } from '../cli/utils/copy-preset.js';
|
|
27
|
-
import {
|
|
28
|
+
import { detectAuditLanguage } from '../cli/utils/detect-language.js';
|
|
28
29
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
29
30
|
import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
|
|
30
31
|
import { applyLoopLabels } from './labels.js';
|
|
@@ -46,9 +47,16 @@ export class FixerAbort extends Error {
|
|
|
46
47
|
this.name = 'FixerAbort';
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The repo's language module, resolved from its marker files. No marker → no
|
|
52
|
+
* manifest ecosystem and no CodeQL matrix, rather than npm/JS guessed (#632).
|
|
53
|
+
*/
|
|
50
54
|
async function moduleFor(targetDir) {
|
|
51
|
-
return resolveLanguageModule(await
|
|
55
|
+
return (resolveLanguageModule(await detectAuditLanguage(targetDir)) ?? {
|
|
56
|
+
label: 'unrecognized-language',
|
|
57
|
+
codeqlLanguages: [],
|
|
58
|
+
dependabotEcosystem: null,
|
|
59
|
+
});
|
|
52
60
|
}
|
|
53
61
|
/**
|
|
54
62
|
* Where to install user-global skills, asking only when nothing resolves. Under
|
|
@@ -149,6 +157,15 @@ export const BASE_FIXERS = [
|
|
|
149
157
|
outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
|
|
150
158
|
canFixDrift: true,
|
|
151
159
|
async run({ targetDir }) {
|
|
160
|
+
const ecosystem = await dependabotEcosystemFor(targetDir);
|
|
161
|
+
// The config is already canonical for this repo's ecosystem, so only the
|
|
162
|
+
// workflow half drifts. Leave the config — and its repo-authored comments
|
|
163
|
+
// — alone rather than regenerate a file with nothing to change (#631).
|
|
164
|
+
const existing = await readDependabotConfig(targetDir);
|
|
165
|
+
if (existing && dependabotConfigDeltas(existing.content, ecosystem).length === 0) {
|
|
166
|
+
await fs.outputFile(path.join(targetDir, '.github', 'workflows', 'dependabot-automerge.yml'), DEPENDABOT_AUTOMERGE_WORKFLOW);
|
|
167
|
+
return { filesWritten: ['.github/workflows/dependabot-automerge.yml'] };
|
|
168
|
+
}
|
|
152
169
|
// The template owns the whole file but emits no `ignore:` block, so
|
|
153
170
|
// regenerating deletes any repo-local ignore rule — silently, and with
|
|
154
171
|
// nothing in `doctor` to report the loss afterwards, because from the
|
|
@@ -159,8 +176,7 @@ export const BASE_FIXERS = [
|
|
|
159
176
|
if (ignored) {
|
|
160
177
|
throw new FixerAbort('dependabot-ignore-rules', `refusing to overwrite ${ignored.file} — it has ${ignored.rules.length} \`ignore:\` rule(s) the canonical config does not reproduce: ${ignored.rules.join(', ')}`, `re-add the \`ignore:\` block after regenerating, or delete it from ${ignored.file} to accept the loss — then re-run \`fix dependabot\``);
|
|
161
178
|
}
|
|
162
|
-
|
|
163
|
-
return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
|
|
179
|
+
return { filesWritten: await generateDependabotConfig(targetDir, ecosystem) };
|
|
164
180
|
},
|
|
165
181
|
},
|
|
166
182
|
{
|
|
@@ -11,7 +11,7 @@ import { readPyproject, renderPythonWorkflow } from '../../languages/python/ci.j
|
|
|
11
11
|
import { resolveLanguageModule } from '../../languages/registry.js';
|
|
12
12
|
import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js';
|
|
13
13
|
import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
|
|
14
|
-
import {
|
|
14
|
+
import { detectAuditLanguage } from '../utils/detect-language.js';
|
|
15
15
|
import { checkAgentUser } from '../../base/agent-user.js';
|
|
16
16
|
import { checkGitHubSettings } from '../../base/github-settings.js';
|
|
17
17
|
import { checkLoopLabels } from '../../base/labels.js';
|
|
@@ -244,7 +244,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
244
244
|
if (lock?.rules?.mcp?.recommended?.length) {
|
|
245
245
|
results.push(await checkRecommendedMcp(dir, lock.rules.mcp.recommended));
|
|
246
246
|
}
|
|
247
|
-
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget));
|
|
247
|
+
results.push(await checkReadmeBadges(dir, opts.badges.audience, opts.badges.fixTarget, opts.badges.name));
|
|
248
248
|
results.push(await checkCoverageUpload(dir));
|
|
249
249
|
return results;
|
|
250
250
|
}
|
|
@@ -253,20 +253,21 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
253
253
|
const lock = await readLockfile(targetDir);
|
|
254
254
|
// Per-module dispatch (#285): the base checks (repo hygiene, CI, security,
|
|
255
255
|
// GitHub settings) apply to any repo and run for every language. A supported
|
|
256
|
-
// module layers its own checks on top. 'unknown' (
|
|
257
|
-
//
|
|
258
|
-
const language = await
|
|
256
|
+
// module layers its own checks on top. 'unknown' (no marker) gets the base
|
|
257
|
+
// suite only — JS checks against a C++ or Go repo are all noise (#632).
|
|
258
|
+
const language = await detectAuditLanguage(targetDir);
|
|
259
259
|
const languageModule = resolveLanguageModule(language);
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// with `supported: false` first, and until its module lands its repos get the
|
|
260
|
+
// Also the on-ramp for a new language: it is added to the registry with
|
|
261
|
+
// `supported: false` first, and until its module lands its repos get the
|
|
263
262
|
// full base suite rather than the wholesale skip this replaced.
|
|
264
|
-
if (!languageModule
|
|
263
|
+
if (!languageModule?.supported) {
|
|
265
264
|
const results = [
|
|
266
265
|
{
|
|
267
266
|
check: 'language',
|
|
268
267
|
status: 'ok',
|
|
269
|
-
detail:
|
|
268
|
+
detail: languageModule
|
|
269
|
+
? `detected ${languageModule.label} — running language-agnostic checks; ${languageModule.label}-specific checks land with its module (#139)`
|
|
270
|
+
: 'no language marker (package.json, Package.swift, pyproject.toml, cpanfile, …) — running language-agnostic checks only',
|
|
270
271
|
},
|
|
271
272
|
// hooks: null — guessing a hook convention would nag every repo with a
|
|
272
273
|
// fix target that doesn't exist.
|
|
@@ -275,7 +276,7 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
275
276
|
badges: { audience: 'public', fixTarget: null },
|
|
276
277
|
presetWorkflow: null,
|
|
277
278
|
language,
|
|
278
|
-
codeqlLanguages: languageModule
|
|
279
|
+
codeqlLanguages: languageModule?.codeqlLanguages ?? [],
|
|
279
280
|
skillsDir,
|
|
280
281
|
})),
|
|
281
282
|
];
|
|
@@ -407,7 +408,11 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
407
408
|
}
|
|
408
409
|
results.push(...(await runBaseChecks(targetDir, lock, {
|
|
409
410
|
hooks: jsGitHooksProfile(pkg),
|
|
410
|
-
badges: {
|
|
411
|
+
badges: {
|
|
412
|
+
audience: jsBadgeAudience(pkg),
|
|
413
|
+
fixTarget: 'badges',
|
|
414
|
+
name: typeof pkg?.name === 'string' ? pkg.name : undefined,
|
|
415
|
+
},
|
|
411
416
|
// Same `scripts` gating the fixer applies, or the workflow doctor
|
|
412
417
|
// compares against would reference steps the fixer never writes (#364).
|
|
413
418
|
presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg) })),
|
|
@@ -521,7 +526,7 @@ export async function doctorCommand(options = {}) {
|
|
|
521
526
|
const summary = summarize(results);
|
|
522
527
|
console.log();
|
|
523
528
|
console.log(` Summary: ${chalk.green(`${summary.ok} ok`)}, ${chalk.yellow(`${summary.drift} drift`)}, ${chalk.red(`${summary.missing} missing`)}, ${chalk.gray(`${summary.optionalMissing} not configured`)}, ${chalk.blue(`${summary.declared} declared`)}\n`);
|
|
524
|
-
const suggestions = nextStepSuggestions(results, await
|
|
529
|
+
const suggestions = nextStepSuggestions(results, await detectAuditLanguage(dir));
|
|
525
530
|
if (suggestions.length > 0) {
|
|
526
531
|
console.log(chalk.bold(' Next steps:'));
|
|
527
532
|
for (const s of suggestions) {
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -14,20 +14,21 @@ import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
|
|
|
14
14
|
import { PERL_FIXERS } from '../../languages/perl/fixers.js';
|
|
15
15
|
import { PYTHON_FIXERS } from '../../languages/python/fixers.js';
|
|
16
16
|
import { SWIFT_FIXERS } from '../../languages/swift/fixers.js';
|
|
17
|
-
import {
|
|
17
|
+
import { detectAuditLanguage } from '../utils/detect-language.js';
|
|
18
18
|
/** The module-specific fixer set per detected language (#286, #289, #290, #303). */
|
|
19
19
|
const LANGUAGE_FIXERS = {
|
|
20
|
+
js: FIXERS,
|
|
20
21
|
swift: SWIFT_FIXERS,
|
|
21
22
|
python: PYTHON_FIXERS,
|
|
22
23
|
perl: PERL_FIXERS,
|
|
23
24
|
};
|
|
24
25
|
/**
|
|
25
26
|
* The fixers that apply to a repo, by detected language: the language-agnostic
|
|
26
|
-
* base set plus the module's own.
|
|
27
|
-
*
|
|
27
|
+
* base set plus the module's own. A repo with no marker gets the base set only —
|
|
28
|
+
* the JS fixers would run against a missing package.json (#632).
|
|
28
29
|
*/
|
|
29
30
|
function fixersForLanguage(language) {
|
|
30
|
-
return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ??
|
|
31
|
+
return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ?? [])];
|
|
31
32
|
}
|
|
32
33
|
/** Every fixer across every language — for `--list` and the unknown-target hint. */
|
|
33
34
|
const ALL_FIXERS = [
|
|
@@ -133,8 +134,16 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
|
|
|
133
134
|
},
|
|
134
135
|
});
|
|
135
136
|
// assumeYes: a preview must never prompt.
|
|
137
|
+
let written;
|
|
136
138
|
try {
|
|
137
|
-
|
|
139
|
+
;
|
|
140
|
+
({ filesWritten: written } = await fixer.run({
|
|
141
|
+
targetDir: tmpDir,
|
|
142
|
+
pkg,
|
|
143
|
+
result,
|
|
144
|
+
lock,
|
|
145
|
+
assumeYes: true,
|
|
146
|
+
}));
|
|
138
147
|
}
|
|
139
148
|
catch (err) {
|
|
140
149
|
// A fixer that refuses has nothing to preview. Swallow it here rather than
|
|
@@ -146,14 +155,21 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
|
|
|
146
155
|
}
|
|
147
156
|
const previews = [];
|
|
148
157
|
const seen = new Set();
|
|
149
|
-
|
|
158
|
+
// Declared outputs miss globbed writes (apps/docs/**), so also take what the
|
|
159
|
+
// shadow run reported writing.
|
|
160
|
+
const candidates = [
|
|
161
|
+
...fixer.outputs,
|
|
162
|
+
...written.map((f) => (path.isAbsolute(f) ? path.relative(tmpDir, f) : f)),
|
|
163
|
+
];
|
|
164
|
+
for (const output of candidates) {
|
|
150
165
|
const rel = outputToRelativePath(output);
|
|
151
|
-
if (seen.has(rel))
|
|
166
|
+
if (seen.has(rel) || rel.startsWith('..'))
|
|
152
167
|
continue;
|
|
153
168
|
seen.add(rel);
|
|
154
169
|
const tmpPath = path.join(tmpDir, rel);
|
|
155
170
|
const realPath = path.join(targetDir, rel);
|
|
156
|
-
|
|
171
|
+
const stat = await fs.stat(tmpPath).catch(() => null);
|
|
172
|
+
if (!stat?.isFile())
|
|
157
173
|
continue;
|
|
158
174
|
const newContent = await fs.readFile(tmpPath, 'utf-8');
|
|
159
175
|
const existed = await fs.pathExists(realPath);
|
|
@@ -197,12 +213,34 @@ function printPreviews(previews) {
|
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* What a fixer would actually change. Shadow-runs it like `--diff` does, except
|
|
218
|
+
* for safe-add fixers: some of those write remote state or the home dir, so they
|
|
219
|
+
* are never executed — and since they only create missing files, the declared
|
|
220
|
+
* outputs that already exist are dropped instead.
|
|
221
|
+
*/
|
|
222
|
+
async function dryRunFiles(fixer, result, targetDir, pkg, lock) {
|
|
223
|
+
if ((fixer.riskLevel ?? 'destructive') === 'safe-add') {
|
|
224
|
+
const missing = [];
|
|
225
|
+
for (const out of fixer.outputs) {
|
|
226
|
+
if (!(await fs.pathExists(path.join(targetDir, outputToRelativePath(out))))) {
|
|
227
|
+
missing.push(out);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return missing;
|
|
231
|
+
}
|
|
232
|
+
const previews = await previewFixer(fixer, result, targetDir, pkg, lock);
|
|
233
|
+
return previews.filter((p) => p.kind !== 'unchanged').map((p) => p.path);
|
|
234
|
+
}
|
|
200
235
|
async function applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, opts) {
|
|
201
236
|
if (dryRun) {
|
|
237
|
+
const files = await dryRunFiles(fixer, result, targetDir, pkg, lock);
|
|
202
238
|
if (!silent) {
|
|
203
|
-
console.log(chalk.cyan(
|
|
239
|
+
console.log(chalk.cyan(files.length > 0
|
|
240
|
+
? ` [dry-run] would write: ${files.join(', ')}`
|
|
241
|
+
: ' [dry-run] nothing to write'));
|
|
204
242
|
}
|
|
205
|
-
return { filesWritten:
|
|
243
|
+
return { filesWritten: files, dryRun: true };
|
|
206
244
|
}
|
|
207
245
|
const { filesWritten } = await fixer.run({ targetDir, pkg, result, lock, ...opts });
|
|
208
246
|
if (!silent && filesWritten.length > 0) {
|
|
@@ -332,7 +370,7 @@ export async function fixCommand(target, options = {}) {
|
|
|
332
370
|
}
|
|
333
371
|
const pkg = await readPackageJson(targetDir);
|
|
334
372
|
const lock = await readLockfile(targetDir);
|
|
335
|
-
const fixers = fixersForLanguage(await
|
|
373
|
+
const fixers = fixersForLanguage(await detectAuditLanguage(targetDir));
|
|
336
374
|
// Same --skills-dir the claude-skills fixer writes to, so the diagnosis fix
|
|
337
375
|
// acts on and the install it performs agree on one directory (#485).
|
|
338
376
|
const results = await runDoctor(targetDir, options.skillsDir);
|
|
@@ -489,7 +527,8 @@ export async function fixCommand(target, options = {}) {
|
|
|
489
527
|
// A fixer that refuses (e.g. dependabot, when the existing config carries
|
|
490
528
|
// repo-local `ignore:` rules the template can't reproduce) is a skip, not a
|
|
491
529
|
// crash — the remaining findings still deserve their fixers. The reason goes
|
|
492
|
-
// to stderr so it survives `--json`.
|
|
530
|
+
// to stderr so it survives `--json`. An unexpected error is skipped the same
|
|
531
|
+
// way (#636): rethrowing it killed the walk mid-run with no JSON on stdout.
|
|
493
532
|
let outcome;
|
|
494
533
|
try {
|
|
495
534
|
outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
|
|
@@ -499,12 +538,15 @@ export async function fixCommand(target, options = {}) {
|
|
|
499
538
|
});
|
|
500
539
|
}
|
|
501
540
|
catch (err) {
|
|
502
|
-
if (!(err instanceof FixerAbort))
|
|
503
|
-
throw err;
|
|
504
541
|
actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
542
|
+
if (err instanceof FixerAbort) {
|
|
543
|
+
console.error(chalk.red(` refused — ${err.message}`));
|
|
544
|
+
if (err.hint)
|
|
545
|
+
console.error(chalk.gray(` ${err.hint}`));
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
console.error(chalk.red(` failed — ${err instanceof Error ? err.message : String(err)}`));
|
|
549
|
+
}
|
|
508
550
|
skippedCount++;
|
|
509
551
|
continue;
|
|
510
552
|
}
|
|
@@ -56,9 +56,29 @@ export function buildBadgeBlock(inputs) {
|
|
|
56
56
|
const row = buildBadgeRow(inputs);
|
|
57
57
|
return row ? `${BADGE_START}\n${row}\n${BADGE_END}` : '';
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
/**
|
|
60
|
+
* True when the text carries npm/bundlephobia/codecov badges (which 404 on
|
|
61
|
+
* private/unpublished). Given `name`, npm/bundlephobia badges count only when
|
|
62
|
+
* they point at that package — a README may advertise *other* packages (#634).
|
|
63
|
+
*/
|
|
64
|
+
export function hasPublicOnlyBadges(text, name) {
|
|
65
|
+
if (/codecov\.io/.test(text))
|
|
66
|
+
return true;
|
|
67
|
+
if (!name)
|
|
68
|
+
return /img\.shields\.io\/npm\/|bundlephobia/.test(text);
|
|
69
|
+
const refs = text.matchAll(/(?:img\.shields\.io\/npm\/[\w-]+|bundlephobia\/[\w-]+|bundlephobia\.com\/package)\/([^\s)"'?#]+)/g);
|
|
70
|
+
for (const [, raw = ''] of refs) {
|
|
71
|
+
let ref = raw;
|
|
72
|
+
try {
|
|
73
|
+
ref = decodeURIComponent(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Malformed escape — compare the raw text.
|
|
77
|
+
}
|
|
78
|
+
if (ref === name || ref.startsWith(`${name}/`))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
62
82
|
}
|
|
63
83
|
/**
|
|
64
84
|
* Insert or refresh the badge block in a README. Replaces an existing delimited
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { hookHasUncommented } from '../../base/checks.js';
|
|
3
4
|
export const PRE_PUSH_HOOK_CONTENT = `echo "🔍 Running pre-push verify..."
|
|
4
5
|
pnpm verify
|
|
5
6
|
STATUS=$?
|
|
@@ -25,10 +26,7 @@ export async function generateHuskyConfig(config, targetDir) {
|
|
|
25
26
|
// Pre-commit hook. husky v10 format: just the command — the v9 shebang +
|
|
26
27
|
// `. "$(dirname ...)/_/husky.sh"` bootstrap is deprecated (warns on every
|
|
27
28
|
// hook run in v9, fails outright in v10).
|
|
28
|
-
|
|
29
|
-
const preCommitContent = 'npx lint-staged\n';
|
|
30
|
-
await fs.writeFile(preCommitPath, preCommitContent);
|
|
31
|
-
await fs.chmod(preCommitPath, 0o755);
|
|
29
|
+
await ensureHookRuns(path.join(huskyDir, 'pre-commit'), /\blint-staged\b/, 'npx lint-staged\n');
|
|
32
30
|
// Pre-push hook — only when the package.json already has a `verify` script.
|
|
33
31
|
// In the setup flow, generatePackageJson runs before this and writes verify
|
|
34
32
|
// when 2+ tools are enabled. In the `fix husky` path, a pre-existing verify
|
|
@@ -63,9 +61,24 @@ export async function generateHuskyConfig(config, targetDir) {
|
|
|
63
61
|
export async function generatePrePushHook(targetDir) {
|
|
64
62
|
const huskyDir = path.join(targetDir, '.husky');
|
|
65
63
|
await fs.ensureDir(huskyDir);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
await ensureHookRuns(path.join(huskyDir, 'pre-push'), /\bpnpm\s+verify\b/, PRE_PUSH_HOOK_CONTENT, 'pnpm verify\n');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Write a missing hook; append the command to an existing hook that lacks it.
|
|
68
|
+
* Never replaces a hand-written hook (#629). Husky runs hooks with `sh -e`, so
|
|
69
|
+
* an appended command still fails the hook when it fails.
|
|
70
|
+
*/
|
|
71
|
+
async function ensureHookRuns(hookPath, runs, fresh, line = fresh) {
|
|
72
|
+
if (!(await fs.pathExists(hookPath))) {
|
|
73
|
+
await fs.writeFile(hookPath, fresh);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const existing = await fs.readFile(hookPath, 'utf-8');
|
|
77
|
+
if (hookHasUncommented(existing, runs))
|
|
78
|
+
return;
|
|
79
|
+
await fs.writeFile(hookPath, `${existing.replace(/\n*$/, '\n')}${line}`);
|
|
80
|
+
}
|
|
81
|
+
await fs.chmod(hookPath, 0o755);
|
|
69
82
|
}
|
|
70
83
|
/** Package versions the commit-msg hook needs on disk to run at all. */
|
|
71
84
|
export const COMMITLINT_DEPS = {
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { renderCodeQLWorkflow } from '../../base/ci.js';
|
|
4
4
|
import { LANGUAGES } from '../../languages/registry.js';
|
|
5
|
+
import { detectLanguage } from '../utils/detect-language.js';
|
|
5
6
|
/**
|
|
6
7
|
* The canonical dependency-update standard shared by every @rtorcato repo.
|
|
7
8
|
*
|
|
@@ -73,6 +74,37 @@ export const DEPENDABOT_CONFIG_PATHS = [
|
|
|
73
74
|
'.github/dependabot.yml',
|
|
74
75
|
'.github/dependabot.yaml',
|
|
75
76
|
];
|
|
77
|
+
/**
|
|
78
|
+
* The manifest ecosystem a repo's dependabot.yml should cover, or null when it
|
|
79
|
+
* has none — Perl, or a repo with no recognised manifest at all (#631). Unlike
|
|
80
|
+
* `resolveLanguageModule`, `unknown` does not fall back to npm here: a C++ repo
|
|
81
|
+
* with no package.json has nothing for an npm block to update.
|
|
82
|
+
*/
|
|
83
|
+
export async function dependabotEcosystemFor(dir) {
|
|
84
|
+
const language = await detectLanguage(dir);
|
|
85
|
+
return language === 'unknown' ? null : LANGUAGES[language].dependabotEcosystem;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* How an existing dependabot.yml falls short of `dependabotConfig(ecosystem)`.
|
|
89
|
+
* The canonical groups live on the manifest block, so a repo with no manifest
|
|
90
|
+
* ecosystem is not expected to carry them (#631).
|
|
91
|
+
*/
|
|
92
|
+
export function dependabotConfigDeltas(content, ecosystem) {
|
|
93
|
+
if (ecosystem === null)
|
|
94
|
+
return [];
|
|
95
|
+
return ['production-minor', 'dev-minor', 'major-updates']
|
|
96
|
+
.filter((group) => !new RegExp(`^\\s*${group}:`, 'm').test(content))
|
|
97
|
+
.map((group) => `missing \`${group}\` group`);
|
|
98
|
+
}
|
|
99
|
+
/** The existing dependabot config, in Dependabot's resolution order, or null. */
|
|
100
|
+
export async function readDependabotConfig(dir) {
|
|
101
|
+
for (const file of DEPENDABOT_CONFIG_PATHS) {
|
|
102
|
+
const candidate = path.join(dir, file);
|
|
103
|
+
if (await fs.pathExists(candidate))
|
|
104
|
+
return { file, content: await fs.readFile(candidate, 'utf8') };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
76
108
|
/**
|
|
77
109
|
* The `ignore:` rules an existing dependabot.yml carries, named by
|
|
78
110
|
* `dependency-name` (#422).
|
|
@@ -236,14 +268,11 @@ export const DEPENDABOT_FILES = [
|
|
|
236
268
|
* they live in — or null when there is nothing to lose (#422).
|
|
237
269
|
*/
|
|
238
270
|
export async function findDependabotIgnoreRules(targetDir) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return rules.length > 0 ? { file, rules } : null;
|
|
245
|
-
}
|
|
246
|
-
return null;
|
|
271
|
+
const existing = await readDependabotConfig(targetDir);
|
|
272
|
+
if (!existing)
|
|
273
|
+
return null;
|
|
274
|
+
const rules = dependabotIgnoreRules(existing.content);
|
|
275
|
+
return rules.length > 0 ? { file: existing.file, rules } : null;
|
|
247
276
|
}
|
|
248
277
|
/**
|
|
249
278
|
* Scaffold the canonical Dependabot setup: the grouped \`dependabot.yml\` **and**
|
|
@@ -32,9 +32,12 @@ export async function generateTypedocConfig(pkg, targetDir) {
|
|
|
32
32
|
};
|
|
33
33
|
await fs.writeJson(path.join(targetDir, 'typedoc.json'), config, { spaces: 2 });
|
|
34
34
|
}
|
|
35
|
+
/** The path written, or null when a docs.yml already exists — never replaced (#629). */
|
|
35
36
|
export async function generateTypedocWorkflow(targetDir) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
await fs.
|
|
39
|
-
|
|
37
|
+
const rel = '.github/workflows/docs.yml';
|
|
38
|
+
const workflowPath = path.join(targetDir, rel);
|
|
39
|
+
if (await fs.pathExists(workflowPath))
|
|
40
|
+
return null;
|
|
41
|
+
await fs.outputFile(workflowPath, DOCS_WORKFLOW);
|
|
42
|
+
return rel;
|
|
40
43
|
}
|
|
@@ -5,6 +5,10 @@ import fs from 'fs-extra';
|
|
|
5
5
|
* wins. One language per repo root; multi-language monorepos stay out of scope
|
|
6
6
|
* (#139, reaffirmed in #317 — the audit is root-only, but says so via
|
|
7
7
|
* `detectNestedLanguages`). A dir with no marker → 'unknown' (base checks only).
|
|
8
|
+
*
|
|
9
|
+
* One exception to the order: `Package.swift` beats a `package.json` that ships
|
|
10
|
+
* no JS (no `main`/`exports`/`bin`) — a Swift package whose root `package.json`
|
|
11
|
+
* only drives its docs site is a Swift repo (#633).
|
|
8
12
|
*/
|
|
9
13
|
const MARKERS = [
|
|
10
14
|
['js', ['package.json']],
|
|
@@ -12,7 +16,14 @@ const MARKERS = [
|
|
|
12
16
|
['perl', ['cpanfile', 'Makefile.PL', 'dist.ini']],
|
|
13
17
|
['python', ['pyproject.toml', 'setup.py']],
|
|
14
18
|
];
|
|
19
|
+
/** True when `package.json` declares a JS entry point — the repo ships JS, not just tooling. */
|
|
20
|
+
async function shipsJs(dir) {
|
|
21
|
+
const pkg = await fs.readJson(path.join(dir, 'package.json')).catch(() => null);
|
|
22
|
+
return Boolean(pkg && (pkg.main || pkg.exports || pkg.bin));
|
|
23
|
+
}
|
|
15
24
|
export async function detectLanguage(dir) {
|
|
25
|
+
if ((await fs.pathExists(path.join(dir, 'Package.swift'))) && !(await shipsJs(dir)))
|
|
26
|
+
return 'swift';
|
|
16
27
|
for (const [language, candidates] of MARKERS) {
|
|
17
28
|
for (const candidate of candidates) {
|
|
18
29
|
if (await fs.pathExists(path.join(dir, candidate)))
|
|
@@ -21,6 +32,20 @@ export async function detectLanguage(dir) {
|
|
|
21
32
|
}
|
|
22
33
|
return 'unknown';
|
|
23
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The language doctor/fix audit a repo as. Like `detectLanguage`, except a fresh
|
|
37
|
+
* dir — nothing but dotfiles (`.git`, the lockfile) — is audited as JS, the
|
|
38
|
+
* default `setup` scaffolds, so its full suite shows what is still to come. A
|
|
39
|
+
* dir with real content and no marker (C++, Go, a README) stays 'unknown' and
|
|
40
|
+
* gets the base checks only (#632).
|
|
41
|
+
*/
|
|
42
|
+
export async function detectAuditLanguage(dir) {
|
|
43
|
+
const language = await detectLanguage(dir);
|
|
44
|
+
if (language !== 'unknown')
|
|
45
|
+
return language;
|
|
46
|
+
const entries = await fs.readdir(dir).catch(() => []);
|
|
47
|
+
return entries.every((entry) => entry.startsWith('.')) ? 'js' : 'unknown';
|
|
48
|
+
}
|
|
24
49
|
/** Never worth descending into, and expensive when we do. */
|
|
25
50
|
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'target', 'vendor', '.build']);
|
|
26
51
|
/**
|
|
@@ -1147,7 +1147,10 @@ export async function checkExportsBuildable(dir, pkg, exec) {
|
|
|
1147
1147
|
}
|
|
1148
1148
|
/** Who this package's README badges are for — feeds the base badge check (#309). */
|
|
1149
1149
|
export function jsBadgeAudience(pkg) {
|
|
1150
|
-
|
|
1150
|
+
// No package.json: nothing here is published, so no npm badge can be ours (#634).
|
|
1151
|
+
if (!pkg)
|
|
1152
|
+
return 'not-applicable';
|
|
1153
|
+
if (pkg.private === true)
|
|
1151
1154
|
return 'private';
|
|
1152
1155
|
return isPublishableLibrary(pkg) ? 'public' : 'not-applicable';
|
|
1153
1156
|
}
|
|
@@ -304,6 +304,11 @@ export const FIXERS = [
|
|
|
304
304
|
canFixDrift: true,
|
|
305
305
|
async run({ targetDir, pkg }) {
|
|
306
306
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
307
|
+
// Refuse before writing any hook: lint-staged lives in package.json, so a
|
|
308
|
+
// repo without a readable one cannot be wired (#636).
|
|
309
|
+
if (!pkg) {
|
|
310
|
+
throw new FixerAbort('no-package-json', 'no readable package.json — husky needs one for lint-staged', 'add a package.json, then re-run');
|
|
311
|
+
}
|
|
307
312
|
const existingLintStaged = pkg?.['lint-staged'] ?? {};
|
|
308
313
|
await generateHuskyConfig(inferProjectConfig(pkg), targetDir);
|
|
309
314
|
const updated = (await fs.readJson(pkgPath));
|
|
@@ -690,7 +695,7 @@ export const FIXERS = [
|
|
|
690
695
|
await generateTypedocConfig(pkg, targetDir);
|
|
691
696
|
const workflow = await generateTypedocWorkflow(targetDir);
|
|
692
697
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
693
|
-
const filesWritten = ['typedoc.json', workflow];
|
|
698
|
+
const filesWritten = workflow ? ['typedoc.json', workflow] : ['typedoc.json'];
|
|
694
699
|
if (await fs.pathExists(pkgPath)) {
|
|
695
700
|
const pkgData = (await fs.readJson(pkgPath));
|
|
696
701
|
const scripts = pkgData.scripts ?? {};
|
|
@@ -29,9 +29,11 @@ export const LANGUAGES = {
|
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
32
|
-
* Resolve the module for a detected language
|
|
33
|
-
*
|
|
32
|
+
* Resolve the module for a detected language, or null for `unknown` — a repo
|
|
33
|
+
* with no marker (C++, Go, a README) gets the base suite only, never JS (#632).
|
|
34
|
+
* A fresh, empty dir never arrives as `unknown`: `detectAuditLanguage` audits
|
|
35
|
+
* it as JS, and setup's language picker defaults it to JS itself.
|
|
34
36
|
*/
|
|
35
37
|
export function resolveLanguageModule(language) {
|
|
36
|
-
return language === 'unknown' ?
|
|
38
|
+
return language === 'unknown' ? null : LANGUAGES[language];
|
|
37
39
|
}
|
package/package.json
CHANGED