@rtorcato/repo-tooling 3.5.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/base/git-hooks.js +43 -0
- package/dist/cli/commands/doctor.js +64 -8
- package/dist/cli/commands/fix-targets.js +38 -4
- package/dist/cli/commands/fix.js +19 -6
- package/dist/cli/index.js +30 -0
- package/dist/cli/utils/copy-preset.js +25 -0
- package/dist/languages/js/fixers.js +15 -15
- 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/checks.js +234 -0
- package/dist/languages/python/ci.js +153 -0
- package/dist/languages/python/fixers.js +104 -0
- package/dist/languages/python/git-hooks.js +35 -0
- package/dist/languages/python/gitignore.js +55 -0
- package/dist/languages/registry.js +2 -2
- package/dist/languages/swift/fixers.js +2 -2
- package/dist/languages/swift/git-hooks.js +7 -35
- package/package.json +5 -1
- package/tooling/perl/perlcriticrc +35 -0
- package/tooling/perl/perltidyrc +23 -0
- package/tooling/python/mypy.ini +21 -0
- package/tooling/python/pytest.ini +15 -0
- package/tooling/python/ruff.toml +32 -0
|
@@ -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' \\)`;
|