@rtorcato/repo-tooling 3.2.3 → 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.
- package/dist/base/checks.js +410 -0
- package/dist/base/ci.js +133 -0
- package/dist/base/fixers.js +207 -0
- package/dist/base/git-identity.js +98 -0
- package/dist/base/github-settings.js +461 -0
- package/dist/base/types.js +1 -0
- package/dist/cli/generators/linting.js +11 -1
- package/dist/cli/generators/package-json.js +27 -0
- package/dist/cli/generators/testing.js +12 -1
- package/dist/cli/generators/tsconfig.js +6 -0
- package/dist/languages/js/ci.js +1 -1
- package/package.json +2 -1
- package/tooling/eslint/base.mjs +8 -0
- package/tooling/github-actions/workflows/cloudflare-pages.yml +1 -1
- package/tooling/typescript/tsconfig.base.json +6 -2
- package/tooling/typescript/tsconfig.next.json +8 -1
- package/tooling/typescript/tsconfig.react.json +7 -1
|
@@ -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
|
+
}
|