@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.
@@ -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' \\)`;