@rtorcato/repo-tooling 3.13.2 → 3.14.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 +2 -0
- package/dist/base/checks.js +24 -5
- package/dist/base/fixers.js +30 -1
- package/dist/base/github-settings.js +269 -0
- package/dist/cli/commands/doctor.js +4 -0
- package/dist/cli/commands/fix-targets.js +1 -0
- package/dist/cli/commands/fix.js +35 -9
- package/dist/cli/generators/security.js +90 -9
- package/dist/cli/utils/copied-assets.js +79 -0
- package/dist/cli/utils/copy-preset.js +23 -0
- package/dist/cli/utils/lockfile.js +26 -3
- package/package.json +2 -1
- package/tooling/docusaurus/docs-helpers.mjs +109 -0
package/AGENTS.md
CHANGED
|
@@ -95,6 +95,8 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
|
|
|
95
95
|
|
|
96
96
|
Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
|
|
97
97
|
|
|
98
|
+
A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
|
|
99
|
+
|
|
98
100
|
## Source-of-truth files in the repo
|
|
99
101
|
|
|
100
102
|
- `src/cli/commands/setup.ts` — `ProjectConfig` interface and the setup orchestrator
|
package/dist/base/checks.js
CHANGED
|
@@ -3,6 +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_SKILL } from '../cli/generators/claude-skills.js';
|
|
6
|
+
import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
|
|
6
7
|
import { detectNestedLanguages } from '../cli/utils/detect-language.js';
|
|
7
8
|
/**
|
|
8
9
|
* Root-only detection is the decision (#317), not an oversight — but it used to
|
|
@@ -235,7 +236,7 @@ export async function checkGitHubActions(dir, preset = null) {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
export async function checkDependabot(dir) {
|
|
238
|
-
for (const candidate of
|
|
239
|
+
for (const candidate of DEPENDABOT_CONFIG_PATHS) {
|
|
239
240
|
const candidatePath = path.join(dir, candidate);
|
|
240
241
|
if (await fs.pathExists(candidatePath)) {
|
|
241
242
|
// The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
|
|
@@ -250,21 +251,39 @@ export async function checkDependabot(dir) {
|
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
|
|
253
|
-
if (
|
|
254
|
+
if (await fs.pathExists(automergePath)) {
|
|
255
|
+
// Pre-#423 workflows gated on the semver type alone, so production
|
|
256
|
+
// bumps of a published package merged with nobody in the loop. The
|
|
257
|
+
// canonical gate also requires the dev-minor group.
|
|
258
|
+
const automerge = await fs.readFile(automergePath, 'utf8');
|
|
259
|
+
if (!automerge.includes("dependency-group == 'dev-minor'")) {
|
|
260
|
+
deltas.push('auto-merge workflow merges production bumps (missing dev-minor group gate)');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
254
264
|
deltas.push('missing dependabot-automerge workflow');
|
|
255
265
|
}
|
|
266
|
+
// Repo-local `ignore:` rules aren't drift — they're deliberate, and the
|
|
267
|
+
// canonical config has no way to express them. Report them anyway so the
|
|
268
|
+
// reason `fix dependabot` refuses is visible before anyone runs it (#422).
|
|
269
|
+
const ignored = dependabotIgnoreRules(content);
|
|
270
|
+
const ignoreNote = ignored.length === 0
|
|
271
|
+
? ''
|
|
272
|
+
: ` — ${ignored.length} local \`ignore:\` rule(s) the canonical config cannot express (${ignored.join(', ')}); \`fix dependabot\` refuses rather than drop them`;
|
|
256
273
|
if (deltas.length > 0) {
|
|
257
274
|
return {
|
|
258
275
|
check: 'Dependabot',
|
|
259
276
|
status: 'drift',
|
|
260
|
-
detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
|
|
261
|
-
hint:
|
|
277
|
+
detail: `${candidate} drifts from canonical (${deltas.join('; ')})${ignoreNote}`,
|
|
278
|
+
hint: ignored.length > 0
|
|
279
|
+
? 'Run `npx @rtorcato/repo-tooling fix dependabot --diff` — it will refuse until the `ignore:` block is dealt with by hand'
|
|
280
|
+
: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
|
|
262
281
|
};
|
|
263
282
|
}
|
|
264
283
|
return {
|
|
265
284
|
check: 'Dependabot',
|
|
266
285
|
status: 'ok',
|
|
267
|
-
detail: `${candidate} + auto-merge workflow`,
|
|
286
|
+
detail: `${candidate} + auto-merge workflow${ignoreNote}`,
|
|
268
287
|
};
|
|
269
288
|
}
|
|
270
289
|
}
|
package/dist/base/fixers.js
CHANGED
|
@@ -21,7 +21,8 @@ import { generateBrand } from '../cli/generators/brand.js';
|
|
|
21
21
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
22
22
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
23
23
|
import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
|
|
24
|
-
import { generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
|
|
24
|
+
import { findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
|
|
25
|
+
import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
|
|
25
26
|
import { copyPreset } from '../cli/utils/copy-preset.js';
|
|
26
27
|
import { detectLanguage } from '../cli/utils/detect-language.js';
|
|
27
28
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
@@ -75,6 +76,24 @@ async function resolveInstallDir(explicit, assumeYes) {
|
|
|
75
76
|
return trimmed ? path.resolve(trimmed) : null;
|
|
76
77
|
}
|
|
77
78
|
export const BASE_FIXERS = [
|
|
79
|
+
{
|
|
80
|
+
target: 'copied-assets',
|
|
81
|
+
description: 'Re-copy presets that are unchanged since they were copied but older than the version this package ships',
|
|
82
|
+
appliesTo: ['Copied assets'],
|
|
83
|
+
outputs: ['(the stale copied presets, re-copied in place)'],
|
|
84
|
+
canFixDrift: true,
|
|
85
|
+
async run({ targetDir }) {
|
|
86
|
+
// Only the `stale` ones: their content provably still matches what was
|
|
87
|
+
// copied, so overwriting loses nothing. `modified` assets are somebody's
|
|
88
|
+
// deliberate fork and stay a human decision (#428).
|
|
89
|
+
const stale = (await classifyCopiedAssets(targetDir)).filter((a) => a.state === 'stale');
|
|
90
|
+
const filesWritten = [];
|
|
91
|
+
for (const asset of stale) {
|
|
92
|
+
filesWritten.push((await copyPreset(asset.preset, targetDir)).target);
|
|
93
|
+
}
|
|
94
|
+
return { filesWritten };
|
|
95
|
+
},
|
|
96
|
+
},
|
|
78
97
|
{
|
|
79
98
|
target: 'editorconfig',
|
|
80
99
|
description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
|
|
@@ -112,6 +131,16 @@ export const BASE_FIXERS = [
|
|
|
112
131
|
outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
|
|
113
132
|
canFixDrift: true,
|
|
114
133
|
async run({ targetDir }) {
|
|
134
|
+
// The template owns the whole file but emits no `ignore:` block, so
|
|
135
|
+
// regenerating deletes any repo-local ignore rule — silently, and with
|
|
136
|
+
// nothing in `doctor` to report the loss afterwards, because from the
|
|
137
|
+
// fixer's point of view the file then matches the standard exactly (#422).
|
|
138
|
+
// Refuse rather than warn: an unattended `fix --yes` would walk past a
|
|
139
|
+
// warning, and the rules are unrecoverable once written over.
|
|
140
|
+
const ignored = await findDependabotIgnoreRules(targetDir);
|
|
141
|
+
if (ignored) {
|
|
142
|
+
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\``);
|
|
143
|
+
}
|
|
115
144
|
const { dependabotEcosystem } = await moduleFor(targetDir);
|
|
116
145
|
return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
|
|
117
146
|
},
|
|
@@ -51,11 +51,15 @@ export const GITHUB_STANDARD = {
|
|
|
51
51
|
requiredContexts: ['lint', 'typecheck', 'build', 'test'],
|
|
52
52
|
};
|
|
53
53
|
const CODE_SCANNING_CHECK = 'Code-scanning gate';
|
|
54
|
+
const RELEASE_GATE_CHECK = 'Release gate';
|
|
55
|
+
const RELEASE_ENV_CHECK = 'Release environment';
|
|
54
56
|
const CHECK_NAMES = [
|
|
55
57
|
'Branch protection',
|
|
56
58
|
'Merge settings',
|
|
57
59
|
'Workflow permissions',
|
|
58
60
|
CODE_SCANNING_CHECK,
|
|
61
|
+
RELEASE_GATE_CHECK,
|
|
62
|
+
RELEASE_ENV_CHECK,
|
|
59
63
|
];
|
|
60
64
|
// Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
|
|
61
65
|
// override surface: bump them here for a stricter/looser fleet-wide baseline.
|
|
@@ -143,6 +147,7 @@ export async function checkGitHubSettings(dir, exec) {
|
|
|
143
147
|
checkMergeSettings(info),
|
|
144
148
|
await checkWorkflowPermissions(gh, info.nwo),
|
|
145
149
|
await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
|
|
150
|
+
...(await checkReleaseGate(gh, info.nwo, dir)),
|
|
146
151
|
];
|
|
147
152
|
}
|
|
148
153
|
function probeFailureReason(probe) {
|
|
@@ -425,6 +430,270 @@ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
|
|
|
425
430
|
: 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
|
|
426
431
|
};
|
|
427
432
|
}
|
|
433
|
+
// --- Release environment gate (#429) --------------------------------------
|
|
434
|
+
/** The environment name the standard reserves for the publish gate. */
|
|
435
|
+
const RELEASE_ENVIRONMENT = 'release';
|
|
436
|
+
/**
|
|
437
|
+
* What a job has to run for a merge to the default branch to reach a registry.
|
|
438
|
+
* `semantic-release` counts on its own — the shipped preset publishes with it.
|
|
439
|
+
*/
|
|
440
|
+
const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
|
|
441
|
+
const GATE_HINT = 'Create a `release` environment with required reviewers (Settings → Environments) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
|
|
442
|
+
const ENV_HINT = 'Add `environment: release` to the publishing job, or delete the environment — whichever was meant. An environment nothing references still lists under Settings → Environments as though it gates something';
|
|
443
|
+
const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
|
|
444
|
+
/**
|
|
445
|
+
* A workflow's `jobs:` blocks, keyed by job id.
|
|
446
|
+
*
|
|
447
|
+
* Hand-split rather than parsed: this package ships no YAML dependency, and the
|
|
448
|
+
* only question asked of the result is whether *one particular job* carries an
|
|
449
|
+
* `environment:` key. A whole-file grep would answer that wrong on any repo with
|
|
450
|
+
* a `github-pages` deploy job — which is the exact false negative this check
|
|
451
|
+
* exists to avoid. Jobs sit one indent level under `jobs:` and their keys one
|
|
452
|
+
* level below that, which holds for every workflow Actions accepts.
|
|
453
|
+
*/
|
|
454
|
+
export function workflowJobs(yaml) {
|
|
455
|
+
const jobs = new Map();
|
|
456
|
+
const lines = yaml.split('\n');
|
|
457
|
+
const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
|
|
458
|
+
if (start === -1)
|
|
459
|
+
return jobs;
|
|
460
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
461
|
+
// The block runs until the next top-level key. A column-0 comment is not one —
|
|
462
|
+
// it ends nothing, so skipping it keeps a stray comment between `jobs:` and its
|
|
463
|
+
// first job from truncating the block and hiding every job below it.
|
|
464
|
+
let end = lines.length;
|
|
465
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
466
|
+
const l = lines[i] ?? '';
|
|
467
|
+
if (l.trim() !== '' && !l.trimStart().startsWith('#') && indentOf(l) === 0) {
|
|
468
|
+
end = i;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const body = lines.slice(start + 1, end);
|
|
473
|
+
const first = body.find((l) => l.trim() !== '' && !l.trimStart().startsWith('#'));
|
|
474
|
+
if (first === undefined)
|
|
475
|
+
return jobs;
|
|
476
|
+
const jobIndent = indentOf(first);
|
|
477
|
+
let id = null;
|
|
478
|
+
let buf = [];
|
|
479
|
+
for (const line of body) {
|
|
480
|
+
const header = line.trim() !== '' && indentOf(line) === jobIndent
|
|
481
|
+
? /^([\w.-]+):/.exec(line.trim())?.[1]
|
|
482
|
+
: undefined;
|
|
483
|
+
if (header) {
|
|
484
|
+
if (id)
|
|
485
|
+
jobs.set(id, buf.join('\n'));
|
|
486
|
+
id = header;
|
|
487
|
+
buf = [];
|
|
488
|
+
}
|
|
489
|
+
else if (id) {
|
|
490
|
+
buf.push(line);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (id)
|
|
494
|
+
jobs.set(id, buf.join('\n'));
|
|
495
|
+
return jobs;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The environment a job runs in, in either form Actions accepts: the scalar
|
|
499
|
+
* `environment: release`, or a block whose `name:` names it. Null when the job
|
|
500
|
+
* declares none.
|
|
501
|
+
*/
|
|
502
|
+
export function jobEnvironment(body) {
|
|
503
|
+
const inline = /^[ \t]*environment:[ \t]*(\S+)[ \t]*$/m.exec(body);
|
|
504
|
+
if (inline?.[1])
|
|
505
|
+
return unquote(inline[1]);
|
|
506
|
+
const at = body.search(/^[ \t]*environment:[ \t]*$/m);
|
|
507
|
+
if (at === -1)
|
|
508
|
+
return null;
|
|
509
|
+
// Step names are list items (`- name:`), so the first bare `name:` after the
|
|
510
|
+
// block opener is the environment's.
|
|
511
|
+
const name = /^[ \t]*name:[ \t]*(\S+)/m.exec(body.slice(at))?.[1];
|
|
512
|
+
return name ? unquote(name) : null;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* A job body with whole-line comments dropped. Same reasoning as
|
|
516
|
+
* `hookHasUncommented` in base/checks.ts: a `#` line runs nothing, so matching
|
|
517
|
+
* it is a false positive. Observed on this repo's own ci.yml, where a `varcheck`
|
|
518
|
+
* job carrying `# npm publish uses OIDC trusted publishing` was reported as the
|
|
519
|
+
* publishing job. Comments are stripped rather than pattern-tested because
|
|
520
|
+
* `jobEnvironment` needs the same treatment — a commented-out `environment:`
|
|
521
|
+
* would otherwise read as a live gate, the exact inversion of this check.
|
|
522
|
+
*/
|
|
523
|
+
function withoutComments(body) {
|
|
524
|
+
return body
|
|
525
|
+
.split('\n')
|
|
526
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
527
|
+
.join('\n');
|
|
528
|
+
}
|
|
529
|
+
/** `private: true` — nothing reaches a registry, so no gate is owed. */
|
|
530
|
+
async function isPrivatePackage(dir) {
|
|
531
|
+
try {
|
|
532
|
+
const pkg = await fs.readJson(path.join(dir, 'package.json'));
|
|
533
|
+
return pkg?.private === true;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The first workflow job that runs a publish command, or null if none does.
|
|
541
|
+
*
|
|
542
|
+
* `'skip'` when the directory exists but can't be read — a permission error or a
|
|
543
|
+
* broken symlink must not read as "nothing publishes", which would report the
|
|
544
|
+
* gate as `ok` on a repo whose workflows were never inspected. Same shape as
|
|
545
|
+
* `readEnvironments`: absent is an answer, unreadable is not.
|
|
546
|
+
*/
|
|
547
|
+
async function findPublishJob(dir) {
|
|
548
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
549
|
+
if (!(await fs.pathExists(workflowsDir)))
|
|
550
|
+
return null;
|
|
551
|
+
try {
|
|
552
|
+
for (const f of (await fs.readdir(workflowsDir)).sort()) {
|
|
553
|
+
if (!/\.ya?ml$/.test(f))
|
|
554
|
+
continue;
|
|
555
|
+
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
556
|
+
if (!PUBLISH_COMMAND.test(content))
|
|
557
|
+
continue;
|
|
558
|
+
for (const [job, raw] of workflowJobs(content)) {
|
|
559
|
+
const body = withoutComments(raw);
|
|
560
|
+
if (PUBLISH_COMMAND.test(body)) {
|
|
561
|
+
return { file: f, job, environment: jobEnvironment(body) };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return 'skip';
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
async function readEnvironments(gh, nwo) {
|
|
572
|
+
const r = await gh(['api', `repos/${nwo}/environments`]);
|
|
573
|
+
// A repo with no environments can answer 404 — that's "none", not unreadable.
|
|
574
|
+
if (!r.ok)
|
|
575
|
+
return /404|not found/i.test(r.stderr) ? new Map() : 'skip';
|
|
576
|
+
try {
|
|
577
|
+
const parsed = JSON.parse(r.stdout);
|
|
578
|
+
const envs = new Map();
|
|
579
|
+
for (const e of parsed.environments ?? []) {
|
|
580
|
+
if (typeof e.name !== 'string')
|
|
581
|
+
continue;
|
|
582
|
+
envs.set(e.name, (e.protection_rules ?? []).some((p) => p.type === 'required_reviewers'));
|
|
583
|
+
}
|
|
584
|
+
return envs;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return 'skip';
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* The gap between merging the default branch and publishing to npm (#429). On
|
|
592
|
+
* the shipped semantic-release preset those are one event: nothing stands
|
|
593
|
+
* between a squash-merge and a new version on the registry.
|
|
594
|
+
*
|
|
595
|
+
* Two checks, because they fail differently and want different answers:
|
|
596
|
+
*
|
|
597
|
+
* - **Release gate** — the publishing job runs behind no environment at all, or
|
|
598
|
+
* behind one that isn't really a gate (absent, or with no required reviewers).
|
|
599
|
+
* - **Release environment** — the repo *has* a `release` environment and no job
|
|
600
|
+
* references it. That's the failure mode worth catching: an unreferenced
|
|
601
|
+
* environment gates nothing while reading as a gate in the GitHub UI.
|
|
602
|
+
*
|
|
603
|
+
* The two never both fire on one repo. "No `environment:` and no `release`
|
|
604
|
+
* environment" is the gate check's; "no `environment:` but a `release`
|
|
605
|
+
* environment exists" is the environment check's, and the gate check defers.
|
|
606
|
+
*
|
|
607
|
+
* A repo that publishes nothing is `ok` — not applicable, not drift. There is
|
|
608
|
+
* no fixer: creating the environment needs a `required_reviewers` list only a
|
|
609
|
+
* human can supply, so both hints describe the manual step.
|
|
610
|
+
*/
|
|
611
|
+
async function checkReleaseGate(gh, nwo, dir) {
|
|
612
|
+
const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
|
|
613
|
+
if (publish === 'skip') {
|
|
614
|
+
const reason = 'could not read .github/workflows';
|
|
615
|
+
return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
|
|
616
|
+
}
|
|
617
|
+
if (!publish) {
|
|
618
|
+
const detail = 'not applicable — no workflow job publishes to a registry';
|
|
619
|
+
return [
|
|
620
|
+
{ check: RELEASE_GATE_CHECK, status: 'ok', detail },
|
|
621
|
+
{ check: RELEASE_ENV_CHECK, status: 'ok', detail },
|
|
622
|
+
];
|
|
623
|
+
}
|
|
624
|
+
const envs = await readEnvironments(gh, nwo);
|
|
625
|
+
if (envs === 'skip') {
|
|
626
|
+
const reason = 'could not read environments';
|
|
627
|
+
return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
|
|
628
|
+
}
|
|
629
|
+
const named = publish.environment;
|
|
630
|
+
const where = `${publish.file} \`${publish.job}\``;
|
|
631
|
+
const hasRelease = envs.has(RELEASE_ENVIRONMENT);
|
|
632
|
+
let gate;
|
|
633
|
+
if (named === null) {
|
|
634
|
+
gate = hasRelease
|
|
635
|
+
? {
|
|
636
|
+
check: RELEASE_GATE_CHECK,
|
|
637
|
+
status: 'ok',
|
|
638
|
+
detail: `${where} declares no environment — reported by the ${RELEASE_ENV_CHECK} check`,
|
|
639
|
+
}
|
|
640
|
+
: {
|
|
641
|
+
check: RELEASE_GATE_CHECK,
|
|
642
|
+
status: 'drift',
|
|
643
|
+
detail: `${where} publishes with no \`environment:\` and the repo has no \`${RELEASE_ENVIRONMENT}\` environment — anything that lands on the default branch publishes`,
|
|
644
|
+
hint: GATE_HINT,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
else if (!envs.has(named)) {
|
|
648
|
+
gate = {
|
|
649
|
+
check: RELEASE_GATE_CHECK,
|
|
650
|
+
status: 'drift',
|
|
651
|
+
detail: `${where} names the \`${named}\` environment, which the repo does not have — Actions creates it unprotected on first run, so the publish is never held`,
|
|
652
|
+
hint: GATE_HINT,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
else if (!envs.get(named)) {
|
|
656
|
+
gate = {
|
|
657
|
+
check: RELEASE_GATE_CHECK,
|
|
658
|
+
status: 'drift',
|
|
659
|
+
detail: `the \`${named}\` environment has no required_reviewers — ${where} passes straight through it`,
|
|
660
|
+
hint: GATE_HINT,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
gate = {
|
|
665
|
+
check: RELEASE_GATE_CHECK,
|
|
666
|
+
status: 'ok',
|
|
667
|
+
detail: `${where} runs behind the \`${named}\` environment (required reviewers)`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
let environment;
|
|
671
|
+
if (!hasRelease) {
|
|
672
|
+
environment = {
|
|
673
|
+
check: RELEASE_ENV_CHECK,
|
|
674
|
+
status: 'ok',
|
|
675
|
+
detail: `no \`${RELEASE_ENVIRONMENT}\` environment — nothing to reference`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
else if (named === RELEASE_ENVIRONMENT) {
|
|
679
|
+
environment = {
|
|
680
|
+
check: RELEASE_ENV_CHECK,
|
|
681
|
+
status: 'ok',
|
|
682
|
+
detail: `${where} references the \`${RELEASE_ENVIRONMENT}\` environment`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
environment = {
|
|
687
|
+
check: RELEASE_ENV_CHECK,
|
|
688
|
+
status: 'drift',
|
|
689
|
+
detail: named
|
|
690
|
+
? `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} runs in \`${named}\` — \`${RELEASE_ENVIRONMENT}\` gates nothing while still reading as a gate in the GitHub UI`
|
|
691
|
+
: `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} references no environment — it gates nothing while still reading as a gate in the GitHub UI`,
|
|
692
|
+
hint: ENV_HINT,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
return [gate, environment];
|
|
696
|
+
}
|
|
428
697
|
/** The branch-protection body PUT to the API — mirrors the doctor standard. */
|
|
429
698
|
const PROTECTION_BODY = JSON.stringify({
|
|
430
699
|
required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
|
|
@@ -15,6 +15,7 @@ import { detectLanguage } from '../utils/detect-language.js';
|
|
|
15
15
|
import { checkGitHubSettings } from '../../base/github-settings.js';
|
|
16
16
|
import { checkMilestones } from '../../base/milestones.js';
|
|
17
17
|
import { checkGitIdentity } from '../../base/git-identity.js';
|
|
18
|
+
import { checkCopiedAssets } from '../utils/copied-assets.js';
|
|
18
19
|
import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
19
20
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
20
21
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
@@ -150,6 +151,9 @@ function demoteDeclined(results, lock) {
|
|
|
150
151
|
async function runBaseChecks(dir, lock, opts) {
|
|
151
152
|
const results = [];
|
|
152
153
|
results.push(checkLockfile(lock));
|
|
154
|
+
// Any language can have copied presets (swiftlint, ruff, perlcriticrc), so
|
|
155
|
+
// this rides with the base suite rather than a module's (#428).
|
|
156
|
+
results.push(await checkCopiedAssets(dir));
|
|
153
157
|
results.push(await checkNestedLanguages(dir, opts.language));
|
|
154
158
|
results.push(await checkGitIdentity(dir));
|
|
155
159
|
results.push(await checkEditorConfig(dir));
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -133,7 +133,17 @@ async function previewFixer(fixer, result, targetDir, pkg, lock) {
|
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
135
|
// assumeYes: a preview must never prompt.
|
|
136
|
-
|
|
136
|
+
try {
|
|
137
|
+
await fixer.run({ targetDir: tmpDir, pkg, result, lock, assumeYes: true });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// A fixer that refuses has nothing to preview. Swallow it here rather than
|
|
141
|
+
// reporting it twice — the real run happens moments later and its abort is
|
|
142
|
+
// what the caller prints.
|
|
143
|
+
if (!(err instanceof FixerAbort))
|
|
144
|
+
throw err;
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
137
147
|
const previews = [];
|
|
138
148
|
const seen = new Set();
|
|
139
149
|
for (const output of fixer.outputs) {
|
|
@@ -398,10 +408,9 @@ export async function fixCommand(target, options = {}) {
|
|
|
398
408
|
console.log(chalk.gray(' skipped\n'));
|
|
399
409
|
return;
|
|
400
410
|
}
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
// surface as an unhandled rejection there; guard the loop when one exists.
|
|
411
|
+
// A targeted abort is fatal: the user named this fixer, so failing to run it
|
|
412
|
+
// is the whole outcome of the command. The bulk loop below takes the other
|
|
413
|
+
// branch and records a skip, since one refusal must not abandon the rest.
|
|
405
414
|
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
|
|
406
415
|
skillsDir: options.skillsDir,
|
|
407
416
|
assumeYes,
|
|
@@ -471,10 +480,27 @@ export async function fixCommand(target, options = {}) {
|
|
|
471
480
|
skippedCount++;
|
|
472
481
|
continue;
|
|
473
482
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
483
|
+
// A fixer that refuses (e.g. dependabot, when the existing config carries
|
|
484
|
+
// repo-local `ignore:` rules the template can't reproduce) is a skip, not a
|
|
485
|
+
// crash — the remaining findings still deserve their fixers. The reason goes
|
|
486
|
+
// to stderr so it survives `--json`.
|
|
487
|
+
let outcome;
|
|
488
|
+
try {
|
|
489
|
+
outcome = await applyFixer(fixer, result, targetDir, pkg, lock, dryRun, silent, {
|
|
490
|
+
skillsDir: options.skillsDir,
|
|
491
|
+
assumeYes,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
if (!(err instanceof FixerAbort))
|
|
496
|
+
throw err;
|
|
497
|
+
actions.push(recordFor(fixer.target, result.check, result.status, 'skipped', [], conflict));
|
|
498
|
+
console.error(chalk.red(` refused — ${err.message}`));
|
|
499
|
+
if (err.hint)
|
|
500
|
+
console.error(chalk.gray(` ${err.hint}`));
|
|
501
|
+
skippedCount++;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
478
504
|
actions.push(recordFor(fixer.target, result.check, result.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
479
505
|
appliedCount++;
|
|
480
506
|
}
|
|
@@ -29,13 +29,16 @@ export function dependabotConfig(ecosystem) {
|
|
|
29
29
|
prefix: chore
|
|
30
30
|
include: scope
|
|
31
31
|
groups:
|
|
32
|
-
#
|
|
33
|
-
#
|
|
32
|
+
# Runtime minor/patch. Grouped so react/react-dom move together, but NOT
|
|
33
|
+
# auto-merged — these ship to consumers of a published package, so they
|
|
34
|
+
# get a human (see the dependabot-automerge workflow).
|
|
34
35
|
production-minor:
|
|
35
36
|
dependency-type: production
|
|
36
37
|
update-types:
|
|
37
38
|
- minor
|
|
38
39
|
- patch
|
|
40
|
+
# The only tier that auto-merges on green — dev tooling never reaches a
|
|
41
|
+
# consumer of the published package.
|
|
39
42
|
dev-minor:
|
|
40
43
|
dependency-type: development
|
|
41
44
|
update-types:
|
|
@@ -62,11 +65,71 @@ ${manifest} - package-ecosystem: github-actions
|
|
|
62
65
|
/** The JS flavour — the historical default, kept for the generators that don't
|
|
63
66
|
* resolve a language module. */
|
|
64
67
|
export const DEPENDABOT_CONFIG = dependabotConfig('npm');
|
|
68
|
+
/** Where `.github/dependabot.yml` can live, in the order Dependabot resolves it. */
|
|
69
|
+
export const DEPENDABOT_CONFIG_PATHS = [
|
|
70
|
+
'.github/dependabot.yml',
|
|
71
|
+
'.github/dependabot.yaml',
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* The `ignore:` rules an existing dependabot.yml carries, named by
|
|
75
|
+
* `dependency-name` (#422).
|
|
76
|
+
*
|
|
77
|
+
* `dependabotConfig()` owns the whole file but emits no `ignore:` block, and
|
|
78
|
+
* `ignore` is precisely the key that is inherently repo-local — a pin held back
|
|
79
|
+
* by hand, with the reason usually in a comment above it. So every rule this
|
|
80
|
+
* finds is one a regeneration would delete silently.
|
|
81
|
+
*
|
|
82
|
+
* ponytail: a line scanner, not a YAML parse — the repo ships no YAML parser and
|
|
83
|
+
* this only needs to answer "is there something here to lose, and what is it
|
|
84
|
+
* called". A rule split across lines (`-` alone, `dependency-name` beneath)
|
|
85
|
+
* reports as `<unnamed rule>`, which still stops the overwrite. Reach for a
|
|
86
|
+
* parser if the message ever has to reproduce the rules rather than name them.
|
|
87
|
+
*/
|
|
88
|
+
export function dependabotIgnoreRules(content) {
|
|
89
|
+
const lines = content.split('\n');
|
|
90
|
+
const rules = [];
|
|
91
|
+
for (const [index, line] of lines.entries()) {
|
|
92
|
+
const header = /^(\s*)ignore:\s*(#.*)?$/.exec(line);
|
|
93
|
+
if (!header)
|
|
94
|
+
continue;
|
|
95
|
+
const blockIndent = (header[1] ?? '').length;
|
|
96
|
+
// Rules sit at the first list indent under `ignore:`; deeper `- ` lines are
|
|
97
|
+
// an entry's own values (`update-types:`) and must not count as rules.
|
|
98
|
+
let itemIndent = null;
|
|
99
|
+
const names = [];
|
|
100
|
+
let items = 0;
|
|
101
|
+
for (const next of lines.slice(index + 1)) {
|
|
102
|
+
if (next.trim() === '')
|
|
103
|
+
continue;
|
|
104
|
+
const indent = next.search(/\S/);
|
|
105
|
+
if (indent <= blockIndent)
|
|
106
|
+
break;
|
|
107
|
+
if (/^\s*-\s/.test(next)) {
|
|
108
|
+
itemIndent ??= indent;
|
|
109
|
+
if (indent === itemIndent)
|
|
110
|
+
items++;
|
|
111
|
+
}
|
|
112
|
+
const named = /(?:^|\s)dependency-name:\s*["']?([^"'#\s]+)/.exec(next);
|
|
113
|
+
if (named?.[1] && (itemIndent === null || indent >= itemIndent))
|
|
114
|
+
names.push(named[1]);
|
|
115
|
+
}
|
|
116
|
+
while (names.length < items)
|
|
117
|
+
names.push('<unnamed rule>');
|
|
118
|
+
rules.push(...names);
|
|
119
|
+
}
|
|
120
|
+
return rules;
|
|
121
|
+
}
|
|
65
122
|
/**
|
|
66
|
-
* Auto-merges patch + minor Dependabot PRs
|
|
67
|
-
* protection with required status checks on
|
|
68
|
-
* \`gh pr merge --auto\` never fires.
|
|
69
|
-
*
|
|
123
|
+
* Auto-merges patch + minor Dependabot PRs **from the `dev-minor` group only**
|
|
124
|
+
* once CI is green. Requires branch protection with required status checks on
|
|
125
|
+
* the target branch — without it, \`gh pr merge --auto\` never fires.
|
|
126
|
+
*
|
|
127
|
+
* Everything else falls through to a human: production bumps ship to consumers
|
|
128
|
+
* of a published package (#423), majors are breaking by definition, and an
|
|
129
|
+
* ungrouped PR reports an empty \`dependency-group\`, so the gate fails closed.
|
|
130
|
+
*
|
|
131
|
+
* The group name is the one \`dependabotConfig()\` writes — the two files are a
|
|
132
|
+
* paired unit and have to move together.
|
|
70
133
|
*/
|
|
71
134
|
export const DEPENDABOT_AUTOMERGE_WORKFLOW = `name: Dependabot auto-merge
|
|
72
135
|
|
|
@@ -87,10 +150,14 @@ jobs:
|
|
|
87
150
|
with:
|
|
88
151
|
github-token: \${{ secrets.GITHUB_TOKEN }}
|
|
89
152
|
|
|
90
|
-
|
|
153
|
+
# Belt and braces: the dev-minor group is already declared minor+patch in
|
|
154
|
+
# dependabot.yml, but this workflow is the security gate and shouldn't
|
|
155
|
+
# trust a config file a consumer repo can edit independently.
|
|
156
|
+
- name: Auto-merge dev-dependency patch and minor updates
|
|
91
157
|
if: |
|
|
92
|
-
steps.metadata.outputs.
|
|
93
|
-
steps.metadata.outputs.update-type == 'version-update:semver-
|
|
158
|
+
steps.metadata.outputs.dependency-group == 'dev-minor' &&
|
|
159
|
+
(steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
|
|
160
|
+
steps.metadata.outputs.update-type == 'version-update:semver-minor')
|
|
94
161
|
run: gh pr merge --auto --squash "$PR_URL"
|
|
95
162
|
env:
|
|
96
163
|
PR_URL: \${{ github.event.pull_request.html_url }}
|
|
@@ -101,6 +168,20 @@ export const DEPENDABOT_FILES = [
|
|
|
101
168
|
'.github/dependabot.yml',
|
|
102
169
|
'.github/workflows/dependabot-automerge.yml',
|
|
103
170
|
];
|
|
171
|
+
/**
|
|
172
|
+
* The repo-local `ignore:` rules a regeneration would delete, with the file
|
|
173
|
+
* they live in — or null when there is nothing to lose (#422).
|
|
174
|
+
*/
|
|
175
|
+
export async function findDependabotIgnoreRules(targetDir) {
|
|
176
|
+
for (const file of DEPENDABOT_CONFIG_PATHS) {
|
|
177
|
+
const candidate = path.join(targetDir, file);
|
|
178
|
+
if (!(await fs.pathExists(candidate)))
|
|
179
|
+
continue;
|
|
180
|
+
const rules = dependabotIgnoreRules(await fs.readFile(candidate, 'utf8'));
|
|
181
|
+
return rules.length > 0 ? { file, rules } : null;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
104
185
|
/**
|
|
105
186
|
* Scaffold the canonical Dependabot setup: the grouped \`dependabot.yml\` **and**
|
|
106
187
|
* the auto-merge workflow. They're a paired unit — the config batches updates
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift detection for copied presets (#428).
|
|
3
|
+
*
|
|
4
|
+
* `copy <preset>` used to be a one-shot: it wrote the file and nothing ever
|
|
5
|
+
* looked at it again, so eight copies of `sync-changelog.mjs` drifted into six
|
|
6
|
+
* versions with no signal. The fix is one recorded fact — the asset's pristine
|
|
7
|
+
* hash at copy time, in `.repo-tooling.json` — which is enough to separate the
|
|
8
|
+
* two cases that matter:
|
|
9
|
+
*
|
|
10
|
+
* - file ≠ recorded hash → `modified`. Someone edited it on purpose (js-common
|
|
11
|
+
* forked this very script and documented why in its header). Reported, never
|
|
12
|
+
* overwritten, never counted as drift.
|
|
13
|
+
* - file = recorded hash, but the shipped asset has moved on → `stale`. Nothing
|
|
14
|
+
* local is in it, so re-copying is lossless.
|
|
15
|
+
* - no recorded hash → `unknown`. Copies predating this, and repos with no
|
|
16
|
+
* lockfile at all. Absence of a record is not evidence of drift, so this
|
|
17
|
+
* never fails a build.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { getPackageRoot, hashFile, PRESETS } from './copy-preset.js';
|
|
21
|
+
import { readLockfile } from './lockfile.js';
|
|
22
|
+
/**
|
|
23
|
+
* Classify every preset whose target file exists in `dir`. Presets that were
|
|
24
|
+
* never copied here are absent from the result rather than reported missing —
|
|
25
|
+
* `copy` is opt-in, and doctor already has checks for the files that aren't.
|
|
26
|
+
*/
|
|
27
|
+
export async function classifyCopiedAssets(dir) {
|
|
28
|
+
const lock = await readLockfile(dir);
|
|
29
|
+
const packageRoot = getPackageRoot();
|
|
30
|
+
const statuses = [];
|
|
31
|
+
for (const name of Object.keys(PRESETS)) {
|
|
32
|
+
const preset = PRESETS[name];
|
|
33
|
+
const current = await hashFile(path.join(dir, preset.target));
|
|
34
|
+
if (current === null)
|
|
35
|
+
continue;
|
|
36
|
+
const recorded = lock?.assets?.[name];
|
|
37
|
+
// Unmodified since the copy, so whether it's stale is purely a question of
|
|
38
|
+
// what this package ships now. A source we can't read (shouldn't happen)
|
|
39
|
+
// falls back to the recorded hash — "no news", not drift.
|
|
40
|
+
const shipped = recorded
|
|
41
|
+
? ((await hashFile(path.join(packageRoot, preset.source))) ?? recorded)
|
|
42
|
+
: null;
|
|
43
|
+
let state = 'ok';
|
|
44
|
+
if (!recorded)
|
|
45
|
+
state = 'unknown';
|
|
46
|
+
else if (recorded !== current)
|
|
47
|
+
state = 'modified';
|
|
48
|
+
else if (shipped !== recorded)
|
|
49
|
+
state = 'stale';
|
|
50
|
+
statuses.push({ preset: name, target: preset.target, state });
|
|
51
|
+
}
|
|
52
|
+
return statuses;
|
|
53
|
+
}
|
|
54
|
+
const listOf = (s) => s.map((a) => a.preset).join(', ');
|
|
55
|
+
export async function checkCopiedAssets(dir) {
|
|
56
|
+
const check = 'Copied assets';
|
|
57
|
+
const all = await classifyCopiedAssets(dir);
|
|
58
|
+
if (all.length === 0) {
|
|
59
|
+
return { check, status: 'optional-missing', detail: 'no copied presets found' };
|
|
60
|
+
}
|
|
61
|
+
const by = (state) => all.filter((a) => a.state === state);
|
|
62
|
+
const stale = by('stale');
|
|
63
|
+
const modified = by('modified');
|
|
64
|
+
const unknown = by('unknown');
|
|
65
|
+
const parts = [`${by('ok').length} in sync`];
|
|
66
|
+
if (modified.length > 0)
|
|
67
|
+
parts.push(`${modified.length} locally modified: ${listOf(modified)}`);
|
|
68
|
+
if (unknown.length > 0)
|
|
69
|
+
parts.push(`${unknown.length} untracked: ${listOf(unknown)}`);
|
|
70
|
+
if (stale.length === 0) {
|
|
71
|
+
return { check, status: 'ok', detail: parts.join('; ') };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
check,
|
|
75
|
+
status: 'drift',
|
|
76
|
+
detail: `${stale.length} stale: ${listOf(stale)}; ${parts.join('; ')}`,
|
|
77
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix copied-assets` to re-copy them. They still match what was copied, so nothing local is lost — locally modified assets are left alone.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import fs from 'fs-extra';
|
|
4
|
+
import { recordAssetHash } from './lockfile.js';
|
|
3
5
|
export const PRESETS = {
|
|
4
6
|
biome: {
|
|
5
7
|
source: 'tooling/biome/biome.json',
|
|
@@ -97,6 +99,11 @@ export const PRESETS = {
|
|
|
97
99
|
target: 'scripts/sync-changelog.mjs',
|
|
98
100
|
desc: 'Canonical CHANGELOG → docs sync script for Docusaurus sites',
|
|
99
101
|
},
|
|
102
|
+
'docusaurus-docs-helpers': {
|
|
103
|
+
source: 'tooling/docusaurus/docs-helpers.mjs',
|
|
104
|
+
target: 'scripts/docs-helpers.mjs',
|
|
105
|
+
desc: 'Docs-generator helpers (markdown-table escaping, export parser, generated-block splice)',
|
|
106
|
+
},
|
|
100
107
|
'docusaurus-theme-tokens': {
|
|
101
108
|
source: 'tooling/docusaurus/theme-tokens.css',
|
|
102
109
|
target: 'apps/docs/src/css/_jt-tokens.css',
|
|
@@ -112,6 +119,17 @@ export function getPackageRoot() {
|
|
|
112
119
|
const cliFile = new URL(import.meta.url).pathname;
|
|
113
120
|
return path.dirname(path.dirname(path.dirname(path.dirname(cliFile))));
|
|
114
121
|
}
|
|
122
|
+
/** sha256 of a file's bytes, or null when it doesn't exist / can't be read. */
|
|
123
|
+
export async function hashFile(filepath) {
|
|
124
|
+
try {
|
|
125
|
+
return createHash('sha256')
|
|
126
|
+
.update(await fs.readFile(filepath))
|
|
127
|
+
.digest('hex');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
115
133
|
export async function copyPreset(name, targetDir = process.cwd()) {
|
|
116
134
|
const preset = PRESETS[name];
|
|
117
135
|
const packageRoot = getPackageRoot();
|
|
@@ -124,6 +142,11 @@ export async function copyPreset(name, targetDir = process.cwd()) {
|
|
|
124
142
|
if (legacyPath !== targetPath)
|
|
125
143
|
await fs.remove(legacyPath);
|
|
126
144
|
}
|
|
145
|
+
// Stamp what was copied, so doctor can later tell a local edit from a copy
|
|
146
|
+
// the package has moved past (#428). No-ops when the repo has no lockfile.
|
|
147
|
+
const hash = await hashFile(sourcePath);
|
|
148
|
+
if (hash)
|
|
149
|
+
await recordAssetHash(targetDir, name, hash);
|
|
127
150
|
return {
|
|
128
151
|
source: preset.source,
|
|
129
152
|
target: preset.target,
|
|
@@ -13,12 +13,14 @@ export const LEGACY_TOOL_NAME = 'js-tooling';
|
|
|
13
13
|
export const LEGACY_LOCKFILE_NAME = `.${LEGACY_TOOL_NAME}.json`;
|
|
14
14
|
// v2 added ProjectConfig.language (multi-language seam, #140). v1 files are
|
|
15
15
|
// migrated to v2 on read, defaulting language to 'js'.
|
|
16
|
-
|
|
16
|
+
// v3 added `assets` — the pristine hash of each copied preset (#428). Older
|
|
17
|
+
// files carry no hashes, which reads as "not tracked", never as drift.
|
|
18
|
+
export const LOCKFILE_VERSION = 3;
|
|
17
19
|
const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/lockfile.json';
|
|
18
20
|
/**
|
|
19
21
|
* Upgrade an older lockfile in-memory. Only touches files older than the
|
|
20
22
|
* current version, so a newer-than-supported file is left as-is for
|
|
21
|
-
* checkLockfile to flag. The file is rewritten to
|
|
23
|
+
* checkLockfile to flag. The file is rewritten to v3 next time it's saved.
|
|
22
24
|
*/
|
|
23
25
|
function migrate(lock) {
|
|
24
26
|
if (lock.version >= LOCKFILE_VERSION)
|
|
@@ -27,6 +29,7 @@ function migrate(lock) {
|
|
|
27
29
|
...lock,
|
|
28
30
|
version: LOCKFILE_VERSION,
|
|
29
31
|
config: { language: 'js', ...lock.config },
|
|
32
|
+
assets: lock.assets ?? {},
|
|
30
33
|
};
|
|
31
34
|
}
|
|
32
35
|
export async function readLockfile(dir) {
|
|
@@ -53,16 +56,23 @@ export async function readLockfile(dir) {
|
|
|
53
56
|
return null;
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
/**
|
|
60
|
+
* @param assets Recorded asset hashes to write. Omit to carry the existing
|
|
61
|
+
* file's hashes forward — every caller that only means to update `config`
|
|
62
|
+
* would otherwise silently drop them.
|
|
63
|
+
*/
|
|
64
|
+
export async function writeLockfile(dir, config, assets) {
|
|
57
65
|
const { valid, errors } = validateProjectConfig(config);
|
|
58
66
|
if (!valid) {
|
|
59
67
|
throw new Error(`Refusing to write invalid lockfile:\n - ${errors.join('\n - ')}`);
|
|
60
68
|
}
|
|
69
|
+
const carried = assets ?? (await readLockfile(dir))?.assets;
|
|
61
70
|
const filepath = path.join(dir, LOCKFILE_NAME);
|
|
62
71
|
const lockfile = {
|
|
63
72
|
$schema: LOCKFILE_SCHEMA_URL,
|
|
64
73
|
version: LOCKFILE_VERSION,
|
|
65
74
|
config,
|
|
75
|
+
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
66
76
|
writtenBy: `@rtorcato/repo-tooling@${packageJson.version}`,
|
|
67
77
|
writtenAt: new Date().toISOString(),
|
|
68
78
|
};
|
|
@@ -86,3 +96,16 @@ export async function updateLockfileConfig(dir, patch) {
|
|
|
86
96
|
await writeLockfile(dir, merged);
|
|
87
97
|
return true;
|
|
88
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Record the pristine hash of a just-copied preset (#428). Returns false when
|
|
101
|
+
* the repo has no lockfile — four family repos don't, and creating one as a
|
|
102
|
+
* side effect of `copy` would be a surprise. Those repos keep reporting the
|
|
103
|
+
* asset as untracked, which is the honest answer.
|
|
104
|
+
*/
|
|
105
|
+
export async function recordAssetHash(dir, preset, hash) {
|
|
106
|
+
const existing = await readLockfile(dir);
|
|
107
|
+
if (!existing)
|
|
108
|
+
return false;
|
|
109
|
+
await writeLockfile(dir, existing.config, { ...existing.assets, [preset]: hash });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/repo-tooling",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.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": [
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"tooling/docusaurus/index.mjs",
|
|
88
88
|
"tooling/docusaurus/index.d.mts",
|
|
89
89
|
"tooling/docusaurus/sync-changelog.mjs",
|
|
90
|
+
"tooling/docusaurus/docs-helpers.mjs",
|
|
90
91
|
"tooling/docusaurus/theme-tokens.css",
|
|
91
92
|
"tooling/docusaurus/theme.css",
|
|
92
93
|
"tooling/biome/biome.json",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Canonical docs-generator helpers for @rtorcato/* repos (shipped by
|
|
2
|
+
// @rtorcato/repo-tooling — copy via `repo-tooling copy docusaurus-docs-helpers`).
|
|
3
|
+
//
|
|
4
|
+
// The pure pieces every subpath-exports package's doc generator needs, so the
|
|
5
|
+
// generator script above them stays small and project-specific:
|
|
6
|
+
//
|
|
7
|
+
// escapeForMarkdownTable(text) — make a JSDoc summary safe for a table cell
|
|
8
|
+
// collectExportNames(file) — recursive `export` parser over a module graph
|
|
9
|
+
// spliceGeneratedBlock(existing, block) — rewrite only the fenced generated region
|
|
10
|
+
//
|
|
11
|
+
// Zero-config and side-effect free: no paths, no package names, no I/O beyond
|
|
12
|
+
// reading the files you hand it, so this file is copied unmodified.
|
|
13
|
+
//
|
|
14
|
+
// import {
|
|
15
|
+
// collectExportNames,
|
|
16
|
+
// escapeForMarkdownTable,
|
|
17
|
+
// MARKER_END,
|
|
18
|
+
// MARKER_START,
|
|
19
|
+
// spliceGeneratedBlock,
|
|
20
|
+
// } from './docs-helpers.mjs'
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join, resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const EXPORT_NAMED =
|
|
26
|
+
/export\s+(?:async\s+)?(?:function|const|let|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g
|
|
27
|
+
const EXPORT_BRACE = /export\s*\{\s*([^}]+)\}/g
|
|
28
|
+
// Both re-export forms, any specifier — `export * from …` and
|
|
29
|
+
// `export { … } from …`. Non-relative specifiers are filtered below rather than
|
|
30
|
+
// in the pattern, so a bare-package re-export is recognised and then skipped
|
|
31
|
+
// instead of silently parsed as nothing.
|
|
32
|
+
const REEXPORT_FROM = /export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Escape `text` so it survives a markdown table cell.
|
|
36
|
+
*
|
|
37
|
+
* Neutralises raw HTML-ish tags outside code spans, which would otherwise
|
|
38
|
+
* confuse Docusaurus's MDX parser — but keeps them inside backticks, where
|
|
39
|
+
* `Success<T>` and `<br>` are the point. Then escapes pipes everywhere, since
|
|
40
|
+
* one anywhere in the cell breaks the row.
|
|
41
|
+
*
|
|
42
|
+
* Escaping the `<` beats stripping `/<[^>]*>/`: a one-pass tag strip can leave
|
|
43
|
+
* a tag behind on nested input (`<<b>>` -> `<b>`) and silently eats text like
|
|
44
|
+
* `Success<T>` when the JSDoc forgot the backticks.
|
|
45
|
+
*
|
|
46
|
+
* Escape the backslash in the same pass as the pipe, not after it: escaping
|
|
47
|
+
* only `|` turns the input `a\|b` into `a\\|b`, which markdown reads as an
|
|
48
|
+
* escaped backslash followed by a live pipe, and the row breaks anyway.
|
|
49
|
+
*/
|
|
50
|
+
export function escapeForMarkdownTable(text) {
|
|
51
|
+
return text
|
|
52
|
+
.split(/(`[^`]*`)/)
|
|
53
|
+
.map((part, i) => (i % 2 === 1 ? part : part.replace(/</g, '<')))
|
|
54
|
+
.join('')
|
|
55
|
+
.replace(/[\\|]/g, '\\$&')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Collect every name `file` exports, following relative re-exports into the
|
|
60
|
+
* files they name. Returns the accumulating `names` set; `seen` guards against
|
|
61
|
+
* an import cycle re-entering a file.
|
|
62
|
+
*
|
|
63
|
+
* For an aliased export the *alias* is the exported name — `export { foo as
|
|
64
|
+
* bar }` exports `bar`, which is what a consumer imports — so this takes the
|
|
65
|
+
* last segment, not the first.
|
|
66
|
+
*/
|
|
67
|
+
export function collectExportNames(file, names = new Set(), seen = new Set()) {
|
|
68
|
+
if (seen.has(file) || !existsSync(file)) return names
|
|
69
|
+
seen.add(file)
|
|
70
|
+
const src = readFileSync(file, 'utf8')
|
|
71
|
+
|
|
72
|
+
for (const m of src.matchAll(EXPORT_NAMED)) names.add(m[1])
|
|
73
|
+
for (const m of src.matchAll(EXPORT_BRACE)) {
|
|
74
|
+
for (const part of m[1].split(',')) {
|
|
75
|
+
const name = part
|
|
76
|
+
.trim()
|
|
77
|
+
.split(/\s+as\s+/)
|
|
78
|
+
.pop()
|
|
79
|
+
?.trim()
|
|
80
|
+
if (name) names.add(name)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const m of src.matchAll(REEXPORT_FROM)) {
|
|
84
|
+
if (!m[1].startsWith('.')) continue
|
|
85
|
+
const base = resolve(dirname(file), m[1].replace(/\.js$/, ''))
|
|
86
|
+
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) {
|
|
87
|
+
if (existsSync(candidate)) {
|
|
88
|
+
collectExportNames(candidate, names, seen)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return names
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const MARKER_START =
|
|
97
|
+
'<!-- generated:exports — do not edit; `pnpm docs:generate` rewrites this block -->'
|
|
98
|
+
export const MARKER_END = '<!-- /generated:exports -->'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Splice `block` into `existing` between the markers. Returns null when the
|
|
102
|
+
* page has no markers, which means "hand-written, leave it alone".
|
|
103
|
+
*/
|
|
104
|
+
export function spliceGeneratedBlock(existing, block) {
|
|
105
|
+
const start = existing.indexOf(MARKER_START)
|
|
106
|
+
const end = existing.indexOf(MARKER_END)
|
|
107
|
+
if (start === -1 || end === -1 || end < start) return null
|
|
108
|
+
return existing.slice(0, start) + block + existing.slice(end + MARKER_END.length)
|
|
109
|
+
}
|