@rtorcato/repo-tooling 3.2.4 → 3.2.5

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.
@@ -0,0 +1,410 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
4
+ export async function checkFile(dir, spec) {
5
+ for (const candidate of spec.candidates) {
6
+ const filepath = path.join(dir, candidate);
7
+ if (!(await fs.pathExists(filepath)))
8
+ continue;
9
+ const contents = await fs.readFile(filepath, 'utf-8');
10
+ if (spec.matcher.test(contents)) {
11
+ return {
12
+ check: spec.check,
13
+ status: 'ok',
14
+ detail: `${candidate} ${spec.expected}`,
15
+ };
16
+ }
17
+ return {
18
+ check: spec.check,
19
+ status: 'drift',
20
+ detail: `${candidate} found but does not ${spec.expected}`,
21
+ hint: spec.hint,
22
+ };
23
+ }
24
+ return {
25
+ check: spec.check,
26
+ status: spec.optional ? 'optional-missing' : 'missing',
27
+ detail: `no ${spec.candidates.join(' / ')} found`,
28
+ hint: spec.hint,
29
+ };
30
+ }
31
+ export async function checkEditorConfig(dir) {
32
+ const exists = await fs.pathExists(path.join(dir, '.editorconfig'));
33
+ return {
34
+ check: 'EditorConfig',
35
+ status: exists ? 'ok' : 'optional-missing',
36
+ detail: exists ? '.editorconfig found' : 'no .editorconfig',
37
+ hint: exists ? undefined : 'Add an .editorconfig for cross-editor formatting consistency',
38
+ };
39
+ }
40
+ export async function checkCodeowners(dir) {
41
+ for (const candidate of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
42
+ if (await fs.pathExists(path.join(dir, candidate))) {
43
+ return {
44
+ check: 'CODEOWNERS',
45
+ status: 'ok',
46
+ detail: `${candidate} found`,
47
+ };
48
+ }
49
+ }
50
+ return {
51
+ check: 'CODEOWNERS',
52
+ status: 'optional-missing',
53
+ detail: 'no CODEOWNERS file',
54
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeowners` to scaffold .github/CODEOWNERS',
55
+ };
56
+ }
57
+ export async function checkCommunityHealth(dir) {
58
+ const anchors = ['CONTRIBUTING.md', 'SECURITY.md'];
59
+ const present = await Promise.all(anchors.map((f) => fs.pathExists(path.join(dir, f))));
60
+ if (present.every(Boolean)) {
61
+ return {
62
+ check: 'Community health',
63
+ status: 'ok',
64
+ detail: 'CONTRIBUTING.md and SECURITY.md found',
65
+ };
66
+ }
67
+ return {
68
+ check: 'Community health',
69
+ status: 'optional-missing',
70
+ detail: 'missing community-health files (CONTRIBUTING/SECURITY/templates)',
71
+ hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
72
+ };
73
+ }
74
+ export async function checkGitHubActions(dir) {
75
+ const workflowsDir = path.join(dir, '.github', 'workflows');
76
+ if (!(await fs.pathExists(workflowsDir))) {
77
+ return {
78
+ check: 'GitHub Actions',
79
+ status: 'optional-missing',
80
+ detail: 'no .github/workflows/',
81
+ hint: 'Run `npx @rtorcato/repo-tooling setup` to scaffold a CI workflow',
82
+ };
83
+ }
84
+ try {
85
+ const files = await fs.readdir(workflowsDir);
86
+ const workflows = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
87
+ if (workflows.length === 0) {
88
+ return {
89
+ check: 'GitHub Actions',
90
+ status: 'optional-missing',
91
+ detail: '.github/workflows/ is empty',
92
+ hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
93
+ };
94
+ }
95
+ return {
96
+ check: 'GitHub Actions',
97
+ status: 'ok',
98
+ detail: `${workflows.length} workflow${workflows.length === 1 ? '' : 's'} in .github/workflows/`,
99
+ };
100
+ }
101
+ catch {
102
+ return {
103
+ check: 'GitHub Actions',
104
+ status: 'optional-missing',
105
+ detail: 'unable to read .github/workflows/',
106
+ };
107
+ }
108
+ }
109
+ export async function checkDependabot(dir) {
110
+ for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
111
+ const candidatePath = path.join(dir, candidate);
112
+ if (await fs.pathExists(candidatePath)) {
113
+ // The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
114
+ // the safe-tier + major-tier grouping plus the auto-merge workflow that
115
+ // depends on it. Flag any config that predates it so `fix dependabot` can
116
+ // bring the pair up to standard.
117
+ const content = await fs.readFile(candidatePath, 'utf8');
118
+ const deltas = [];
119
+ for (const group of ['production-minor', 'dev-minor', 'major-updates']) {
120
+ if (!new RegExp(`^\\s*${group}:`, 'm').test(content)) {
121
+ deltas.push(`missing \`${group}\` group`);
122
+ }
123
+ }
124
+ const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
125
+ if (!(await fs.pathExists(automergePath))) {
126
+ deltas.push('missing dependabot-automerge workflow');
127
+ }
128
+ if (deltas.length > 0) {
129
+ return {
130
+ check: 'Dependabot',
131
+ status: 'drift',
132
+ detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
133
+ hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
134
+ };
135
+ }
136
+ return {
137
+ check: 'Dependabot',
138
+ status: 'ok',
139
+ detail: `${candidate} + auto-merge workflow`,
140
+ };
141
+ }
142
+ }
143
+ for (const candidate of [
144
+ 'renovate.json',
145
+ 'renovate.json5',
146
+ '.github/renovate.json',
147
+ '.github/renovate.json5',
148
+ '.renovaterc',
149
+ '.renovaterc.json',
150
+ ]) {
151
+ if (await fs.pathExists(path.join(dir, candidate))) {
152
+ return {
153
+ check: 'Dependabot',
154
+ status: 'ok',
155
+ detail: `${candidate} found (Renovate)`,
156
+ };
157
+ }
158
+ }
159
+ return {
160
+ check: 'Dependabot',
161
+ status: 'optional-missing',
162
+ detail: 'no Dependabot or Renovate config',
163
+ hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` (or `fix renovate`) to scaffold weekly dep updates',
164
+ };
165
+ }
166
+ export async function checkCodeQL(dir) {
167
+ const workflowsDir = path.join(dir, '.github', 'workflows');
168
+ if (!(await fs.pathExists(workflowsDir))) {
169
+ return {
170
+ check: 'CodeQL',
171
+ status: 'optional-missing',
172
+ detail: 'no .github/workflows/',
173
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
174
+ };
175
+ }
176
+ for (const candidate of ['codeql.yml', 'codeql.yaml']) {
177
+ if (await fs.pathExists(path.join(workflowsDir, candidate))) {
178
+ return {
179
+ check: 'CodeQL',
180
+ status: 'ok',
181
+ detail: `.github/workflows/${candidate} found`,
182
+ };
183
+ }
184
+ }
185
+ try {
186
+ const files = await fs.readdir(workflowsDir);
187
+ for (const f of files) {
188
+ if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
189
+ continue;
190
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
191
+ if (/github\/codeql-action/.test(content)) {
192
+ return {
193
+ check: 'CodeQL',
194
+ status: 'ok',
195
+ detail: `codeql-action referenced in ${f}`,
196
+ };
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // fall through to optional-missing
202
+ }
203
+ return {
204
+ check: 'CodeQL',
205
+ status: 'optional-missing',
206
+ detail: 'no codeql workflow found',
207
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
208
+ };
209
+ }
210
+ // A README that advertises a Codecov badge but a CI that never uploads coverage
211
+ // leaves the badge permanently red. Only flags when the badge is actually present
212
+ // (no badge → nothing to back, so it's not applicable).
213
+ export async function checkCoverageUpload(dir) {
214
+ const readmePath = path.join(dir, 'README.md');
215
+ const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
216
+ if (!/codecov\.io/.test(readme)) {
217
+ return {
218
+ check: 'Coverage upload',
219
+ status: 'ok',
220
+ detail: 'no coverage badge in README (nothing to back)',
221
+ };
222
+ }
223
+ const workflowsDir = path.join(dir, '.github', 'workflows');
224
+ if (await fs.pathExists(workflowsDir)) {
225
+ try {
226
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
227
+ for (const f of files) {
228
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
229
+ if (/codecov\/codecov-action/.test(content)) {
230
+ return {
231
+ check: 'Coverage upload',
232
+ status: 'ok',
233
+ detail: `coverage badge backed by codecov-action in .github/workflows/${f}`,
234
+ };
235
+ }
236
+ }
237
+ }
238
+ catch {
239
+ // fall through to drift
240
+ }
241
+ }
242
+ return {
243
+ check: 'Coverage upload',
244
+ status: 'drift',
245
+ detail: 'README has a Codecov badge but no CI step uploads coverage (badge stays red)',
246
+ hint: 'Run `npx @rtorcato/repo-tooling fix github-actions` to regenerate ci.yml with a Codecov upload step',
247
+ };
248
+ }
249
+ export async function checkAiSetup(dir) {
250
+ // Consider AI setup present if AGENTS.md carries the js-tooling block or the
251
+ // Claude skill is installed — the two primary markers `fix ai` writes.
252
+ const agentsPath = path.join(dir, 'AGENTS.md');
253
+ const hasAgentsBlock = (await fs.pathExists(agentsPath)) &&
254
+ (await fs.readFile(agentsPath, 'utf8')).includes('<!-- js-tooling:start -->');
255
+ const hasSkill = (await fs.pathExists(path.join(dir, '.claude', 'skills', 'repo-tooling.md'))) ||
256
+ // Pre-rename name — still counts as present until `fix` migrates it.
257
+ (await fs.pathExists(path.join(dir, '.claude', 'skills', 'js-tooling.md')));
258
+ if (hasAgentsBlock || hasSkill) {
259
+ return {
260
+ check: 'AI setup',
261
+ status: 'ok',
262
+ detail: hasAgentsBlock ? 'AGENTS.md has the js-tooling block' : '.claude skill installed',
263
+ };
264
+ }
265
+ return {
266
+ check: 'AI setup',
267
+ status: 'optional-missing',
268
+ detail: 'no AI agent files (AGENTS.md, CLAUDE.md, Cursor/Copilot rules, Claude skill)',
269
+ hint: 'Run `npx @rtorcato/repo-tooling fix ai` to scaffold agent rules for every AI tool',
270
+ };
271
+ }
272
+ /**
273
+ * Conventional Commits is a repo convention, not a JavaScript one — the config
274
+ * file is the same in any repo that has node available to run commitlint, so
275
+ * the spec lives here rather than in the JS module's FILE_CHECKS (#309).
276
+ */
277
+ export const COMMITLINT_FILE_CHECK = {
278
+ check: 'Commitlint',
279
+ candidates: ['commitlint.config.js', 'commitlint.config.mjs', 'commitlint.config.cjs'],
280
+ expected: 'exports "@rtorcato/repo-tooling/commitlint/config"',
281
+ matcher: /@rtorcato\/(?:js|repo)-tooling\/commitlint\/config/,
282
+ optional: true,
283
+ };
284
+ /**
285
+ * True when a shell hook has an uncommented line matching `pattern`. A
286
+ * commented-out line (e.g. `# pnpm verify`) doesn't count — the command never
287
+ * runs, so it isn't real wiring.
288
+ */
289
+ export function hookHasUncommented(contents, pattern) {
290
+ return contents.split('\n').some((line) => {
291
+ const trimmed = line.trim();
292
+ return trimmed.length > 0 && !trimmed.startsWith('#') && pattern.test(trimmed);
293
+ });
294
+ }
295
+ /** `swift test` → /\bswift\s+test\b/, so extra whitespace in a hook still matches. */
296
+ function commandMatcher(command) {
297
+ const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
298
+ return new RegExp(`\\b${escaped}\\b`);
299
+ }
300
+ export async function checkGitHooks(dir, p) {
301
+ const check = 'Git hooks';
302
+ const hint = `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks`;
303
+ const hooksDir = await fs.pathExists(path.join(dir, p.dir));
304
+ // With no committed install signal, the hooks directory itself is the whole
305
+ // answer — there's no second half that can drift out of sync with it.
306
+ const installed = p.install === null ? hooksDir : p.install.present;
307
+ if (hooksDir && installed) {
308
+ return {
309
+ check,
310
+ status: 'ok',
311
+ detail: p.install ? `${p.dir}/ and ${p.install.label} configured` : `${p.dir}/ found`,
312
+ };
313
+ }
314
+ if (hooksDir || installed) {
315
+ return {
316
+ check,
317
+ status: 'drift',
318
+ detail: hooksDir
319
+ ? `${p.dir}/ exists but no ${p.install?.label}`
320
+ : `${p.install?.label} set but no ${p.dir}/ directory`,
321
+ hint: `${hint} (scaffolds both halves)`,
322
+ };
323
+ }
324
+ return { check, status: 'optional-missing', detail: 'git hooks not configured', hint };
325
+ }
326
+ /**
327
+ * The pre-push gate. Only asserts that the hook runs the language's verify
328
+ * command — whether that command itself is well-formed is the language module's
329
+ * business (JS keeps a `verify script` check for exactly that).
330
+ */
331
+ export async function checkPrePushHook(dir, p) {
332
+ const check = 'Pre-push hook';
333
+ if (!(await fs.pathExists(path.join(dir, p.dir)))) {
334
+ return {
335
+ check,
336
+ status: 'optional-missing',
337
+ detail: 'git hooks not configured',
338
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks (includes pre-push)`,
339
+ };
340
+ }
341
+ const hookPath = path.join(dir, p.dir, 'pre-push');
342
+ if (!(await fs.pathExists(hookPath))) {
343
+ return {
344
+ check,
345
+ status: 'optional-missing',
346
+ detail: `no ${p.dir}/pre-push`,
347
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to scaffold a pre-push hook that runs \`${p.verifyCommand}\``,
348
+ };
349
+ }
350
+ const contents = await fs.readFile(hookPath, 'utf-8');
351
+ if (hookHasUncommented(contents, commandMatcher(p.verifyCommand))) {
352
+ return { check, status: 'ok', detail: `${p.dir}/pre-push runs \`${p.verifyCommand}\`` };
353
+ }
354
+ return {
355
+ check,
356
+ status: 'drift',
357
+ detail: `${p.dir}/pre-push exists but does not run \`${p.verifyCommand}\``,
358
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to align the hook with \`${p.verifyCommand}\``,
359
+ };
360
+ }
361
+ export async function checkReadmeBadges(dir, audience,
362
+ /** `fix` target that rebuilds the badge block, or null when the language has none. */
363
+ fixTarget) {
364
+ const check = 'README badges';
365
+ const hint = fixTarget
366
+ ? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to add CI/npm/coverage/license badges`
367
+ : 'Add CI and license badges to README.md';
368
+ const readmePath = path.join(dir, 'README.md');
369
+ const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
370
+ if (audience === 'private') {
371
+ // Only a problem if a private/app repo carries badges that would 404.
372
+ if (readme && hasPublicOnlyBadges(readme)) {
373
+ return {
374
+ check,
375
+ status: 'drift',
376
+ detail: 'README has npm/coverage badges but the package is private (they 404)',
377
+ hint: fixTarget
378
+ ? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to rebuild badges for a private repo`
379
+ : 'Remove the npm/coverage badges — they 404 for a private package',
380
+ };
381
+ }
382
+ return { check, status: 'ok', detail: 'not applicable (private package)' };
383
+ }
384
+ if (audience === 'not-applicable') {
385
+ return { check, status: 'ok', detail: 'not applicable (no published exports)' };
386
+ }
387
+ const hasBadges = readme.includes(BADGE_START) ||
388
+ /img\.shields\.io|badge\.svg|badge\.fury\.io|codecov\.io/.test(readme);
389
+ if (hasBadges) {
390
+ return { check, status: 'ok', detail: 'README carries status badges' };
391
+ }
392
+ return { check, status: 'optional-missing', detail: 'no status badges in README', hint };
393
+ }
394
+ export async function checkGitLabCI(dir) {
395
+ for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
396
+ if (await fs.pathExists(path.join(dir, candidate))) {
397
+ return {
398
+ check: 'GitLab CI',
399
+ status: 'ok',
400
+ detail: `${candidate} found`,
401
+ };
402
+ }
403
+ }
404
+ return {
405
+ check: 'GitLab CI',
406
+ status: 'optional-missing',
407
+ detail: 'no .gitlab-ci.yml',
408
+ hint: 'Run `npx @rtorcato/repo-tooling fix gitlab-ci` to scaffold a starter GitLab pipeline',
409
+ };
410
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Language-agnostic CI skeletons (#283).
3
+ *
4
+ * The *shell* of a pipeline — triggers, concurrency, the skip gate, job
5
+ * scaffolding, stage derivation — is identical whether the repo is JS, Swift,
6
+ * Perl or Python. Only the steps inside each job differ. This module owns the
7
+ * shell; language modules (`src/languages/<id>/ci.ts`) supply the steps.
8
+ *
9
+ * Steps arrive pre-rendered rather than as a YAML AST: the generators' whole
10
+ * job is emitting text, and a tree we'd immediately flatten buys nothing but a
11
+ * serializer to maintain.
12
+ */
13
+ /** Every job is gated on the skip-CI check; extra conditions are ANDed on. */
14
+ const SKIP_GUARD = "needs.check-skip.outputs.should-skip != 'true'";
15
+ /** Header through `jobs:` — triggers and concurrency are language-independent. */
16
+ const WORKFLOW_HEADER = `name: 🚀 CI/CD Pipeline
17
+
18
+ on:
19
+ push:
20
+ branches: [main, release, develop]
21
+ pull_request:
22
+ branches: [main]
23
+ workflow_dispatch:
24
+
25
+ concurrency:
26
+ group: \${{ github.workflow }}-\${{ github.ref }}
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ `;
31
+ /**
32
+ * Honours `[ci skip]` / `[skip ci]` in the head commit message. Pure shell
33
+ * plumbing — no toolchain involved, so every language shares it verbatim.
34
+ */
35
+ const CHECK_SKIP_JOB = ` check-skip:
36
+ runs-on: ubuntu-latest
37
+ outputs:
38
+ should-skip: \${{ steps.skip-check.outputs.should-skip }}
39
+ steps:
40
+ - name: Check for skip CI
41
+ id: skip-check
42
+ run: |
43
+ if [[ "\${{ github.event.head_commit.message }}" =~ \\[(ci skip|skip ci)\\] ]]; then
44
+ echo "should-skip=true" >> $GITHUB_OUTPUT
45
+ else
46
+ echo "should-skip=false" >> $GITHUB_OUTPUT
47
+ fi`;
48
+ function renderJob(job) {
49
+ // A lone dependency stays a scalar (`needs: check-skip`) — that's the form
50
+ // the hand-written workflows used, and Actions treats both identically.
51
+ const needs = [...(job.needs ?? []), 'check-skip'];
52
+ const needsYaml = needs.length === 1 ? needs[0] : `[${needs.join(', ')}]`;
53
+ const condition = job.if ? `${SKIP_GUARD} && ${job.if}` : SKIP_GUARD;
54
+ return ` ${job.id}:
55
+ runs-on: ${job.runsOn ?? 'ubuntu-latest'}
56
+ needs: ${needsYaml}
57
+ if: ${condition}
58
+ ${job.extra ? `${job.extra}\n` : ''} steps:
59
+ ${job.steps}`;
60
+ }
61
+ /** Wrap language-supplied jobs in the shared workflow shell. */
62
+ export function renderGitHubWorkflow(jobs) {
63
+ return `${WORKFLOW_HEADER}${[CHECK_SKIP_JOB, ...jobs.map(renderJob)].join('\n\n')}\n`;
64
+ }
65
+ /**
66
+ * The CodeQL workflow. Only the matrix languages vary, so the whole file lives
67
+ * here and each language module contributes its `codeqlLanguages`.
68
+ */
69
+ export function renderCodeQLWorkflow(languages) {
70
+ return `name: CodeQL
71
+
72
+ on:
73
+ push:
74
+ branches: [main]
75
+ pull_request:
76
+ branches: [main]
77
+ schedule:
78
+ - cron: '0 6 * * 1'
79
+
80
+ jobs:
81
+ analyze:
82
+ name: Analyze
83
+ runs-on: ubuntu-latest
84
+ permissions:
85
+ actions: read
86
+ contents: read
87
+ security-events: write
88
+
89
+ strategy:
90
+ fail-fast: false
91
+ matrix:
92
+ language: [${languages.join(', ')}]
93
+
94
+ steps:
95
+ - name: Checkout
96
+ uses: actions/checkout@v7
97
+
98
+ - name: Initialize CodeQL
99
+ uses: github/codeql-action/init@v3
100
+ with:
101
+ languages: \${{ matrix.language }}
102
+
103
+ - name: Perform CodeQL Analysis
104
+ uses: github/codeql-action/analyze@v3
105
+ with:
106
+ category: "/language:\${{ matrix.language }}"
107
+ `;
108
+ }
109
+ /**
110
+ * Wrap language-supplied jobs in the shared `.gitlab-ci.yml` shell. Stages are
111
+ * derived from the jobs in first-appearance order, so a language module never
112
+ * has to keep a stage list in sync with the jobs it emits.
113
+ */
114
+ export function renderGitLabCI({ image, preamble, jobs }) {
115
+ const stages = [...new Set(jobs.map((job) => job.stage))];
116
+ // A pipeline with no jobs still needs a valid `stages:` key.
117
+ if (stages.length === 0)
118
+ stages.push('test');
119
+ const blocks = jobs.map((job) => `${job.id}:
120
+ stage: ${job.stage}
121
+ script:
122
+ ${job.script.map((line) => ` - ${line}`).join('\n')}${job.extra ? `\n${job.extra}` : ''}`);
123
+ return `# .gitlab-ci.yml — generated by @rtorcato/repo-tooling
124
+ # Customize stages and jobs to fit your pipeline.
125
+
126
+ image: ${image}
127
+
128
+ stages:
129
+ ${stages.map((s) => ` - ${s}`).join('\n')}
130
+
131
+ ${[preamble, ...blocks].join('\n\n')}
132
+ `;
133
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The fixer contract plus the language-agnostic fixers (#286, #303).
3
+ *
4
+ * These are the fixers for the checks in ./checks.ts — repo hygiene, security
5
+ * automation, community health, AI agent files, GitHub repo settings. Nothing
6
+ * here reads a package.json or emits a toolchain-specific step, so every
7
+ * language module gets them for free; `fix` concatenates them with the module's
8
+ * own set.
9
+ *
10
+ * Not here: `github-actions` / `gitlab-ci` / `lockfile`. Their *content* is
11
+ * language-shaped (CI steps, recorded tool choices), so each module ships its
12
+ * own — see src/base/ci.ts for the shell they share.
13
+ */
14
+ import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
15
+ import { generateCommunityHealth } from '../cli/generators/community-health.js';
16
+ import { generateCommitlintConfig } from '../cli/generators/git.js';
17
+ import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
18
+ import { generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
19
+ import { copyPreset } from '../cli/utils/copy-preset.js';
20
+ import { detectLanguage } from '../cli/utils/detect-language.js';
21
+ import { resolveLanguageModule } from '../languages/registry.js';
22
+ import { applyGithubSettings } from './github-settings.js';
23
+ /** The repo's language module, resolved from its marker files. */
24
+ async function moduleFor(targetDir) {
25
+ return resolveLanguageModule(await detectLanguage(targetDir));
26
+ }
27
+ export const BASE_FIXERS = [
28
+ {
29
+ target: 'editorconfig',
30
+ description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
31
+ appliesTo: ['EditorConfig'],
32
+ outputs: ['.editorconfig'],
33
+ canFixDrift: true,
34
+ async run({ targetDir }) {
35
+ await generateEditorConfig(targetDir);
36
+ return { filesWritten: ['.editorconfig'] };
37
+ },
38
+ },
39
+ {
40
+ target: 'commitlint',
41
+ description: 'Scaffold commitlint.config.mjs exporting the preset',
42
+ // Conventional Commits is a repo convention, not a JS one (#309) — the
43
+ // config is identical in any repo. Running commitlint still needs node on
44
+ // PATH, which is why the Swift hooks don't wire a commit-msg hook.
45
+ appliesTo: ['Commitlint'],
46
+ outputs: ['commitlint.config.mjs'],
47
+ canFixDrift: true,
48
+ async run({ targetDir }) {
49
+ await generateCommitlintConfig(targetDir);
50
+ return { filesWritten: ['commitlint.config.mjs'] };
51
+ },
52
+ },
53
+ {
54
+ target: 'dependabot',
55
+ description: 'Scaffold the canonical .github/dependabot.yml (monthly, grouped: production-minor/dev-minor/major-updates) + the dependabot-automerge workflow',
56
+ appliesTo: ['Dependabot'],
57
+ outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
58
+ canFixDrift: true,
59
+ async run({ targetDir }) {
60
+ const { dependabotEcosystem } = await moduleFor(targetDir);
61
+ return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
62
+ },
63
+ },
64
+ {
65
+ target: 'renovate',
66
+ description: 'Scaffold renovate.json (weekly schedule; alternative to Dependabot)',
67
+ appliesTo: ['Dependabot'],
68
+ outputs: ['renovate.json'],
69
+ riskLevel: 'safe-add',
70
+ async run({ targetDir }) {
71
+ await generateRenovateConfig(targetDir);
72
+ return { filesWritten: ['renovate.json'] };
73
+ },
74
+ },
75
+ {
76
+ target: 'codeql',
77
+ description: 'Scaffold .github/workflows/codeql.yml (security scanning)',
78
+ appliesTo: ['CodeQL'],
79
+ outputs: ['.github/workflows/codeql.yml'],
80
+ async run({ targetDir }) {
81
+ const { codeqlLanguages } = await moduleFor(targetDir);
82
+ return { filesWritten: await generateCodeQLWorkflow(targetDir, codeqlLanguages) };
83
+ },
84
+ },
85
+ {
86
+ target: 'github-settings',
87
+ description: 'Apply branch protection + auto-merge + workflow permissions + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
88
+ appliesTo: [
89
+ 'Branch protection',
90
+ 'Merge settings',
91
+ 'Workflow permissions',
92
+ 'Code-scanning gate',
93
+ ],
94
+ outputs: ['GitHub repo settings (remote, via gh api)'],
95
+ // safe-add is load-bearing: it exempts this fixer from the `--diff` shadow-run
96
+ // (previewFixer copies to tmp and *executes* run(), which would fire real
97
+ // `gh api` PUTs during a mere preview).
98
+ riskLevel: 'safe-add',
99
+ canFixDrift: true,
100
+ async run({ targetDir }) {
101
+ return { filesWritten: await applyGithubSettings(targetDir) };
102
+ },
103
+ },
104
+ {
105
+ target: 'codeowners',
106
+ description: 'Scaffold .github/CODEOWNERS with commented examples',
107
+ appliesTo: ['CODEOWNERS'],
108
+ outputs: ['.github/CODEOWNERS'],
109
+ riskLevel: 'safe-add',
110
+ canFixDrift: false,
111
+ async run({ targetDir }) {
112
+ const written = await generateCodeowners(targetDir);
113
+ return { filesWritten: [written] };
114
+ },
115
+ },
116
+ {
117
+ target: 'community-health',
118
+ description: 'Scaffold CONTRIBUTING.md, SECURITY.md, PR + issue templates',
119
+ appliesTo: ['Community health'],
120
+ outputs: [
121
+ 'CONTRIBUTING.md',
122
+ 'SECURITY.md',
123
+ '.github/PULL_REQUEST_TEMPLATE.md',
124
+ '.github/ISSUE_TEMPLATE/bug_report.md',
125
+ '.github/ISSUE_TEMPLATE/feature_request.md',
126
+ ],
127
+ riskLevel: 'safe-add',
128
+ canFixDrift: false,
129
+ async run({ targetDir }) {
130
+ const filesWritten = await generateCommunityHealth(targetDir);
131
+ return { filesWritten };
132
+ },
133
+ },
134
+ {
135
+ target: 'ai',
136
+ description: 'Install all AI agent files at once (AGENTS.md, CLAUDE.md, Cursor, Copilot, Claude skill, MCP example)',
137
+ appliesTo: ['AI setup'],
138
+ outputs: [
139
+ 'AGENTS.md',
140
+ 'CLAUDE.md',
141
+ '.cursor/rules/repo-tooling.mdc',
142
+ '.github/copilot-instructions.md',
143
+ '.claude/skills/repo-tooling.md',
144
+ '.mcp.json.example',
145
+ // Only written when the repo ships its own skills/<name>/SKILL.md.
146
+ 'README.md',
147
+ ],
148
+ // Every output is a delimited-block upsert or a `.example` file — existing
149
+ // user content is never clobbered.
150
+ riskLevel: 'safe-merge',
151
+ canFixDrift: true,
152
+ async run({ targetDir }) {
153
+ const filesWritten = await installAiSetup(targetDir);
154
+ return { filesWritten };
155
+ },
156
+ },
157
+ {
158
+ target: 'claude-skill',
159
+ description: 'Install the repo-tooling Claude Code skill into .claude/skills/',
160
+ appliesTo: ['Claude skill'],
161
+ outputs: ['.claude/skills/repo-tooling.md'],
162
+ riskLevel: 'safe-add',
163
+ canFixDrift: true,
164
+ async run({ targetDir }) {
165
+ const result = await copyPreset('claude-skill', targetDir);
166
+ return { filesWritten: [result.target] };
167
+ },
168
+ },
169
+ {
170
+ target: 'cursor-rules',
171
+ description: 'Install the repo-tooling rules for Cursor (.cursor/rules/repo-tooling.mdc)',
172
+ appliesTo: ['Cursor rules'],
173
+ outputs: ['.cursor/rules/repo-tooling.mdc'],
174
+ riskLevel: 'safe-add',
175
+ canFixDrift: true,
176
+ async run({ targetDir }) {
177
+ const written = await installAgentRules(targetDir, 'cursor');
178
+ return { filesWritten: [written] };
179
+ },
180
+ },
181
+ {
182
+ target: 'copilot-instructions',
183
+ description: 'Install the repo-tooling rules for GitHub Copilot (.github/copilot-instructions.md)',
184
+ appliesTo: ['Copilot instructions'],
185
+ outputs: ['.github/copilot-instructions.md'],
186
+ // Upserts a delimited block — never clobbers the consumer's own instructions.
187
+ riskLevel: 'safe-merge',
188
+ canFixDrift: true,
189
+ async run({ targetDir }) {
190
+ const written = await installAgentRules(targetDir, 'copilot');
191
+ return { filesWritten: [written] };
192
+ },
193
+ },
194
+ {
195
+ target: 'agents-md',
196
+ description: 'Install the repo-tooling rules into AGENTS.md (universal agent instructions)',
197
+ appliesTo: ['AGENTS.md rules'],
198
+ outputs: ['AGENTS.md'],
199
+ // Upserts a delimited block — never clobbers existing AGENTS.md content.
200
+ riskLevel: 'safe-merge',
201
+ canFixDrift: true,
202
+ async run({ targetDir }) {
203
+ const written = await installAgentRules(targetDir, 'agents-md');
204
+ return { filesWritten: [written] };
205
+ },
206
+ },
207
+ ];
@@ -0,0 +1,98 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import fs from 'fs-extra';
4
+ /**
5
+ * IANA-reserved domains that can never receive mail. This is what made #327
6
+ * unrecoverable: an address at one of these can never be verified as a
7
+ * secondary email, so the commits can never be re-linked without a history
8
+ * rewrite.
9
+ */
10
+ const PLACEHOLDER_DOMAINS = ['example.com', 'example.org', 'example.net', 'invalid', 'test'];
11
+ /**
12
+ * Suffixes of a machine-derived hostname. `.local` is mDNS (macOS default),
13
+ * `.matrix` is this user's LAN — both produced real commits in the #327 set.
14
+ */
15
+ const HOSTNAME_SUFFIXES = ['.local', '.matrix', '.localhost', '.lan', '.home', '.internal'];
16
+ /** Pure so the rules are testable without a git repo or a spawn. */
17
+ export function classifyGitEmail(raw) {
18
+ const email = (raw ?? '').trim().toLowerCase();
19
+ if (email === '')
20
+ return 'unset';
21
+ // lastIndexOf: a quoted local part may legally contain '@'.
22
+ const at = email.lastIndexOf('@');
23
+ const domain = at === -1 ? '' : email.slice(at + 1);
24
+ // No '@' at all, or nothing either side of it — not an address.
25
+ if (domain === '' || at === 0)
26
+ return 'generated';
27
+ if (PLACEHOLDER_DOMAINS.some((d) => domain === d || domain.endsWith(`.${d}`))) {
28
+ return 'placeholder';
29
+ }
30
+ // A bare hostname has no dot at all (`richard@laptop`); the suffixes catch
31
+ // the ones that do (`richard@Richards-Mini.matrix`).
32
+ if (!domain.includes('.') || HOSTNAME_SUFFIXES.some((s) => domain.endsWith(s))) {
33
+ return 'generated';
34
+ }
35
+ return 'ok';
36
+ }
37
+ const DETAIL = {
38
+ unset: () => 'git user.email is not set — git will invent one from the hostname at commit time',
39
+ placeholder: (e) => `git user.email is a placeholder that can never receive mail: ${e}`,
40
+ generated: (e) => `git user.email looks machine-derived, not a real address: ${e}`,
41
+ };
42
+ const HINT = 'Commits made with this identity will not link to your forge account, and the address cannot be added as a verified secondary email to fix them retroactively (#327). Set a real one: `git config --global user.email you@yourdomain.com` — or per-repo, drop `--global`.';
43
+ const CHECK = 'Git identity';
44
+ const GIT_TIMEOUT_MS = 5_000;
45
+ /** Never rejects; a missing or failing git resolves to null. */
46
+ export const realGitExec = (args, cwd) => new Promise((resolve) => {
47
+ let settled = false;
48
+ const done = (v) => {
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ resolve(v);
54
+ };
55
+ // Args are internal constants, never user free-text — shell:false keeps
56
+ // this injection-safe.
57
+ const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
58
+ let stdout = '';
59
+ const timer = setTimeout(() => {
60
+ child.kill();
61
+ done(null);
62
+ }, GIT_TIMEOUT_MS);
63
+ child.stdout?.on('data', (d) => {
64
+ stdout += d;
65
+ });
66
+ child.on('close', (code) => done(code === 0 ? stdout.trim() : null));
67
+ child.on('error', () => done(null));
68
+ });
69
+ /**
70
+ * STATUS: a bad identity is never `drift` or `missing`, both of which exit 1.
71
+ * The identity belongs to whoever is running doctor, not to the repo — failing
72
+ * CI over the runner's git config would be noise the repo's author cannot fix
73
+ * by changing the repo. `optional-missing` reports it in gray and leaves the
74
+ * exit code alone.
75
+ */
76
+ export async function checkGitIdentity(dir, exec) {
77
+ // Cheap gate: no .git → no commits to mis-attribute, and no spawn.
78
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
79
+ return { check: CHECK, status: 'ok', detail: 'not a git repository' };
80
+ }
81
+ // On a runner the identity is the bot's, so there is nothing to warn about
82
+ // and the warning would be permanent.
83
+ if (process.env.CI) {
84
+ return { check: CHECK, status: 'ok', detail: 'skipped on CI (identity is the runner’s)' };
85
+ }
86
+ const git = exec ?? ((args) => realGitExec(args, dir));
87
+ const email = await git(['config', '--get', 'user.email']);
88
+ const verdict = classifyGitEmail(email);
89
+ if (verdict === 'ok') {
90
+ return { check: CHECK, status: 'ok', detail: `git user.email is ${email}` };
91
+ }
92
+ return {
93
+ check: CHECK,
94
+ status: 'optional-missing',
95
+ detail: DETAIL[verdict](email ?? ''),
96
+ hint: HINT,
97
+ };
98
+ }
@@ -0,0 +1,461 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs-extra';
5
+ const GH_TIMEOUT_MS = 10_000;
6
+ /**
7
+ * Real `gh` runner — never rejects; a missing/failing gh resolves ok:false.
8
+ * `cwd` scopes gh's repo resolution to the target dir so `-d/--directory` is
9
+ * honored (gh otherwise resolves the remote from process.cwd()). Not annotated
10
+ * `: GhExec` so the optional cwd stays callable; still assignable where GhExec
11
+ * is expected.
12
+ */
13
+ export const realGhExec = (args, stdin, cwd) => new Promise((resolve) => {
14
+ let settled = false;
15
+ const done = (r) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ resolve(r);
21
+ };
22
+ // gh args are internal/derived from gh itself (never user free-text), so
23
+ // shell:false + an args array keeps this injection-safe.
24
+ const child = spawn('gh', args, {
25
+ cwd,
26
+ stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
27
+ });
28
+ let stdout = '';
29
+ let stderr = '';
30
+ const timer = setTimeout(() => {
31
+ child.kill();
32
+ done({ ok: false, stdout: '', stderr: 'gh timed out', code: null });
33
+ }, GH_TIMEOUT_MS);
34
+ child.stdout?.on('data', (d) => {
35
+ stdout += d;
36
+ });
37
+ child.stderr?.on('data', (d) => {
38
+ stderr += d;
39
+ });
40
+ child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code }));
41
+ child.on('error', (err) => done({ ok: false, stdout: '', stderr: String(err), code: null }));
42
+ if (stdin !== undefined && child.stdin) {
43
+ child.stdin.write(stdin);
44
+ child.stdin.end();
45
+ }
46
+ });
47
+ /** The standard doctor checks these settings against. */
48
+ export const GITHUB_STANDARD = {
49
+ // Required status contexts on the default branch (strict off — see below).
50
+ requiredContexts: ['lint', 'typecheck', 'build', 'test'],
51
+ };
52
+ const CODE_SCANNING_CHECK = 'Code-scanning gate';
53
+ const CHECK_NAMES = [
54
+ 'Branch protection',
55
+ 'Merge settings',
56
+ 'Workflow permissions',
57
+ CODE_SCANNING_CHECK,
58
+ ];
59
+ // Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
60
+ // override surface: bump them here for a stricter/looser fleet-wide baseline.
61
+ // ponytail: module constants, not per-repo config, until a repo actually needs to differ.
62
+ const CODE_SCANNING_THRESHOLDS = {
63
+ security_alerts_threshold: 'high_or_higher',
64
+ alerts_threshold: 'errors',
65
+ };
66
+ const CODE_SCANNING_RULESET_NAME = 'code-scanning-main';
67
+ /** The branch ruleset POSTed to require code-scanning results before merge (#269). */
68
+ const CODE_SCANNING_RULESET_BODY = JSON.stringify({
69
+ name: CODE_SCANNING_RULESET_NAME,
70
+ target: 'branch',
71
+ enforcement: 'active',
72
+ // ~DEFAULT_BRANCH keeps this branch-name-agnostic across the fleet.
73
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
74
+ rules: [
75
+ {
76
+ type: 'code_scanning',
77
+ parameters: {
78
+ code_scanning_tools: [{ tool: 'CodeQL', ...CODE_SCANNING_THRESHOLDS }],
79
+ },
80
+ },
81
+ ],
82
+ });
83
+ /** All three checks as an `ok` skip — keeps them out of next-steps and exit code. */
84
+ function skipAll(reason) {
85
+ return CHECK_NAMES.map((check) => ({ check, status: 'ok', detail: `skipped — ${reason}` }));
86
+ }
87
+ function skip(check, reason) {
88
+ return { check, status: 'ok', detail: `skipped — ${reason}` };
89
+ }
90
+ /**
91
+ * One combined probe: proves gh is installed + authed + has a GitHub remote +
92
+ * online, and carries identity, default branch, and the merge settings. Reads
93
+ * the REST repo endpoint (gh resolves the `{owner}/{repo}` placeholder from the
94
+ * remote) rather than `gh repo view --json` because auto-merge (`allow_auto_merge`)
95
+ * is not a `gh repo view` field at all — requesting it fails the whole call.
96
+ */
97
+ async function probeRepo(exec) {
98
+ const r = await exec(['api', 'repos/{owner}/{repo}']);
99
+ if (!r.ok)
100
+ return { skip: probeFailureReason(r) };
101
+ let d;
102
+ try {
103
+ d = JSON.parse(r.stdout);
104
+ }
105
+ catch {
106
+ return { skip: 'could not parse gh output' };
107
+ }
108
+ const nwo = typeof d.full_name === 'string' ? d.full_name : undefined;
109
+ if (!nwo)
110
+ return { skip: 'no GitHub remote' };
111
+ return {
112
+ info: {
113
+ nwo,
114
+ branch: typeof d.default_branch === 'string' ? d.default_branch : 'main',
115
+ autoMerge: d.allow_auto_merge === true,
116
+ squashMerge: d.allow_squash_merge === true,
117
+ deleteOnMerge: d.delete_branch_on_merge === true,
118
+ mergeVisible: ['allow_auto_merge', 'allow_squash_merge', 'delete_branch_on_merge'].every((k) => typeof d[k] === 'boolean'),
119
+ },
120
+ };
121
+ }
122
+ export async function checkGitHubSettings(dir, exec) {
123
+ // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
124
+ if (!(await fs.pathExists(path.join(dir, '.git'))))
125
+ return skipAll('not a git repository');
126
+ // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
127
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
128
+ const probe = await probeRepo(gh);
129
+ if ('skip' in probe)
130
+ return skipAll(probe.skip);
131
+ const { info } = probe;
132
+ return [
133
+ await checkBranchProtection(gh, info.nwo, info.branch),
134
+ checkMergeSettings(info),
135
+ await checkWorkflowPermissions(gh, info.nwo),
136
+ await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
137
+ ];
138
+ }
139
+ function probeFailureReason(probe) {
140
+ if (probe.code === null)
141
+ return 'gh not installed';
142
+ if (/not logged|gh auth login|authentication/i.test(probe.stderr))
143
+ return 'gh not authenticated';
144
+ return 'not a GitHub repo or gh unavailable';
145
+ }
146
+ async function checkBranchProtection(exec, nwo, branch) {
147
+ const check = 'Branch protection';
148
+ const r = await exec(['api', `repos/${nwo}/branches/${branch}/protection`]);
149
+ if (!r.ok) {
150
+ if (/404|not found/i.test(r.stderr)) {
151
+ return {
152
+ check,
153
+ status: 'optional-missing',
154
+ detail: `${branch} is unprotected`,
155
+ hint: `Protect ${branch}: require checks ${GITHUB_STANDARD.requiredContexts.join(', ')}, no force-push/deletions`,
156
+ };
157
+ }
158
+ if (/403|forbidden/i.test(r.stderr))
159
+ return skip(check, 'token lacks admin access');
160
+ return skip(check, 'could not read branch protection');
161
+ }
162
+ let p;
163
+ try {
164
+ p = JSON.parse(r.stdout);
165
+ }
166
+ catch {
167
+ return skip(check, 'could not parse protection response');
168
+ }
169
+ const deltas = [];
170
+ const contexts = p.required_status_checks?.contexts ?? [];
171
+ // A matrix job reports each leg as `test (node 22)`, `test (node 24)`, etc.
172
+ // Treat any `<ctx> (...)` variant as satisfying the bare `<ctx>` requirement,
173
+ // so matrix CIs aren't falsely flagged as missing the check.
174
+ const isSatisfied = (c) => contexts.some((ctx) => ctx === c || ctx.startsWith(`${c} (`));
175
+ const missing = GITHUB_STANDARD.requiredContexts.filter((c) => !isSatisfied(c));
176
+ if (missing.length)
177
+ deltas.push(`missing required checks: ${missing.join(', ')}`);
178
+ if (p.required_status_checks?.strict === true)
179
+ deltas.push('strict status checks on (should be off)');
180
+ // enforce_admins must stay off so the App/RELEASE_TOKEN can bypass protection
181
+ // to push semantic-release's version commit (see Release-token doctor check).
182
+ if (p.enforce_admins?.enabled === true)
183
+ deltas.push('enforce_admins on (blocks release bypass)');
184
+ if (p.allow_force_pushes?.enabled === true)
185
+ deltas.push('force pushes allowed');
186
+ if (p.allow_deletions?.enabled === true)
187
+ deltas.push('branch deletions allowed');
188
+ // Required human review deadlocks solo Dependabot auto-merge.
189
+ if (p.required_pull_request_reviews)
190
+ deltas.push('required PR reviews on (deadlocks auto-merge)');
191
+ if (deltas.length)
192
+ return {
193
+ check,
194
+ status: 'drift',
195
+ detail: deltas.join('; '),
196
+ hint: `Align ${branch} protection with the standard`,
197
+ };
198
+ return { check, status: 'ok', detail: `${branch} protected per standard` };
199
+ }
200
+ function checkMergeSettings(info) {
201
+ const check = 'Merge settings';
202
+ // The token can't see the merge-setting fields (no admin:read) — skip rather
203
+ // than misreport the absent booleans as "disabled" (a false-positive drift).
204
+ if (!info.mergeVisible)
205
+ return skip(check, 'token lacks admin:read for merge settings');
206
+ const deltas = [];
207
+ if (!info.autoMerge)
208
+ deltas.push('auto-merge disabled');
209
+ if (!info.squashMerge)
210
+ deltas.push('squash-merge disabled');
211
+ if (!info.deleteOnMerge)
212
+ deltas.push('delete-branch-on-merge disabled');
213
+ if (deltas.length)
214
+ return {
215
+ check,
216
+ status: 'drift',
217
+ detail: deltas.join('; '),
218
+ hint: 'Enable auto-merge, squash-merge, and delete-branch-on-merge in repo settings',
219
+ };
220
+ return { check, status: 'ok', detail: 'auto-merge, squash-merge, delete-on-merge all on' };
221
+ }
222
+ async function checkWorkflowPermissions(exec, nwo) {
223
+ const check = 'Workflow permissions';
224
+ const r = await exec(['api', `repos/${nwo}/actions/permissions/workflow`]);
225
+ if (!r.ok) {
226
+ if (/403|forbidden/i.test(r.stderr))
227
+ return skip(check, 'token lacks admin access');
228
+ return skip(check, 'could not read workflow permissions');
229
+ }
230
+ let p;
231
+ try {
232
+ p = JSON.parse(r.stdout);
233
+ }
234
+ catch {
235
+ return skip(check, 'could not parse permissions response');
236
+ }
237
+ const deltas = [];
238
+ if (p.default_workflow_permissions !== 'read')
239
+ deltas.push(`default permissions '${p.default_workflow_permissions}' (should be read)`);
240
+ if (p.can_approve_pull_request_reviews === true)
241
+ deltas.push('workflows can approve PRs (should be off)');
242
+ if (deltas.length)
243
+ return {
244
+ check,
245
+ status: 'drift',
246
+ detail: deltas.join('; '),
247
+ hint: 'Set default workflow permissions to read-only and disable workflow PR approvals',
248
+ };
249
+ return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
250
+ }
251
+ /**
252
+ * True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
253
+ * ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
254
+ * GitHub's default setup (no file — ask the code-scanning API). Mirrors doctor's
255
+ * on-disk CodeQL check; kept inline to avoid a doctor↔settings import cycle.
256
+ */
257
+ async function codeqlEnabled(gh, nwo, dir) {
258
+ const workflowsDir = path.join(dir, '.github', 'workflows');
259
+ if (await fs.pathExists(workflowsDir)) {
260
+ try {
261
+ for (const f of await fs.readdir(workflowsDir)) {
262
+ if (!/\.ya?ml$/.test(f))
263
+ continue;
264
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
265
+ if (/github\/codeql-action/.test(content))
266
+ return true;
267
+ }
268
+ }
269
+ catch {
270
+ // fall through to the API probe
271
+ }
272
+ }
273
+ // Default setup leaves no workflow file — the API is the only signal.
274
+ const r = await gh(['api', `repos/${nwo}/code-scanning/default-setup`]);
275
+ if (!r.ok)
276
+ return false;
277
+ try {
278
+ return JSON.parse(r.stdout).state === 'configured';
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ }
284
+ /** True when a ruleset's ref_name conditions cover the default branch. */
285
+ function rulesetTargetsBranch(conditions, branch) {
286
+ const include = conditions?.ref_name?.include ?? [];
287
+ return include.some((ref) => ref === '~DEFAULT_BRANCH' || ref === '~ALL' || ref === `refs/heads/${branch}`);
288
+ }
289
+ /**
290
+ * Does an active branch ruleset enforce a code_scanning rule on the default
291
+ * branch? The list endpoint omits rules/conditions, so active branch rulesets
292
+ * are fetched by id to inspect them. 'skip' on any read failure (self-skips like
293
+ * the other checks).
294
+ */
295
+ async function hasCodeScanningRuleset(gh, nwo, branch) {
296
+ const list = await gh(['api', `repos/${nwo}/rulesets`]);
297
+ if (!list.ok)
298
+ return 'skip';
299
+ let rulesets;
300
+ try {
301
+ rulesets = JSON.parse(list.stdout);
302
+ }
303
+ catch {
304
+ return 'skip';
305
+ }
306
+ if (!Array.isArray(rulesets))
307
+ return 'skip';
308
+ const active = rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active' && typeof r.id === 'number');
309
+ for (const rs of active) {
310
+ const detail = await gh(['api', `repos/${nwo}/rulesets/${rs.id}`]);
311
+ if (!detail.ok)
312
+ continue;
313
+ let full;
314
+ try {
315
+ full = JSON.parse(detail.stdout);
316
+ }
317
+ catch {
318
+ continue;
319
+ }
320
+ const enforcesCodeScanning = (full.rules ?? []).some((r) => r.type === 'code_scanning');
321
+ if (enforcesCodeScanning && rulesetTargetsBranch(full.conditions, branch))
322
+ return 'yes';
323
+ }
324
+ return 'no';
325
+ }
326
+ /**
327
+ * The #269 gap: CodeQL results are advisory by default — a High alert still
328
+ * merges unless a branch ruleset requires the code-scanning check. Only
329
+ * meaningful where CodeQL is actually on, so it no-ops otherwise.
330
+ */
331
+ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
332
+ const check = CODE_SCANNING_CHECK;
333
+ if (!(await codeqlEnabled(gh, nwo, dir))) {
334
+ return { check, status: 'ok', detail: 'CodeQL not enabled — no code-scanning gate needed' };
335
+ }
336
+ const found = await hasCodeScanningRuleset(gh, nwo, branch);
337
+ if (found === 'skip')
338
+ return skip(check, 'could not read rulesets');
339
+ if (found === 'yes') {
340
+ return { check, status: 'ok', detail: `active ruleset requires code-scanning on ${branch}` };
341
+ }
342
+ return {
343
+ check,
344
+ status: 'drift',
345
+ detail: `CodeQL is on but no active ruleset requires code-scanning on ${branch} (High alerts stay advisory)`,
346
+ hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
347
+ };
348
+ }
349
+ /** The branch-protection body PUT to the API — mirrors the doctor standard. */
350
+ const PROTECTION_BODY = JSON.stringify({
351
+ required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
352
+ // enforce_admins off so the App/RELEASE_TOKEN can bypass to push release commits.
353
+ enforce_admins: false,
354
+ // Required human review would deadlock solo Dependabot auto-merge.
355
+ required_pull_request_reviews: null,
356
+ restrictions: null,
357
+ allow_force_pushes: false,
358
+ allow_deletions: false,
359
+ });
360
+ /** Pure: the exact `gh api` invocations for whatever deviates ([] when compliant). */
361
+ export function buildGhApplyCommands(state) {
362
+ const commands = [];
363
+ if (state.merge)
364
+ commands.push({
365
+ label: 'merge settings (auto-merge, squash, delete-on-merge)',
366
+ args: [
367
+ 'api',
368
+ '-X',
369
+ 'PATCH',
370
+ `repos/${state.nwo}`,
371
+ '-F',
372
+ 'allow_auto_merge=true',
373
+ '-F',
374
+ 'allow_squash_merge=true',
375
+ '-F',
376
+ 'delete_branch_on_merge=true',
377
+ ],
378
+ });
379
+ if (state.protection)
380
+ commands.push({
381
+ label: `branch protection on ${state.branch}`,
382
+ args: [
383
+ 'api',
384
+ '-X',
385
+ 'PUT',
386
+ `repos/${state.nwo}/branches/${state.branch}/protection`,
387
+ '--input',
388
+ '-',
389
+ ],
390
+ stdin: PROTECTION_BODY,
391
+ });
392
+ if (state.workflow)
393
+ commands.push({
394
+ label: 'workflow permissions (read-only)',
395
+ args: [
396
+ 'api',
397
+ '-X',
398
+ 'PUT',
399
+ `repos/${state.nwo}/actions/permissions/workflow`,
400
+ '-f',
401
+ 'default_workflow_permissions=read',
402
+ '-F',
403
+ 'can_approve_pull_request_reviews=false',
404
+ ],
405
+ });
406
+ return commands;
407
+ }
408
+ /**
409
+ * Re-reads GitHub state and applies only the deltas via `gh api` (idempotent —
410
+ * a compliant repo is a no-op). Returns human labels for what changed, or `[]`
411
+ * on skip (no gh/auth/remote, or already compliant), logging the reason. Mirrors
412
+ * the "no package.json found — skipping" fixer pattern. Read-only unless a delta
413
+ * exists, so re-runs (walk-all hits it up to 3×) are safe.
414
+ */
415
+ export async function applyGithubSettings(dir, exec) {
416
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
417
+ console.log(chalk.gray(' skipped — not a git repository'));
418
+ return [];
419
+ }
420
+ // Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
421
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
422
+ const probe = await probeRepo(gh);
423
+ if ('skip' in probe) {
424
+ console.log(chalk.gray(` skipped — ${probe.skip}`));
425
+ return [];
426
+ }
427
+ const { info } = probe;
428
+ // Re-read via the same checks so the delta logic stays single-sourced. A 403
429
+ // (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
430
+ const bp = await checkBranchProtection(gh, info.nwo, info.branch);
431
+ const wp = await checkWorkflowPermissions(gh, info.nwo);
432
+ const commands = buildGhApplyCommands({
433
+ nwo: info.nwo,
434
+ branch: info.branch,
435
+ merge: checkMergeSettings(info).status === 'drift',
436
+ protection: bp.status === 'optional-missing' || bp.status === 'drift',
437
+ workflow: wp.status === 'drift',
438
+ });
439
+ const applied = [];
440
+ for (const cmd of commands) {
441
+ const r = await gh(cmd.args, cmd.stdin);
442
+ if (r.ok)
443
+ applied.push(cmd.label);
444
+ else
445
+ console.log(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
446
+ }
447
+ // Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
448
+ // covers the default branch — the check re-read keeps this idempotent.
449
+ const cs = await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir);
450
+ if (cs.status === 'drift') {
451
+ const label = `code-scanning ruleset on ${info.branch}`;
452
+ const r = await gh(['api', '-X', 'POST', `repos/${info.nwo}/rulesets`, '--input', '-'], CODE_SCANNING_RULESET_BODY);
453
+ if (r.ok)
454
+ applied.push(label);
455
+ else
456
+ console.log(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
457
+ }
458
+ if (applied.length === 0)
459
+ console.log(chalk.gray(' already configured — nothing to apply'));
460
+ return applied;
461
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -102,7 +102,7 @@ export function githubJobs(config) {
102
102
  ? `
103
103
 
104
104
  - name: 📊 Upload coverage to Codecov
105
- uses: codecov/codecov-action@v5
105
+ uses: codecov/codecov-action@v7
106
106
  with:
107
107
  token: \${{ secrets.CODECOV_TOKEN }}
108
108
  fail_ci_if_error: false`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.2.4",
3
+ "version": "3.2.5",
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": [
@@ -46,6 +46,7 @@
46
46
  "files": [
47
47
  "dist/cli/**/*.js",
48
48
  "dist/languages/**/*.js",
49
+ "dist/base/**/*.js",
49
50
  "tooling/commitlint/commitlint.mjs",
50
51
  "tooling/commitlint/commitlint.d.mts",
51
52
  "tooling/esbuild/index.mjs",
@@ -26,7 +26,7 @@ jobs:
26
26
  node-version-file: .nvmrc
27
27
 
28
28
  - name: Setup pnpm
29
- uses: pnpm/action-setup@v4
29
+ uses: pnpm/action-setup@v6
30
30
 
31
31
  - name: Install dependencies
32
32
  run: pnpm install --frozen-lockfile