@rtorcato/repo-tooling 3.6.0 → 3.7.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 +1 -1
- package/dist/base/checks.js +15 -1
- package/dist/base/fixers.js +9 -1
- package/dist/cli/commands/doctor.js +40 -8
- package/dist/cli/commands/fix-targets.js +16 -0
- package/dist/cli/commands/fix.js +10 -2
- package/dist/cli/index.js +12 -0
- package/dist/cli/utils/copy-preset.js +10 -0
- package/dist/languages/perl/checks.js +177 -0
- package/dist/languages/perl/ci.js +191 -0
- package/dist/languages/perl/fixers.js +93 -0
- package/dist/languages/perl/git-hooks.js +53 -0
- package/dist/languages/perl/gitignore.js +65 -0
- package/dist/languages/perl/sources.js +22 -0
- package/dist/languages/python/fixers.js +1 -1
- package/dist/languages/registry.js +1 -1
- package/package.json +3 -1
- package/tooling/perl/perlcriticrc +35 -0
- package/tooling/perl/perltidyrc +23 -0
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ Orientation for coding agents working with `@rtorcato/repo-tooling`. Human-reada
|
|
|
6
6
|
|
|
7
7
|
A one-package JavaScript / TypeScript tooling distribution. Ships every preset (TypeScript, Biome, ESLint, Prettier, Vitest, Jest, Commitlint, semantic-release, tsup, esbuild, Vite, Playwright) plus a CLI to scaffold and audit projects. Consumers get one install.
|
|
8
8
|
|
|
9
|
-
**Swift** repos (detected via `Package.swift`) are covered end to end: `setup --preset swift-library` scaffolds a SwiftPM package, and `doctor`/`fix` run the language-agnostic checks plus SwiftLint / Periphery / `.gitignore` / `Package.swift`. **Python** repos (detected via `pyproject.toml` / `setup.py`) get `doctor`/`fix` — Ruff / mypy / pytest / `.gitignore` / CI / git hooks — but no `setup` preset yet. Perl
|
|
9
|
+
**Swift** repos (detected via `Package.swift`) are covered end to end: `setup --preset swift-library` scaffolds a SwiftPM package, and `doctor`/`fix` run the language-agnostic checks plus SwiftLint / Periphery / `.gitignore` / `Package.swift`. **Python** repos (detected via `pyproject.toml` / `setup.py`) get `doctor`/`fix` — Ruff / mypy / pytest / `.gitignore` / CI / git hooks — but no `setup` preset yet. **Perl** distributions (detected via `cpanfile` / `Makefile.PL` / `dist.ini`) get the same deal: Perl::Critic / perltidy / `.gitignore` / CI / git hooks, no `setup` preset. See `src/languages/` — one directory per language module, `src/base/` for what's shared.
|
|
10
10
|
|
|
11
11
|
## CLI surface (agent-friendly)
|
|
12
12
|
|
package/dist/base/checks.js
CHANGED
|
@@ -233,7 +233,21 @@ export async function checkDependabot(dir) {
|
|
|
233
233
|
hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` (or `fix renovate`) to scaffold weekly dep updates',
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
|
-
|
|
236
|
+
/**
|
|
237
|
+
* `languages` is the detected module's `codeqlLanguages`. An empty list means
|
|
238
|
+
* CodeQL ships no analyzer for this language at all (Perl, #289) — reporting it
|
|
239
|
+
* as missing would send the user to `fix codeql`, whose generator correctly
|
|
240
|
+
* writes nothing for an empty matrix, so they'd get a silent no-op and a check
|
|
241
|
+
* that never goes green.
|
|
242
|
+
*/
|
|
243
|
+
export async function checkCodeQL(dir, languages) {
|
|
244
|
+
if (languages.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
check: 'CodeQL',
|
|
247
|
+
status: 'ok',
|
|
248
|
+
detail: 'not applicable — CodeQL has no analyzer for this language',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
237
251
|
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
238
252
|
if (!(await fs.pathExists(workflowsDir))) {
|
|
239
253
|
return {
|
package/dist/base/fixers.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* language-shaped (CI steps, recorded tool choices), so each module ships its
|
|
12
12
|
* own — see src/base/ci.ts for the shell they share.
|
|
13
13
|
*/
|
|
14
|
+
import chalk from 'chalk';
|
|
14
15
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
15
16
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
16
17
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
@@ -78,7 +79,14 @@ export const BASE_FIXERS = [
|
|
|
78
79
|
appliesTo: ['CodeQL'],
|
|
79
80
|
outputs: ['.github/workflows/codeql.yml'],
|
|
80
81
|
async run({ targetDir }) {
|
|
81
|
-
const { codeqlLanguages } = await moduleFor(targetDir);
|
|
82
|
+
const { codeqlLanguages, label } = await moduleFor(targetDir);
|
|
83
|
+
// Say so rather than reporting a successful fix that wrote nothing: the
|
|
84
|
+
// generator correctly emits no workflow for an empty matrix (Perl, #289),
|
|
85
|
+
// and a silent no-op reads as "done".
|
|
86
|
+
if (codeqlLanguages.length === 0) {
|
|
87
|
+
console.error(chalk.yellow(` skipped — CodeQL has no ${label} analyzer`));
|
|
88
|
+
return { filesWritten: [] };
|
|
89
|
+
}
|
|
82
90
|
return { filesWritten: await generateCodeQLWorkflow(targetDir, codeqlLanguages) };
|
|
83
91
|
},
|
|
84
92
|
},
|
|
@@ -4,6 +4,8 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import { renderGitHubWorkflow } from '../../base/ci.js';
|
|
5
5
|
import { githubJobs } from '../../languages/js/ci.js';
|
|
6
6
|
import { inferProjectConfig } from '../../languages/js/fixers.js';
|
|
7
|
+
import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
|
|
8
|
+
import { readPerlProject, renderPerlWorkflow } from '../../languages/perl/ci.js';
|
|
7
9
|
import { PYTHON_GIT_HOOKS, runPythonChecks } from '../../languages/python/checks.js';
|
|
8
10
|
import { readPyproject, renderPythonWorkflow } from '../../languages/python/ci.js';
|
|
9
11
|
import { resolveLanguageModule } from '../../languages/registry.js';
|
|
@@ -157,7 +159,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
157
159
|
}
|
|
158
160
|
results.push(await checkGitHubActions(dir, opts.presetWorkflow));
|
|
159
161
|
results.push(await checkDependabot(dir));
|
|
160
|
-
results.push(await checkCodeQL(dir));
|
|
162
|
+
results.push(await checkCodeQL(dir, opts.codeqlLanguages));
|
|
161
163
|
// GitHub repo-settings drift (branch protection, merge settings, workflow
|
|
162
164
|
// permissions). Read-only; self-skips as `ok` outside a live GitHub repo.
|
|
163
165
|
results.push(...(await checkGitHubSettings(dir)));
|
|
@@ -174,12 +176,14 @@ export async function runDoctor(dir) {
|
|
|
174
176
|
const lock = await readLockfile(targetDir);
|
|
175
177
|
// Per-module dispatch (#285): the base checks (repo hygiene, CI, security,
|
|
176
178
|
// GitHub settings) apply to any repo and run for every language. A supported
|
|
177
|
-
// module layers its own checks on top
|
|
178
|
-
//
|
|
179
|
-
// 'unknown' (bare dir mid-setup) resolves to JS so a fresh repo runs the
|
|
180
|
-
// full suite.
|
|
179
|
+
// module layers its own checks on top. 'unknown' (bare dir mid-setup)
|
|
180
|
+
// resolves to JS so a fresh repo runs the full suite.
|
|
181
181
|
const language = await detectLanguage(targetDir);
|
|
182
182
|
const languageModule = resolveLanguageModule(language);
|
|
183
|
+
// Every language in the registry has a module as of #289, so nothing reaches
|
|
184
|
+
// this today. It stays as the on-ramp: a language is added to the registry
|
|
185
|
+
// with `supported: false` first, and until its module lands its repos get the
|
|
186
|
+
// full base suite rather than the wholesale skip this replaced.
|
|
183
187
|
if (!languageModule.supported) {
|
|
184
188
|
const results = [
|
|
185
189
|
{
|
|
@@ -187,14 +191,14 @@ export async function runDoctor(dir) {
|
|
|
187
191
|
status: 'ok',
|
|
188
192
|
detail: `detected ${languageModule.label} — running language-agnostic checks; ${languageModule.label}-specific checks land with its module (#139)`,
|
|
189
193
|
},
|
|
190
|
-
// hooks: null —
|
|
191
|
-
//
|
|
192
|
-
// exist. #289 fills it in.
|
|
194
|
+
// hooks: null — guessing a hook convention would nag every repo with a
|
|
195
|
+
// fix target that doesn't exist.
|
|
193
196
|
...(await runBaseChecks(targetDir, lock, {
|
|
194
197
|
hooks: null,
|
|
195
198
|
badges: { audience: 'public', fixTarget: null },
|
|
196
199
|
presetWorkflow: null,
|
|
197
200
|
language,
|
|
201
|
+
codeqlLanguages: languageModule.codeqlLanguages,
|
|
198
202
|
})),
|
|
199
203
|
];
|
|
200
204
|
return demoteDeclined(results, lock);
|
|
@@ -216,6 +220,7 @@ export async function runDoctor(dir) {
|
|
|
216
220
|
badges: { audience: 'public', fixTarget: null },
|
|
217
221
|
presetWorkflow: renderSwiftWorkflow(await readSwiftPackage(targetDir)),
|
|
218
222
|
language,
|
|
223
|
+
codeqlLanguages: languageModule.codeqlLanguages,
|
|
219
224
|
})),
|
|
220
225
|
...(await runSwiftChecks(targetDir)),
|
|
221
226
|
];
|
|
@@ -238,11 +243,37 @@ export async function runDoctor(dir) {
|
|
|
238
243
|
badges: { audience: 'public', fixTarget: null },
|
|
239
244
|
presetWorkflow: renderPythonWorkflow(await readPyproject(targetDir)),
|
|
240
245
|
language,
|
|
246
|
+
codeqlLanguages: languageModule.codeqlLanguages,
|
|
241
247
|
})),
|
|
242
248
|
...(await runPythonChecks(targetDir)),
|
|
243
249
|
];
|
|
244
250
|
return demoteDeclined(results, lock);
|
|
245
251
|
}
|
|
252
|
+
// Perl suite (#289): same shape as Swift and Python — base checks plus the
|
|
253
|
+
// module's own, and nothing JS-shaped, because a distribution has no
|
|
254
|
+
// package.json. CodeQL self-reports as not applicable: it ships no Perl
|
|
255
|
+
// analyzer, which `codeqlLanguages: []` in the registry encodes.
|
|
256
|
+
if (languageModule.id === 'perl') {
|
|
257
|
+
const results = [
|
|
258
|
+
{
|
|
259
|
+
check: 'language',
|
|
260
|
+
status: 'ok',
|
|
261
|
+
detail: 'detected Perl (cpanfile / Makefile.PL / dist.ini)',
|
|
262
|
+
},
|
|
263
|
+
...(await runBaseChecks(targetDir, lock, {
|
|
264
|
+
hooks: PERL_GIT_HOOKS,
|
|
265
|
+
// A distribution released to CPAN is public by definition, so badges
|
|
266
|
+
// apply. No fixer: `fix badges` derives the block from package.json
|
|
267
|
+
// name and repository, which a Perl repo hasn't got.
|
|
268
|
+
badges: { audience: 'public', fixTarget: null },
|
|
269
|
+
presetWorkflow: renderPerlWorkflow(await readPerlProject(targetDir)),
|
|
270
|
+
language,
|
|
271
|
+
codeqlLanguages: languageModule.codeqlLanguages,
|
|
272
|
+
})),
|
|
273
|
+
...(await runPerlChecks(targetDir)),
|
|
274
|
+
];
|
|
275
|
+
return demoteDeclined(results, lock);
|
|
276
|
+
}
|
|
246
277
|
// JS suite: the module's own checks, then the shared base ones. Only the
|
|
247
278
|
// JS-shaped checks are listed here — re-listing the base suite is what made
|
|
248
279
|
// any new base check silently skip JS repos (#309).
|
|
@@ -289,6 +320,7 @@ export async function runDoctor(dir) {
|
|
|
289
320
|
badges: { audience: jsBadgeAudience(pkg), fixTarget: 'badges' },
|
|
290
321
|
presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg))),
|
|
291
322
|
language,
|
|
323
|
+
codeqlLanguages: languageModule.codeqlLanguages,
|
|
292
324
|
})));
|
|
293
325
|
return demoteDeclined(results, lock);
|
|
294
326
|
}
|
|
@@ -79,9 +79,24 @@ const PYTHON_FIX_TARGETS = {
|
|
|
79
79
|
// (package metadata, actual tests). Their own hints say what to add.
|
|
80
80
|
// `lockfile` too — see the note at the top of src/languages/python/fixers.ts.
|
|
81
81
|
};
|
|
82
|
+
/** The same shadowing for the Perl module (#289). */
|
|
83
|
+
const PERL_FIX_TARGETS = {
|
|
84
|
+
'Git hooks': 'perl-git-hooks',
|
|
85
|
+
'Pre-push hook': 'perl-git-hooks',
|
|
86
|
+
'GitHub Actions': 'perl-ci',
|
|
87
|
+
'GitLab CI': 'perl-gitlab-ci',
|
|
88
|
+
'Perl::Critic': 'perlcritic',
|
|
89
|
+
perltidy: 'perltidy',
|
|
90
|
+
'Perl .gitignore': 'perl-gitignore',
|
|
91
|
+
// `Perl distribution` and `Perl tests` are deliberately absent, for the same
|
|
92
|
+
// reason `Swift tests` is: the fix is content only the project can write
|
|
93
|
+
// (dependency metadata, actual tests). Their own hints say what to add.
|
|
94
|
+
// `lockfile` too — see the note at the top of src/languages/perl/fixers.ts.
|
|
95
|
+
};
|
|
82
96
|
const FIX_TARGETS_BY_LANGUAGE = {
|
|
83
97
|
swift: SWIFT_FIX_TARGETS,
|
|
84
98
|
python: PYTHON_FIX_TARGETS,
|
|
99
|
+
perl: PERL_FIX_TARGETS,
|
|
85
100
|
};
|
|
86
101
|
export function getFixTargetForCheck(checkName, language) {
|
|
87
102
|
const overrides = language ? FIX_TARGETS_BY_LANGUAGE[language] : undefined;
|
|
@@ -186,6 +201,7 @@ export function lockfilePatchForTarget(target, lock) {
|
|
|
186
201
|
case 'husky':
|
|
187
202
|
case 'swift-git-hooks':
|
|
188
203
|
case 'python-git-hooks':
|
|
204
|
+
case 'perl-git-hooks':
|
|
189
205
|
return c.gitHooks ? null : { gitHooks: true };
|
|
190
206
|
case 'semantic-release':
|
|
191
207
|
case 'swift-release':
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -11,13 +11,15 @@ import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
|
|
|
11
11
|
import { computeFileList } from './setup-presets.js';
|
|
12
12
|
import { BASE_FIXERS } from '../../base/fixers.js';
|
|
13
13
|
import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
|
|
14
|
+
import { PERL_FIXERS } from '../../languages/perl/fixers.js';
|
|
14
15
|
import { PYTHON_FIXERS } from '../../languages/python/fixers.js';
|
|
15
16
|
import { SWIFT_FIXERS } from '../../languages/swift/fixers.js';
|
|
16
17
|
import { detectLanguage } from '../utils/detect-language.js';
|
|
17
|
-
/** The module-specific fixer set per detected language (#286, #290, #303). */
|
|
18
|
+
/** The module-specific fixer set per detected language (#286, #289, #290, #303). */
|
|
18
19
|
const LANGUAGE_FIXERS = {
|
|
19
20
|
swift: SWIFT_FIXERS,
|
|
20
21
|
python: PYTHON_FIXERS,
|
|
22
|
+
perl: PERL_FIXERS,
|
|
21
23
|
};
|
|
22
24
|
/**
|
|
23
25
|
* The fixers that apply to a repo, by detected language: the language-agnostic
|
|
@@ -28,7 +30,13 @@ function fixersForLanguage(language) {
|
|
|
28
30
|
return [...BASE_FIXERS, ...(LANGUAGE_FIXERS[language] ?? FIXERS)];
|
|
29
31
|
}
|
|
30
32
|
/** Every fixer across every language — for `--list` and the unknown-target hint. */
|
|
31
|
-
const ALL_FIXERS = [
|
|
33
|
+
const ALL_FIXERS = [
|
|
34
|
+
...BASE_FIXERS,
|
|
35
|
+
...FIXERS,
|
|
36
|
+
...SWIFT_FIXERS,
|
|
37
|
+
...PYTHON_FIXERS,
|
|
38
|
+
...PERL_FIXERS,
|
|
39
|
+
];
|
|
32
40
|
export function getFixers() {
|
|
33
41
|
return ALL_FIXERS;
|
|
34
42
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -269,6 +269,18 @@ const TOOL_CATALOG = [
|
|
|
269
269
|
exports: [],
|
|
270
270
|
fixTarget: 'pytest',
|
|
271
271
|
},
|
|
272
|
+
{
|
|
273
|
+
name: 'Perl::Critic',
|
|
274
|
+
description: 'Perl linter configuration (severity 3, with the family rule exceptions)',
|
|
275
|
+
exports: [],
|
|
276
|
+
fixTarget: 'perlcritic',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'perltidy',
|
|
280
|
+
description: 'Perl formatter configuration (Perl Best Practices layout at 100 columns)',
|
|
281
|
+
exports: [],
|
|
282
|
+
fixTarget: 'perltidy',
|
|
283
|
+
},
|
|
272
284
|
{
|
|
273
285
|
name: 'AI Agent Setup',
|
|
274
286
|
description: 'Scaffold AGENTS.md, CLAUDE.md, Cursor/Copilot rules, Claude skill, MCP example',
|
|
@@ -66,6 +66,16 @@ export const PRESETS = {
|
|
|
66
66
|
target: 'pytest.ini',
|
|
67
67
|
desc: 'pytest configuration (strict markers and config, warnings as errors)',
|
|
68
68
|
},
|
|
69
|
+
perlcritic: {
|
|
70
|
+
source: 'tooling/perl/perlcriticrc',
|
|
71
|
+
target: '.perlcriticrc',
|
|
72
|
+
desc: 'Perl::Critic configuration (severity 3, with the family rule exceptions)',
|
|
73
|
+
},
|
|
74
|
+
perltidy: {
|
|
75
|
+
source: 'tooling/perl/perltidyrc',
|
|
76
|
+
target: '.perltidyrc',
|
|
77
|
+
desc: 'perltidy configuration (Perl Best Practices layout at 100 columns)',
|
|
78
|
+
},
|
|
69
79
|
'claude-skill': {
|
|
70
80
|
source: 'tooling/claude/repo-tooling.md',
|
|
71
81
|
target: '.claude/skills/repo-tooling.md',
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perl language module — checks (#289), on the same template as Swift (#286)
|
|
3
|
+
* and Python (#290).
|
|
4
|
+
*
|
|
5
|
+
* The standard encoded here is Perl::Critic (lint) and perltidy (format), with
|
|
6
|
+
* prove/Test::More as the runner. Deliberately *not* checked:
|
|
7
|
+
*
|
|
8
|
+
* - **A `pre-commit` framework config** — the hooks in `./git-hooks.ts` call
|
|
9
|
+
* the tools directly, exactly as CI does, rather than adding a second config
|
|
10
|
+
* file and a second pinned-tool source to keep in sync.
|
|
11
|
+
* - **`Perl::Tidy` as a lint rule** (`CodeLayout::RequireTidyCode`) — running
|
|
12
|
+
* the formatter from inside the linter reports "this file is untidy" without
|
|
13
|
+
* saying how, and it makes a perlcritic run depend on perltidy being
|
|
14
|
+
* installed. The CI job runs perltidy directly and shows the diff instead.
|
|
15
|
+
* - **A pinned interpreter file** — the floor is declared once in the
|
|
16
|
+
* distribution's metadata, and CI derives its matrix from that. A root file
|
|
17
|
+
* would be a second place to keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'fs-extra';
|
|
21
|
+
import { checkFile } from '../../base/checks.js';
|
|
22
|
+
import { PERL_METADATA_FILES, declaredPerlFloor, readPerlMetadata } from './ci.js';
|
|
23
|
+
import { PERL_HOOKS_DIR } from './git-hooks.js';
|
|
24
|
+
import { PERL_GITIGNORE_SENTINELS } from './gitignore.js';
|
|
25
|
+
const PERL_FILE_CHECKS = [
|
|
26
|
+
{
|
|
27
|
+
check: 'Perl::Critic',
|
|
28
|
+
// Both spellings are read by perlcritic; the dotted one is conventional.
|
|
29
|
+
candidates: ['.perlcriticrc', 'perlcriticrc'],
|
|
30
|
+
expected: 'is a valid Perl::Critic configuration',
|
|
31
|
+
// A `severity`/`verbose`-style setting, or a `[Policy::Name]` block.
|
|
32
|
+
matcher: /^\s*(severity|verbose|theme|only|include|exclude|profile-strictness)\s*=|^\s*\[[-\w:]+\]/m,
|
|
33
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix perlcritic` to scaffold',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
check: 'perltidy',
|
|
37
|
+
candidates: ['.perltidyrc', 'perltidyrc'],
|
|
38
|
+
expected: 'is a valid perltidy configuration',
|
|
39
|
+
// perltidyrc is a list of command-line switches, one per line.
|
|
40
|
+
matcher: /^\s*-{1,2}[a-zA-Z]/m,
|
|
41
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix perltidy` to scaffold',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* Distribution metadata, the Perl shape of the `Package.swift` and
|
|
46
|
+
* `pyproject.toml` checks. Both signals are things no tool infers for you:
|
|
47
|
+
* without a metadata file there is nothing for cpanm to resolve dependencies
|
|
48
|
+
* from, and without a declared perl floor cpanm installs the distribution on
|
|
49
|
+
* an interpreter that cannot compile it — the failure lands on the user, at
|
|
50
|
+
* `use` time, as a syntax error rather than a version complaint.
|
|
51
|
+
*/
|
|
52
|
+
export async function checkPerlDistribution(dir) {
|
|
53
|
+
const check = 'Perl distribution';
|
|
54
|
+
const metadata = await readPerlMetadata(dir);
|
|
55
|
+
if (!metadata) {
|
|
56
|
+
return {
|
|
57
|
+
check,
|
|
58
|
+
status: 'missing',
|
|
59
|
+
detail: `no ${PERL_METADATA_FILES.join(' / ')}`,
|
|
60
|
+
hint: "Add a `cpanfile` declaring the distribution's dependencies — `requires 'perl', '5.036';` at minimum",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const floor = declaredPerlFloor(metadata.contents);
|
|
64
|
+
if (!floor) {
|
|
65
|
+
return {
|
|
66
|
+
check,
|
|
67
|
+
status: 'drift',
|
|
68
|
+
detail: `${metadata.file} declares no minimum perl version`,
|
|
69
|
+
hint: metadata.file === 'cpanfile'
|
|
70
|
+
? "Add `requires 'perl', '5.036';` — without it cpanm installs on interpreters that cannot compile the code"
|
|
71
|
+
: 'Declare a minimum perl (`MIN_PERL_VERSION` in Makefile.PL, `perl` under [Prereqs] in dist.ini) — without it cpanm installs on interpreters that cannot compile the code',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
check,
|
|
76
|
+
status: 'ok',
|
|
77
|
+
detail: `${metadata.file} declares a minimum perl of ${floor}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function checkPerlGitignore(dir) {
|
|
81
|
+
const check = 'Perl .gitignore';
|
|
82
|
+
const filepath = path.join(dir, '.gitignore');
|
|
83
|
+
if (!(await fs.pathExists(filepath))) {
|
|
84
|
+
return {
|
|
85
|
+
check,
|
|
86
|
+
status: 'missing',
|
|
87
|
+
detail: 'no .gitignore',
|
|
88
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix perl-gitignore` to scaffold the Perl template',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const contents = await fs.readFile(filepath, 'utf-8');
|
|
92
|
+
const missing = PERL_GITIGNORE_SENTINELS.filter((entry) => !contents.includes(entry));
|
|
93
|
+
if (missing.length > 0) {
|
|
94
|
+
return {
|
|
95
|
+
check,
|
|
96
|
+
status: 'drift',
|
|
97
|
+
detail: `.gitignore missing Perl build artefacts: ${missing.join(', ')}`,
|
|
98
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix perl-gitignore` to append the missing entries',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
check,
|
|
103
|
+
status: 'ok',
|
|
104
|
+
detail: `.gitignore covers ${PERL_GITIGNORE_SENTINELS.join(', ')}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** CI files that could be running the test suite, most likely first. */
|
|
108
|
+
async function ciFiles(dir) {
|
|
109
|
+
const candidates = ['.gitlab-ci.yml', '.gitlab-ci.yaml'];
|
|
110
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
111
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
112
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
113
|
+
candidates.unshift(...files.map((f) => path.join('.github', 'workflows', f)));
|
|
114
|
+
}
|
|
115
|
+
return candidates;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The test setup. Same two halves as the Swift and Python versions: a suite has
|
|
119
|
+
* to exist, and CI has to run it. `t/` is the universal convention — prove and
|
|
120
|
+
* every build system default to it — so unlike Python there is no second layout
|
|
121
|
+
* to look for. `xt/` counts too: author tests are still tests.
|
|
122
|
+
*/
|
|
123
|
+
export async function checkPerlTests(dir) {
|
|
124
|
+
const check = 'Perl tests';
|
|
125
|
+
let tests = null;
|
|
126
|
+
for (const candidate of ['t', 'xt']) {
|
|
127
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
128
|
+
tests = `${candidate}/`;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!tests) {
|
|
133
|
+
return {
|
|
134
|
+
check,
|
|
135
|
+
status: 'missing',
|
|
136
|
+
detail: 'no t/ directory',
|
|
137
|
+
hint: 'Add a `t/` directory of `.t` files — `prove -lr t` is what CI runs',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
for (const candidate of await ciFiles(dir)) {
|
|
141
|
+
const filepath = path.join(dir, candidate);
|
|
142
|
+
if (!(await fs.pathExists(filepath)))
|
|
143
|
+
continue;
|
|
144
|
+
// `prove` is the harness; `make test` / `dzil test` are the build systems'
|
|
145
|
+
// wrappers around the same thing, and all three count as running the suite.
|
|
146
|
+
if (/\b(prove|make test|dzil test)\b/.test(await fs.readFile(filepath, 'utf-8'))) {
|
|
147
|
+
return { check, status: 'ok', detail: `${tests} found and run by ${candidate}` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
check,
|
|
152
|
+
status: 'drift',
|
|
153
|
+
detail: `${tests} found but no CI job runs the test suite`,
|
|
154
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix perl-ci` to regenerate a workflow that runs `prove`',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The Perl shape of the base `Git hooks` / `Pre-push hook` checks (#309).
|
|
159
|
+
* `install` is null for the same reason Swift's and Python's are: the wiring —
|
|
160
|
+
* `git config core.hooksPath` — is per-clone local state that nothing commits,
|
|
161
|
+
* so flagging its absence would fail every fresh CI checkout.
|
|
162
|
+
*/
|
|
163
|
+
export const PERL_GIT_HOOKS = {
|
|
164
|
+
dir: PERL_HOOKS_DIR,
|
|
165
|
+
install: null,
|
|
166
|
+
verifyCommand: 'prove -lr t',
|
|
167
|
+
fixTarget: 'perl-git-hooks',
|
|
168
|
+
};
|
|
169
|
+
/** The Perl module's suite, layered on top of the base checks by doctor. */
|
|
170
|
+
export async function runPerlChecks(dir) {
|
|
171
|
+
return [
|
|
172
|
+
await checkPerlDistribution(dir),
|
|
173
|
+
...(await Promise.all(PERL_FILE_CHECKS.map((spec) => checkFile(dir, spec)))),
|
|
174
|
+
await checkPerlGitignore(dir),
|
|
175
|
+
await checkPerlTests(dir),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perl CI generation (#289), built on the language-agnostic skeleton in
|
|
3
|
+
* `src/base/ci.ts`. No `setup-node`, no `pnpm` — the Perl path runs on Linux
|
|
4
|
+
* with `shogo82148/actions-setup-perl` and cpanm.
|
|
5
|
+
*
|
|
6
|
+
* Like the Swift and Python paths there's no `ProjectConfig` to read: the one
|
|
7
|
+
* fact CI needs beyond the fixed job shapes — which interpreters to test on —
|
|
8
|
+
* is declared in the distribution's own metadata, so the matrix is derived
|
|
9
|
+
* from the repo itself.
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import { renderGitHubWorkflow, renderGitLabCI } from '../../base/ci.js';
|
|
14
|
+
import { FIND_PERL_SOURCES } from './sources.js';
|
|
15
|
+
/**
|
|
16
|
+
* The newest interpreter the emitted matrix tests against, and the one the
|
|
17
|
+
* single-interpreter lint job runs on.
|
|
18
|
+
*
|
|
19
|
+
* ponytail: a constant, bumped by hand when perl ships a stable minor (they
|
|
20
|
+
* land roughly annually, even minors only). Resolving "latest" at generation
|
|
21
|
+
* time would need a network call from a generator whose whole job is emitting
|
|
22
|
+
* text.
|
|
23
|
+
*/
|
|
24
|
+
const LATEST_PERL = '5.42';
|
|
25
|
+
/**
|
|
26
|
+
* The oldest interpreter to assume when the distribution declares no floor.
|
|
27
|
+
* Debian stable's perl — the oldest one a consumer is actually likely to be
|
|
28
|
+
* running, as opposed to the oldest one still theoretically supported.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_FLOOR = '5.36';
|
|
31
|
+
const minorOf = (version) => Number.parseInt(version.split('.')[1] ?? '0', 10);
|
|
32
|
+
/**
|
|
33
|
+
* Normalise any of the forms a perl version is written in to `major.minor`.
|
|
34
|
+
*
|
|
35
|
+
* Perl has three spellings of the same thing and metadata files use all of
|
|
36
|
+
* them: `5.36`, the v-string `v5.36.0`, and the *packed decimal* `5.036000`,
|
|
37
|
+
* where the minor is the first three digits after the point. Parsed naively,
|
|
38
|
+
* that last one reads as minor 36000 — which would emit a matrix entry for a
|
|
39
|
+
* perl that does not exist and fail every run.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizePerlVersion(raw) {
|
|
42
|
+
const match = /^v?(\d+)\.(\d+)/.exec(raw.trim());
|
|
43
|
+
if (!match?.[1] || !match[2])
|
|
44
|
+
return null;
|
|
45
|
+
const [, major, fraction] = match;
|
|
46
|
+
// 3+ digits means the packed form (5.036000 → 5.36); fewer is already plain.
|
|
47
|
+
const minor = fraction.length >= 3 ? Number.parseInt(fraction.slice(0, 3), 10) : Number.parseInt(fraction, 10);
|
|
48
|
+
return `${major}.${minor}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The declared minimum perl, or null when the distribution names none.
|
|
52
|
+
*
|
|
53
|
+
* A regex rather than parsing Perl: `cpanfile`, `Makefile.PL` and `Build.PL`
|
|
54
|
+
* are all *executable Perl*, so reading them properly means running arbitrary
|
|
55
|
+
* code from the audited repo — which `doctor` must never do. This is one key.
|
|
56
|
+
*/
|
|
57
|
+
export function declaredPerlFloor(contents) {
|
|
58
|
+
const raw =
|
|
59
|
+
// cpanfile: `requires 'perl', '5.036';` / `requires 'perl' => '5.036000';`
|
|
60
|
+
/requires\s*\(?\s*['"]perl['"]\s*(?:,|=>)\s*['"]?v?([\d._]+)/.exec(contents)?.[1] ??
|
|
61
|
+
// Makefile.PL: `MIN_PERL_VERSION => '5.036'`
|
|
62
|
+
/MIN_PERL_VERSION\s*=>\s*['"]?v?([\d._]+)/.exec(contents)?.[1] ??
|
|
63
|
+
// Build.PL: `requires => { perl => '5.036' }`
|
|
64
|
+
/\bperl\s*=>\s*['"]?v?([\d._]+)/.exec(contents)?.[1] ??
|
|
65
|
+
// dist.ini: `perl = 5.036` under [Prereqs]
|
|
66
|
+
/^\s*perl\s*=\s*['"]?v?([\d._]+)/m.exec(contents)?.[1];
|
|
67
|
+
return raw ? normalizePerlVersion(raw) : null;
|
|
68
|
+
}
|
|
69
|
+
export function parsePerlProject(contents) {
|
|
70
|
+
const floor = declaredPerlFloor(contents) ?? DEFAULT_FLOOR;
|
|
71
|
+
// A floor at or above the newest release we know about collapses the matrix
|
|
72
|
+
// to one entry rather than emitting a version that doesn't exist yet.
|
|
73
|
+
const versions = minorOf(floor) >= minorOf(LATEST_PERL) ? [floor] : [floor, LATEST_PERL];
|
|
74
|
+
return { perlVersions: versions };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Files that can carry a distribution's metadata, most authoritative first.
|
|
78
|
+
* `cpanfile` is the modern one; the `.PL` scripts are the older build systems,
|
|
79
|
+
* and `dist.ini` is Dist::Zilla, which generates the others at release time.
|
|
80
|
+
*/
|
|
81
|
+
export const PERL_METADATA_FILES = ['cpanfile', 'Makefile.PL', 'Build.PL', 'dist.ini'];
|
|
82
|
+
/** The first metadata file present, with its contents. Null when none exists. */
|
|
83
|
+
export async function readPerlMetadata(dir) {
|
|
84
|
+
for (const file of PERL_METADATA_FILES) {
|
|
85
|
+
const filepath = path.join(dir, file);
|
|
86
|
+
if (!(await fs.pathExists(filepath)))
|
|
87
|
+
continue;
|
|
88
|
+
return { file, contents: await fs.readFile(filepath, 'utf-8') };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export async function readPerlProject(dir) {
|
|
93
|
+
const metadata = await readPerlMetadata(dir);
|
|
94
|
+
if (!metadata)
|
|
95
|
+
return { perlVersions: [DEFAULT_FLOOR, LATEST_PERL] };
|
|
96
|
+
return parsePerlProject(metadata.contents);
|
|
97
|
+
}
|
|
98
|
+
/** Checkout + interpreter. cpanm comes from the setup action, not a separate step. */
|
|
99
|
+
function perlSetup(version) {
|
|
100
|
+
return ` - name: 📦 Checkout repository
|
|
101
|
+
uses: actions/checkout@v7
|
|
102
|
+
|
|
103
|
+
- name: 🐪 Set up Perl ${version}
|
|
104
|
+
uses: shogo82148/actions-setup-perl@v1
|
|
105
|
+
with:
|
|
106
|
+
perl-version: '${version}'
|
|
107
|
+
install-modules-with: cpanm`;
|
|
108
|
+
}
|
|
109
|
+
export function perlGithubJobs(project) {
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
id: 'lint',
|
|
113
|
+
// Perl::Critic is static, so the distribution's own dependencies are not
|
|
114
|
+
// installed here — the lint job stays fast and can't fail on a CPAN
|
|
115
|
+
// mirror hiccup that has nothing to do with the code.
|
|
116
|
+
steps: `${perlSetup(LATEST_PERL)}
|
|
117
|
+
|
|
118
|
+
- name: 📦 Install Perl::Critic and Perl::Tidy
|
|
119
|
+
run: cpanm --notest Perl::Critic Perl::Tidy
|
|
120
|
+
|
|
121
|
+
- name: 🔍 perlcritic
|
|
122
|
+
# A block scalar, not a plain one: the find expression carries quotes,
|
|
123
|
+
# parentheses and braces that a plain YAML scalar would make fragile.
|
|
124
|
+
run: |
|
|
125
|
+
${FIND_PERL_SOURCES} -exec perlcritic {} +
|
|
126
|
+
|
|
127
|
+
- name: 🎨 perltidy --check
|
|
128
|
+
# perltidy has no non-rewriting check mode that reports *which* files
|
|
129
|
+
# are untidy, so this tidies in place and lets git report the delta.
|
|
130
|
+
# The diff in the log is the fix, ready to apply.
|
|
131
|
+
run: |
|
|
132
|
+
${FIND_PERL_SOURCES} -exec perltidy -b -bext='/' {} +
|
|
133
|
+
git diff --exit-code`,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'test',
|
|
137
|
+
extra: ` name: prove \${{ matrix.perl-version }}
|
|
138
|
+
strategy:
|
|
139
|
+
fail-fast: false
|
|
140
|
+
matrix:
|
|
141
|
+
perl-version:
|
|
142
|
+
${project.perlVersions.map((v) => ` - '${v}'`).join('\n')}`,
|
|
143
|
+
steps: ` - name: 📦 Checkout repository
|
|
144
|
+
uses: actions/checkout@v7
|
|
145
|
+
|
|
146
|
+
- name: 🐪 Set up Perl \${{ matrix.perl-version }}
|
|
147
|
+
uses: shogo82148/actions-setup-perl@v1
|
|
148
|
+
with:
|
|
149
|
+
perl-version: \${{ matrix.perl-version }}
|
|
150
|
+
install-modules-with: cpanm
|
|
151
|
+
|
|
152
|
+
- name: 📦 Install dependencies
|
|
153
|
+
run: cpanm --installdeps --notest .
|
|
154
|
+
|
|
155
|
+
- name: 🧪 prove
|
|
156
|
+
run: prove -lr t`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
export function renderPerlWorkflow(project) {
|
|
161
|
+
return renderGitHubWorkflow(perlGithubJobs(project));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* GitLab runs the same commands in the official Perl image. The matrix is
|
|
165
|
+
* dropped for the same reason Python's is: GitLab's `parallel:matrix` would
|
|
166
|
+
* need a per-job image override, and a mirrored repo is a secondary pipeline —
|
|
167
|
+
* the version sweep lives on the GitHub side.
|
|
168
|
+
*/
|
|
169
|
+
function perlGitlabSpec(project) {
|
|
170
|
+
const image = `perl:${project.perlVersions.at(-1) ?? LATEST_PERL}`;
|
|
171
|
+
return {
|
|
172
|
+
image,
|
|
173
|
+
preamble: `variables:
|
|
174
|
+
PERL_CPANM_OPT: "--notest"`,
|
|
175
|
+
jobs: [
|
|
176
|
+
{
|
|
177
|
+
id: 'lint',
|
|
178
|
+
stage: 'lint',
|
|
179
|
+
script: ['cpanm --notest Perl::Critic', `${FIND_PERL_SOURCES} -exec perlcritic {} +`],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'test',
|
|
183
|
+
stage: 'test',
|
|
184
|
+
script: ['cpanm --installdeps --notest .', 'prove -lr t'],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function renderPerlGitLabCI(project) {
|
|
190
|
+
return renderGitLabCI(perlGitlabSpec(project));
|
|
191
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perl language module — fixers (#289). One per check in ./checks.ts.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: no `perl-lockfile` fixer, for the same reason the Python module has
|
|
5
|
+
* none (#290). Swift's writes the config `setup --preset swift-library` would
|
|
6
|
+
* have produced, and there is no `perl-library` preset. Inventing a
|
|
7
|
+
* ProjectConfig here would put a fabricated set of JS tool choices in a Perl
|
|
8
|
+
* distribution's lockfile — worse than doctor reporting it absent, which is
|
|
9
|
+
* true.
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
15
|
+
import { readPerlProject, renderPerlGitLabCI, renderPerlWorkflow } from './ci.js';
|
|
16
|
+
import { PERL_HOOKS_DIR, installPerlGitHooks } from './git-hooks.js';
|
|
17
|
+
import { ensurePerlGitignore } from './gitignore.js';
|
|
18
|
+
export const PERL_FIXERS = [
|
|
19
|
+
{
|
|
20
|
+
target: 'perlcritic',
|
|
21
|
+
description: 'Scaffold .perlcriticrc (Perl::Critic at severity 3, with the family exceptions)',
|
|
22
|
+
appliesTo: ['Perl::Critic'],
|
|
23
|
+
outputs: ['.perlcriticrc'],
|
|
24
|
+
canFixDrift: true,
|
|
25
|
+
async run({ targetDir }) {
|
|
26
|
+
const result = await copyPreset('perlcritic', targetDir);
|
|
27
|
+
return { filesWritten: [result.target] };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
target: 'perltidy',
|
|
32
|
+
description: 'Scaffold .perltidyrc (Perl Best Practices layout at 100 columns)',
|
|
33
|
+
appliesTo: ['perltidy'],
|
|
34
|
+
outputs: ['.perltidyrc'],
|
|
35
|
+
canFixDrift: true,
|
|
36
|
+
async run({ targetDir }) {
|
|
37
|
+
const result = await copyPreset('perltidy', targetDir);
|
|
38
|
+
return { filesWritten: [result.target] };
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
target: 'perl-gitignore',
|
|
43
|
+
description: 'Add the Perl build artefacts (blib, MYMETA.*, pm_to_blib, local/) to .gitignore',
|
|
44
|
+
appliesTo: ['Perl .gitignore'],
|
|
45
|
+
// Appends what's missing; never clobbers a project's own entries.
|
|
46
|
+
riskLevel: 'safe-merge',
|
|
47
|
+
outputs: ['.gitignore'],
|
|
48
|
+
canFixDrift: true,
|
|
49
|
+
async run({ targetDir }) {
|
|
50
|
+
return { filesWritten: await ensurePerlGitignore(targetDir) };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
target: 'perl-git-hooks',
|
|
55
|
+
description: `Scaffold ${PERL_HOOKS_DIR}/pre-commit + pre-push (perltidy, perlcritic, prove) and point git at them via core.hooksPath`,
|
|
56
|
+
appliesTo: ['Git hooks', 'Pre-push hook'],
|
|
57
|
+
outputs: [`${PERL_HOOKS_DIR}/pre-commit`, `${PERL_HOOKS_DIR}/pre-push`],
|
|
58
|
+
canFixDrift: true,
|
|
59
|
+
async run({ targetDir }) {
|
|
60
|
+
const { filesWritten, hooksPathSet } = await installPerlGitHooks(targetDir);
|
|
61
|
+
console.error(hooksPathSet
|
|
62
|
+
? chalk.dim(` git config core.hooksPath ${PERL_HOOKS_DIR}`)
|
|
63
|
+
: chalk.yellow(` run \`git config core.hooksPath ${PERL_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
|
|
64
|
+
return { filesWritten };
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
target: 'perl-ci',
|
|
69
|
+
description: 'Scaffold .github/workflows/ci.yml for Perl (perlcritic, perltidy, prove across the supported interpreters)',
|
|
70
|
+
appliesTo: ['GitHub Actions'],
|
|
71
|
+
outputs: ['.github/workflows/ci.yml'],
|
|
72
|
+
canFixDrift: true,
|
|
73
|
+
async run({ targetDir }) {
|
|
74
|
+
const workflowsDir = path.join(targetDir, '.github', 'workflows');
|
|
75
|
+
await fs.ensureDir(workflowsDir);
|
|
76
|
+
const workflow = renderPerlWorkflow(await readPerlProject(targetDir));
|
|
77
|
+
await fs.writeFile(path.join(workflowsDir, 'ci.yml'), workflow);
|
|
78
|
+
return { filesWritten: ['.github/workflows/ci.yml'] };
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
target: 'perl-gitlab-ci',
|
|
83
|
+
description: 'Scaffold .gitlab-ci.yml for Perl (perlcritic + prove on the official Perl image)',
|
|
84
|
+
appliesTo: ['GitLab CI'],
|
|
85
|
+
outputs: ['.gitlab-ci.yml'],
|
|
86
|
+
canFixDrift: true,
|
|
87
|
+
async run({ targetDir }) {
|
|
88
|
+
const ci = renderPerlGitLabCI(await readPerlProject(targetDir));
|
|
89
|
+
await fs.writeFile(path.join(targetDir, '.gitlab-ci.yml'), ci);
|
|
90
|
+
return { filesWritten: ['.gitlab-ci.yml'] };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perl git hooks (#289). Same shape as the Swift (#309) and Python (#290) ones
|
|
3
|
+
* and for the same reason: Husky is an npm package, and a Perl distribution has
|
|
4
|
+
* no node on the path to run it. The committed `.githooks/` directory plus
|
|
5
|
+
* `core.hooksPath` is the node-free equivalent; the mechanics live in
|
|
6
|
+
* `src/base/git-hooks.ts`.
|
|
7
|
+
*
|
|
8
|
+
* The hooks call perltidy/perlcritic/prove directly rather than going through
|
|
9
|
+
* `dzil` or a `Makefile` target — those exist only in some distributions, and a
|
|
10
|
+
* hook that assumes the wrong build system fails on every commit.
|
|
11
|
+
*/
|
|
12
|
+
import { installGitHooks } from '../../base/git-hooks.js';
|
|
13
|
+
import { FIND_PERL_SOURCES } from './sources.js';
|
|
14
|
+
export const PERL_HOOKS_DIR = '.githooks';
|
|
15
|
+
/** Wraps the shared find expression so each hook can name the action once. */
|
|
16
|
+
const FIND_PERL_FN = `# Every Perl source file in the distribution, minus build output and deps.
|
|
17
|
+
find_perl() {
|
|
18
|
+
${FIND_PERL_SOURCES} "$@"
|
|
19
|
+
}`;
|
|
20
|
+
// perltidy before perlcritic so the lint pass sees the final layout. The two
|
|
21
|
+
// never fight, unlike SwiftLint/SwiftFormat or ruff/black: Perl::Critic only
|
|
22
|
+
// reports, it never rewrites.
|
|
23
|
+
//
|
|
24
|
+
// `-b -bext='/'` means in-place with no backup file. Both flags are passed here
|
|
25
|
+
// rather than baked into the shipped .perltidyrc, so a human running `perltidy
|
|
26
|
+
// lib/Foo.pm` by hand still gets Foo.pm.tdy instead of a silent overwrite.
|
|
27
|
+
const PERL_PRE_COMMIT = `#!/bin/sh
|
|
28
|
+
set -e
|
|
29
|
+
|
|
30
|
+
${FIND_PERL_FN}
|
|
31
|
+
|
|
32
|
+
find_perl -exec perltidy -b -bext='/' {} +
|
|
33
|
+
find_perl -exec perlcritic {} +
|
|
34
|
+
`;
|
|
35
|
+
// perlcritic before prove: a lint violation is cheaper to surface than a full
|
|
36
|
+
// test run. `prove -lr t` puts ./lib on @INC and recurses into subdirectories,
|
|
37
|
+
// which is what a distribution's t/ tree needs.
|
|
38
|
+
const PERL_PRE_PUSH = `#!/bin/sh
|
|
39
|
+
set -e
|
|
40
|
+
echo "🔍 Running pre-push verify..."
|
|
41
|
+
|
|
42
|
+
${FIND_PERL_FN}
|
|
43
|
+
|
|
44
|
+
find_perl -exec perlcritic {} +
|
|
45
|
+
prove -lr t
|
|
46
|
+
echo "✅ Verify passed — pushing."
|
|
47
|
+
`;
|
|
48
|
+
export function installPerlGitHooks(targetDir) {
|
|
49
|
+
return installGitHooks(targetDir, PERL_HOOKS_DIR, {
|
|
50
|
+
preCommit: PERL_PRE_COMMIT,
|
|
51
|
+
prePush: PERL_PRE_PUSH,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Perl .gitignore block, shared by the `perl-gitignore` fixer and the check
|
|
3
|
+
* that reports it. Its own module for the same reason Swift's and Python's are
|
|
4
|
+
* (#286, #290): nothing else in the module has to import from `fixers.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
/**
|
|
9
|
+
* The Perl build artefacts that must stay out of git. Appended to an existing
|
|
10
|
+
* .gitignore rather than replacing it — a Perl distribution's ignore file
|
|
11
|
+
* usually carries project-specific entries worth keeping.
|
|
12
|
+
*/
|
|
13
|
+
const PERL_GITIGNORE_BLOCK = `# Perl build output
|
|
14
|
+
/blib/
|
|
15
|
+
/_build/
|
|
16
|
+
/Build
|
|
17
|
+
/Build.bat
|
|
18
|
+
pm_to_blib
|
|
19
|
+
MYMETA.*
|
|
20
|
+
Makefile
|
|
21
|
+
Makefile.old
|
|
22
|
+
/_eumm/
|
|
23
|
+
|
|
24
|
+
# Distribution tarballs
|
|
25
|
+
/*.tar.gz
|
|
26
|
+
/*.zip
|
|
27
|
+
|
|
28
|
+
# Dependencies (Carton / local::lib)
|
|
29
|
+
/local/
|
|
30
|
+
/.carton/
|
|
31
|
+
|
|
32
|
+
# Test and profiling output
|
|
33
|
+
/cover_db/
|
|
34
|
+
nytprof.out
|
|
35
|
+
/tmon.out
|
|
36
|
+
|
|
37
|
+
# Compiled XS
|
|
38
|
+
*.o
|
|
39
|
+
*.bs
|
|
40
|
+
*.c.tmp
|
|
41
|
+
`;
|
|
42
|
+
/**
|
|
43
|
+
* Entries whose presence means the block (or an equivalent) is already there.
|
|
44
|
+
* All three are produced by any Makefile.PL/Build.PL build, so they are the
|
|
45
|
+
* ones a repo accidentally commits. `local/` is deliberately not a sentinel:
|
|
46
|
+
* only Carton users have it, and demanding it would nag every other repo.
|
|
47
|
+
*/
|
|
48
|
+
export const PERL_GITIGNORE_SENTINELS = ['blib', 'MYMETA', 'pm_to_blib'];
|
|
49
|
+
export async function ensurePerlGitignore(targetDir) {
|
|
50
|
+
const filepath = path.join(targetDir, '.gitignore');
|
|
51
|
+
if (!(await fs.pathExists(filepath))) {
|
|
52
|
+
await fs.writeFile(filepath, PERL_GITIGNORE_BLOCK);
|
|
53
|
+
return ['.gitignore'];
|
|
54
|
+
}
|
|
55
|
+
const existing = await fs.readFile(filepath, 'utf-8');
|
|
56
|
+
const missing = PERL_GITIGNORE_SENTINELS.filter((entry) => !existing.includes(entry));
|
|
57
|
+
if (missing.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
// Append only what's absent, so a repo that already ignores blib doesn't get
|
|
60
|
+
// a duplicate entry for it.
|
|
61
|
+
const additions = PERL_GITIGNORE_BLOCK.split('\n').filter((line) => line.length > 0 && !existing.includes(line));
|
|
62
|
+
const separator = existing.endsWith('\n') ? '' : '\n';
|
|
63
|
+
await fs.writeFile(filepath, `${existing}${separator}\n${additions.join('\n')}\n`);
|
|
64
|
+
return ['.gitignore'];
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this module enumerates a distribution's Perl source files.
|
|
3
|
+
*
|
|
4
|
+
* One definition, because the pre-commit hook and both CI lint jobs have to
|
|
5
|
+
* cover the *same* set: if they drift, a commit passes the hook and then fails
|
|
6
|
+
* CI over a file one of them never looked at.
|
|
7
|
+
*
|
|
8
|
+
* Rather than `lib t`, which assumes the standard CPAN layout — plenty of
|
|
9
|
+
* distributions keep scripts in `bin/` or `script/`, and a repo with no `lib/`
|
|
10
|
+
* would make perlcritic exit non-zero for a missing path rather than a real
|
|
11
|
+
* violation.
|
|
12
|
+
*
|
|
13
|
+
* The caller appends the action, always as `-exec … +` and never a pipe into
|
|
14
|
+
* xargs. With no matches `-exec … +` runs nothing at all, while *GNU* xargs —
|
|
15
|
+
* which is what the Linux CI runners have — runs the command once with no
|
|
16
|
+
* arguments unless given `-r`. perltidy with no file arguments reads stdin, so
|
|
17
|
+
* that lands as a lint job hanging until the job timeout with no output.
|
|
18
|
+
* (BSD/macOS xargs already declines to run on empty input, which is exactly
|
|
19
|
+
* how the bug would survive local testing and only show up in CI.) `-exec … +`
|
|
20
|
+
* is POSIX and behaves the same everywhere, with no flag to remember.
|
|
21
|
+
*/
|
|
22
|
+
export const FIND_PERL_SOURCES = `find . \\( -name blib -o -name local -o -name _build -o -name .git \\) -prune -o -type f \\( -name '*.pm' -o -name '*.pl' -o -name '*.t' \\)`;
|
|
@@ -69,7 +69,7 @@ export const PYTHON_FIXERS = [
|
|
|
69
69
|
canFixDrift: true,
|
|
70
70
|
async run({ targetDir }) {
|
|
71
71
|
const { filesWritten, hooksPathSet } = await installPythonGitHooks(targetDir);
|
|
72
|
-
console.
|
|
72
|
+
console.error(hooksPathSet
|
|
73
73
|
? chalk.dim(` git config core.hooksPath ${PYTHON_HOOKS_DIR}`)
|
|
74
74
|
: chalk.yellow(` run \`git config core.hooksPath ${PYTHON_HOOKS_DIR}\` once per clone — it's local git config, not a committed file`));
|
|
75
75
|
return { filesWritten };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/repo-tooling",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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": [
|
|
@@ -105,6 +105,8 @@
|
|
|
105
105
|
"tooling/swift/*.json",
|
|
106
106
|
"tooling/python/*.toml",
|
|
107
107
|
"tooling/python/*.ini",
|
|
108
|
+
"tooling/perl/perlcriticrc",
|
|
109
|
+
"tooling/perl/perltidyrc",
|
|
108
110
|
"tooling/github-actions/workflows/*.yml",
|
|
109
111
|
"README.md",
|
|
110
112
|
"AGENTS.md"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# .perlcriticrc — @rtorcato/repo-tooling Perl preset
|
|
2
|
+
#
|
|
3
|
+
# Perl::Critic is the linter; perltidy (see .perltidyrc) is the formatter.
|
|
4
|
+
# They do not overlap: Perl::Critic never rewrites source, so unlike the
|
|
5
|
+
# SwiftLint/SwiftFormat and ruff/black pairings there is no second rewriting
|
|
6
|
+
# tool to fight — see src/languages/perl/checks.ts.
|
|
7
|
+
|
|
8
|
+
# Severity 3 ("harsh") and above. Severity 1 turns on the whole Perl Best
|
|
9
|
+
# Practices book, including rules modern code deliberately breaks; 4-5 catch
|
|
10
|
+
# so little they stop functioning as a gate.
|
|
11
|
+
severity = 3
|
|
12
|
+
|
|
13
|
+
# One violation per line with a file:line:column prefix, which both an editor
|
|
14
|
+
# and a CI log annotator can parse.
|
|
15
|
+
verbose = %f:%l:%c %m (%p, severity %s)\n
|
|
16
|
+
|
|
17
|
+
# --- Rule adjustments -------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
# RequireVersionVar wants a $VERSION in every .pm. Distributions that carry the
|
|
20
|
+
# version in their metadata (Dist::Zilla) or in the main module alone would
|
|
21
|
+
# have to repeat it in every file to satisfy it.
|
|
22
|
+
[-Modules::RequireVersionVar]
|
|
23
|
+
|
|
24
|
+
# RequirePodSections enforces a fixed POD skeleton in every file, including
|
|
25
|
+
# private modules that have no public interface to document. The POD that
|
|
26
|
+
# matters is on the distribution's entry points, which this cannot tell apart.
|
|
27
|
+
[-Documentation::RequirePodSections]
|
|
28
|
+
|
|
29
|
+
# These modules enable strict and warnings for you, so requiring the explicit
|
|
30
|
+
# pragma alongside them is a false positive.
|
|
31
|
+
[TestingAndDebugging::RequireUseStrict]
|
|
32
|
+
equivalent_modules = Moose Moo Mojo::Base Test2::V0
|
|
33
|
+
|
|
34
|
+
[TestingAndDebugging::RequireUseWarnings]
|
|
35
|
+
equivalent_modules = Moose Moo Mojo::Base Test2::V0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# .perltidyrc — @rtorcato/repo-tooling Perl preset
|
|
2
|
+
#
|
|
3
|
+
# perltidy is the formatter; Perl::Critic (see .perlcriticrc) is the linter.
|
|
4
|
+
#
|
|
5
|
+
# Order matters in this file: -pbp is a bundle that sets many options at once,
|
|
6
|
+
# so every deviation from it has to come afterwards to win.
|
|
7
|
+
|
|
8
|
+
# Perl Best Practices as the base: 4-column indents, no cuddled else,
|
|
9
|
+
# consistent operator spacing.
|
|
10
|
+
-pbp
|
|
11
|
+
|
|
12
|
+
# 100 columns rather than PBP's 78 — the same width the JavaScript and Swift
|
|
13
|
+
# presets in this distribution use, so a polyglot repo wraps consistently.
|
|
14
|
+
-l=100
|
|
15
|
+
|
|
16
|
+
# Keep up to two consecutive blank lines instead of collapsing to one:
|
|
17
|
+
# deliberate vertical grouping inside a long sub is worth preserving.
|
|
18
|
+
-mbl=2
|
|
19
|
+
|
|
20
|
+
# Deliberately NOT set here: -b (in-place edit) and -bext. They change what a
|
|
21
|
+
# bare `perltidy lib/Foo.pm` does for a human running it by hand — it would
|
|
22
|
+
# silently rewrite the file instead of producing Foo.pm.tdy. The pre-commit
|
|
23
|
+
# hook and the CI job pass those flags explicitly, where in-place is the point.
|